From 3862e6fa7c8aaec425d6c40f721b294c4e2f2bda Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 8 Jun 2026 07:33:54 +0200 Subject: [PATCH 01/43] feat: schedules --- datalayer_core/cli/__main__.py | 9 ++ datalayer_core/cli/commands/schedules.py | 135 +++++++++++++++++++++++ datalayer_core/utils/urls.py | 18 +++ 3 files changed, 162 insertions(+) create mode 100644 datalayer_core/cli/commands/schedules.py diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index 8413fcd8..9d887a2a 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -42,6 +42,7 @@ from datalayer_core.cli.commands.sandbox_snapshots import snapshots_ls from datalayer_core.cli.commands.runtimes import app as runtimes_app from datalayer_core.cli.commands.runtimes import runtimes_ls +from datalayer_core.cli.commands.schedules import app as schedules_app from datalayer_core.cli.commands.secrets import app as secrets_app from datalayer_core.cli.commands.secrets import secrets_ls from datalayer_core.cli.commands.subscription import app as subscription_app @@ -152,6 +153,11 @@ def main_callback( "--mcp-server-url", help="Override DATALAYER_MCP_SERVER_URL for this CLI invocation.", ), + scheduler_url: str | None = typer.Option( + None, + "--scheduler-url", + help="Override DATALAYER_SCHEDULER_URL for this CLI invocation.", + ), ) -> None: """Main callback to handle global options.""" overrides = { @@ -169,6 +175,7 @@ def main_callback( "DATALAYER_STATUS_URL": status_url, "DATALAYER_SUPPORT_URL": support_url, "DATALAYER_MCP_SERVER_URL": mcp_server_url, + "DATALAYER_SCHEDULER_URL": scheduler_url, } for env_name, value in overrides.items(): if value is not None: @@ -192,6 +199,7 @@ def main_callback( app.add_typer(pools_app) app.add_typer(ray_app) app.add_typer(runtimes_app) +app.add_typer(schedules_app) app.add_typer(secrets_app) app.add_typer(snapshots_app) app.add_typer(subscription_app) @@ -239,6 +247,7 @@ def main_callback( "--status-url", "--support-url", "--mcp-server-url", + "--scheduler-url", } _GLOBAL_OPTIONS_NO_VALUES = { diff --git a/datalayer_core/cli/commands/schedules.py b/datalayer_core/cli/commands/schedules.py new file mode 100644 index 00000000..93115bf6 --- /dev/null +++ b/datalayer_core/cli/commands/schedules.py @@ -0,0 +1,135 @@ +# Copyright (c) 2023-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""Schedule commands for Datalayer CLI.""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +import requests +import typer +from rich.console import Console +from rich.table import Table + +from datalayer_core.utils.urls import DatalayerURLs + + +app = typer.Typer( + name="schedules", + help="Scheduler management commands.", + invoke_without_command=True, +) + +console = Console() + + +@app.callback() +def schedules_callback(ctx: typer.Context) -> None: + """Scheduler management commands.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +def _resolve_token(token: Optional[str] = None) -> str: + if token: + return token + env_token = os.environ.get("DATALAYER_API_KEY") + if env_token: + return env_token + try: + from datalayer_core.client.client import DatalayerClient + + client = DatalayerClient() + return client._get_token() or "" + except Exception: + return "" + + +def _fetch_scheduler( + *, + path: str, + token: Optional[str] = None, + scheduler_url: Optional[str] = None, +) -> dict[str, Any]: + resolved_token = _resolve_token(token) + if not resolved_token: + raise RuntimeError( + "No authentication token found. Pass --token, set DATALAYER_API_KEY, or run 'datalayer login'." + ) + + urls = DatalayerURLs.from_environment(scheduler_url=scheduler_url) + url = f"{urls.scheduler_url}/api/scheduler/v1{path}" + headers = {"Authorization": f"Bearer {resolved_token}"} + + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() if response.content else {} + if not isinstance(data, dict): + raise RuntimeError("Unexpected scheduler response payload.") + return data + + +def _render_schedules(schedules: list[dict[str, Any]]) -> None: + table = Table(title="Schedules") + table.add_column("UID", style="cyan") + table.add_column("Notebook UID") + table.add_column("Cron") + table.add_column("Preset") + table.add_column("Enabled") + table.add_column("Next Planned") + + for schedule in schedules: + table.add_row( + str(schedule.get("uid", "")), + str(schedule.get("notebook_uid_s", "")), + str(schedule.get("cron_expression_s", "")), + str(schedule.get("preset_s", "")), + "yes" if bool(schedule.get("enabled_b", True)) else "no", + str(schedule.get("next_planned_ts_dt", "")), + ) + console.print(table) + + +def _render_runs(runs: list[dict[str, Any]]) -> None: + table = Table(title="Schedule Runs") + table.add_column("UID", style="cyan") + table.add_column("Schedule UID") + table.add_column("Notebook UID") + table.add_column("State") + table.add_column("Success") + table.add_column("Planned") + table.add_column("Executed") + + for run in runs: + table.add_row( + str(run.get("uid", "")), + str(run.get("schedule_uid_s", "")), + str(run.get("notebook_uid_s", "")), + str(run.get("state_s", "")), + str(run.get("success_b", "")), + str(run.get("planned_ts_dt", "")), + str(run.get("executed_ts_dt", "")), + ) + console.print(table) + + +@app.command(name="ls") +def list_schedules( + runs: bool = typer.Option(False, "--runs", help="List schedule runs instead of schedule definitions."), + token: Optional[str] = typer.Option(None, "--token", help="Authentication token."), + scheduler_url: Optional[str] = typer.Option(None, "--scheduler-url", help="Datalayer Scheduler service URL."), +) -> None: + """List scheduler definitions or scheduler runs.""" + try: + if runs: + payload = _fetch_scheduler(path="/schedules/runs", token=token, scheduler_url=scheduler_url) + _render_runs(payload.get("runs") or []) + return + + payload = _fetch_scheduler(path="/schedules", token=token, scheduler_url=scheduler_url) + _render_schedules(payload.get("schedules") or []) + except Exception as exc: + console.print(f"[red]Error listing schedules: {exc}[/red]") + raise typer.Exit(1) diff --git a/datalayer_core/utils/urls.py b/datalayer_core/utils/urls.py index f51f7cc2..12c0e513 100644 --- a/datalayer_core/utils/urls.py +++ b/datalayer_core/utils/urls.py @@ -48,6 +48,8 @@ DEFAULT_DATALAYER_SUPPORT_URL = DEFAULT_DATALAYER_RUN_URL +DEFAULT_DATALAYER_SCHEDULER_URL = DEFAULT_DATALAYER_RUN_URL + @dataclass class DatalayerURLs: @@ -89,6 +91,8 @@ class DatalayerURLs: The Datalayer MCP server service URL ray_url : str The Datalayer Ray service URL + scheduler_url : str + The Datalayer scheduler service URL """ run_url: str @@ -106,6 +110,7 @@ class DatalayerURLs: support_url: str mcp_server_url: str ray_url: str + scheduler_url: str @classmethod def from_environment( @@ -125,6 +130,7 @@ def from_environment( support_url: Optional[str] = None, mcp_server_url: Optional[str] = None, ray_url: Optional[str] = None, + scheduler_url: Optional[str] = None, ) -> "DatalayerURLs": """ Create DatalayerURLs instance from environment variables and parameters. @@ -176,6 +182,9 @@ def from_environment( ray_url : Optional[str] Override for the Ray URL. If None, will check DATALAYER_RAY_URL env var then fallback to DEFAULT_DATALAYER_RAY_URL. + scheduler_url : Optional[str] + Override for the scheduler URL. If None, will check DATALAYER_SCHEDULER_URL env var + then fallback to DEFAULT_DATALAYER_SCHEDULER_URL. Returns ------- @@ -291,6 +300,12 @@ def from_environment( or base_url_for_services or DEFAULT_DATALAYER_RAY_URL ) + resolved_scheduler_url = ( + scheduler_url + or os.environ.get("DATALAYER_SCHEDULER_URL") + or base_url_for_services + or DEFAULT_DATALAYER_SCHEDULER_URL + ) # Strip trailing slashes for consistency resolved_run_url = resolved_run_url.rstrip("/") @@ -308,6 +323,7 @@ def from_environment( resolved_support_url = resolved_support_url.rstrip("/") resolved_mcp_server_url = resolved_mcp_server_url.rstrip("/") resolved_ray_url = resolved_ray_url.rstrip("/") + resolved_scheduler_url = resolved_scheduler_url.rstrip("/") return cls( run_url=resolved_run_url, @@ -325,6 +341,7 @@ def from_environment( support_url=resolved_support_url, mcp_server_url=resolved_mcp_server_url, ray_url=resolved_ray_url, + scheduler_url=resolved_scheduler_url, ) def __post_init__(self) -> None: @@ -344,3 +361,4 @@ def __post_init__(self) -> None: self.support_url = self.support_url.rstrip("/") self.mcp_server_url = self.mcp_server_url.rstrip("/") self.ray_url = self.ray_url.rstrip("/") + self.scheduler_url = self.scheduler_url.rstrip("/") From bc4ea78e2abd3f163cf729f5de327aa0f89b771c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 8 Jun 2026 16:26:07 +0200 Subject: [PATCH 02/43] exec: examples --- datalayer_core/cli/commands/README.md | 14 +- datalayer_core/cli/commands/exec.py | 165 ++++++++++++++---- .../tests/test_cli_exec_examples.py | 34 ++++ datalayer_core/utils/urls.py | 45 +++++ 4 files changed, 218 insertions(+), 40 deletions(-) create mode 100644 datalayer_core/tests/test_cli_exec_examples.py diff --git a/datalayer_core/cli/commands/README.md b/datalayer_core/cli/commands/README.md index 5c0d401c..4de40ddc 100644 --- a/datalayer_core/cli/commands/README.md +++ b/datalayer_core/cli/commands/README.md @@ -10,23 +10,33 @@ Execute a Python file or Jupyter notebook on a Datalayer runtime. **Usage:** ```bash -dla exec --runtime [options] +dla exec [options] +dla exec --example-py [options] +dla exec --example-notebook [options] ``` **Arguments:** -- `filename`: Path to the Python file (.py) or Jupyter notebook (.ipynb) to execute +- `filename`: Path to the Python file (.py) or Jupyter notebook (.ipynb) to execute (optional when using `--example-py` or `--example-notebook`) **Options:** - `--runtime, -r`: Name of the runtime to execute on (required) - `--verbose, -v`: Show all cell outputs (default: false, outputs are suppressed) - `--timeout, -t`: Execution timeout for each cell in seconds - `--raise`: Stop executing if an exception occurs (default: continue on errors) +- `--example-py`: Create and execute a temporary example Python file +- `--example-notebook`: Create and execute a temporary example notebook **Examples:** ```bash # Execute a Python script on a runtime dla exec script.py --runtime my-runtime +# Execute an auto-generated Python example +dla exec --example-py --runtime my-runtime + +# Execute an auto-generated notebook example +dla exec --example-notebook + # Execute a Jupyter notebook with verbose output dla exec notebook.ipynb --runtime my-runtime --verbose diff --git a/datalayer_core/cli/commands/exec.py b/datalayer_core/cli/commands/exec.py index 999123c3..8cb9d350 100644 --- a/datalayer_core/cli/commands/exec.py +++ b/datalayer_core/cli/commands/exec.py @@ -8,8 +8,11 @@ import json import signal import sys +import tempfile +from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional +from uuid import uuid4 import typer from rich.console import Console @@ -225,7 +228,10 @@ def cleanup(self) -> None: # Main execution function decorated as the default command @app.command() def main( - filename: str = typer.Argument(..., help="Path to the file or notebook to execute"), + filename: Optional[str] = typer.Argument( + None, + help="Path to the file or notebook to execute", + ), runtime: Optional[str] = typer.Option( None, "--runtime", @@ -246,59 +252,142 @@ def main( "--token", help="Authentication token (Bearer token for API requests).", ), + example_notebook: bool = typer.Option( + False, + "--example-notebook", + help="Create a temporary example notebook, execute it, then remove it.", + ), + example_py: bool = typer.Option( + False, + "--example-py", + help="Create a temporary example Python file, execute it, then remove it.", + ), ) -> None: """Execute a Python file or Jupyter notebook on a Datalayer runtime.""" - # Resolve file path - filepath = Path(filename).expanduser().resolve() - - # Check if file exists and is readable - if not filepath.exists(): - console.print(f"[red]Error: File '{filepath}' does not exist[/red]") - raise typer.Exit(1) - - if not filepath.is_file(): - console.print(f"[red]Error: '{filepath}' is not a file[/red]") + if example_notebook and example_py: + console.print( + "[red]Error: --example-notebook and --example-py are mutually exclusive[/red]" + ) raise typer.Exit(1) - try: - with filepath.open("rb"): - pass - except Exception as e: + if filename and (example_notebook or example_py): console.print( - f"[red]Error: Could not open file '{filepath}' for reading: {e}[/red]" + "[red]Error: provide either a filename or one --example-* flag, not both[/red]" ) raise typer.Exit(1) - # Check file extension - if filepath.suffix not in [".py", ".ipynb"]: + if not filename and not example_notebook and not example_py: console.print( - f"[yellow]Warning: File extension '{filepath.suffix}' is not .py or .ipynb[/yellow]" + "[red]Error: missing FILE_PATH or an --example-* option[/red]" ) + raise typer.Exit(1) - # Determine which runtime to use - selected_runtime = runtime - if selected_runtime is None: - selected_runtime = _select_runtime(token=token) - - # Create exec service and execute - exec_service = RuntimesExecService(token=token) + generated_example = False + filepath: Path + if example_notebook: + filepath = _create_example_notebook_file() + generated_example = True + console.print(f"[blue]Generated example notebook: {filepath}[/blue]") + elif example_py: + filepath = _create_example_python_file() + generated_example = True + console.print(f"[blue]Generated example Python file: {filepath}[/blue]") + else: + # Resolve file path + filepath = Path(str(filename)).expanduser().resolve() try: - # Initialize connection to runtime - exec_service.init_kernel_manager(selected_runtime) - - # Execute the file - exec_service.execute_file( - filepath=filepath, - silent=not verbose, - timeout=timeout, - raise_exceptions=raise_exceptions, - ) + # Check if file exists and is readable + if not filepath.exists(): + console.print(f"[red]Error: File '{filepath}' does not exist[/red]") + raise typer.Exit(1) + + if not filepath.is_file(): + console.print(f"[red]Error: '{filepath}' is not a file[/red]") + raise typer.Exit(1) + + try: + with filepath.open("rb"): + pass + except Exception as e: + console.print( + f"[red]Error: Could not open file '{filepath}' for reading: {e}[/red]" + ) + raise typer.Exit(1) + + # Check file extension + if filepath.suffix not in [".py", ".ipynb"]: + console.print( + f"[yellow]Warning: File extension '{filepath.suffix}' is not .py or .ipynb[/yellow]" + ) + + # Determine which runtime to use + selected_runtime = runtime + if selected_runtime is None: + selected_runtime = _select_runtime(token=token) + # Create exec service and execute + exec_service = RuntimesExecService(token=token) + + try: + # Initialize connection to runtime + exec_service.init_kernel_manager(selected_runtime) + + # Execute the file + exec_service.execute_file( + filepath=filepath, + silent=not verbose, + timeout=timeout, + raise_exceptions=raise_exceptions, + ) + + finally: + # Always cleanup + exec_service.cleanup() finally: - # Always cleanup - exec_service.cleanup() + if generated_example: + try: + filepath.unlink(missing_ok=True) + except Exception as e: + console.print( + f"[yellow]Warning: could not remove temporary example file '{filepath}': {e}[/yellow]" + ) + + +def _example_file_path(suffix: str) -> Path: + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + name = f"datalayer-exec-example-{ts}-{uuid4().hex[:8]}{suffix}" + return Path(tempfile.gettempdir()) / name + + +def _create_example_python_file() -> Path: + path = _example_file_path(".py") + path.write_text( + "print('Hello from datalayer exec --example-py')\n", + encoding="utf-8", + ) + return path + + +def _create_example_notebook_file() -> Path: + path = _example_file_path(".ipynb") + notebook_payload = { + "cells": [ + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": ["print('Hello from datalayer exec --example-notebook')"], + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5, + } + path.write_text(json.dumps(notebook_payload), encoding="utf-8") + return path def _select_runtime(token: Optional[str] = None) -> str: diff --git a/datalayer_core/tests/test_cli_exec_examples.py b/datalayer_core/tests/test_cli_exec_examples.py new file mode 100644 index 00000000..d1dced37 --- /dev/null +++ b/datalayer_core/tests/test_cli_exec_examples.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""Unit tests for datalayer exec example file generators.""" + +from pathlib import Path + +from datalayer_core.cli.commands.exec import ( + _create_example_notebook_file, + _create_example_python_file, +) + + +def test_create_example_python_file() -> None: + path = _create_example_python_file() + try: + assert path.exists() + assert path.suffix == ".py" + content = path.read_text(encoding="utf-8") + assert "--example-py" in content + finally: + path.unlink(missing_ok=True) + + +def test_create_example_notebook_file() -> None: + path = _create_example_notebook_file() + try: + assert path.exists() + assert path.suffix == ".ipynb" + content = path.read_text(encoding="utf-8") + assert "--example-notebook" in content + assert '"cells"' in content + finally: + path.unlink(missing_ok=True) diff --git a/datalayer_core/utils/urls.py b/datalayer_core/utils/urls.py index 12c0e513..93d1b57f 100644 --- a/datalayer_core/utils/urls.py +++ b/datalayer_core/utils/urls.py @@ -8,6 +8,7 @@ """ import os +from dataclasses import asdict from dataclasses import dataclass from typing import Optional @@ -362,3 +363,47 @@ def __post_init__(self) -> None: self.mcp_server_url = self.mcp_server_url.rstrip("/") self.ray_url = self.ray_url.rstrip("/") self.scheduler_url = self.scheduler_url.rstrip("/") + + def as_dict(self) -> dict[str, str]: + """Return all resolved service URLs as a dictionary.""" + return asdict(self) + + @classmethod + def get_all_urls( + cls, + run_url: Optional[str] = None, + iam_url: Optional[str] = None, + runtimes_url: Optional[str] = None, + spacer_url: Optional[str] = None, + library_url: Optional[str] = None, + manager_url: Optional[str] = None, + ai_agents_url: Optional[str] = None, + ai_inference_url: Optional[str] = None, + otel_url: Optional[str] = None, + growth_url: Optional[str] = None, + success_url: Optional[str] = None, + status_url: Optional[str] = None, + support_url: Optional[str] = None, + mcp_server_url: Optional[str] = None, + ray_url: Optional[str] = None, + scheduler_url: Optional[str] = None, + ) -> dict[str, str]: + """Resolve and return all service URLs with optional overrides.""" + return cls.from_environment( + run_url=run_url, + iam_url=iam_url, + runtimes_url=runtimes_url, + spacer_url=spacer_url, + library_url=library_url, + manager_url=manager_url, + ai_agents_url=ai_agents_url, + ai_inference_url=ai_inference_url, + otel_url=otel_url, + growth_url=growth_url, + success_url=success_url, + status_url=status_url, + support_url=support_url, + mcp_server_url=mcp_server_url, + ray_url=ray_url, + scheduler_url=scheduler_url, + ).as_dict() From 2fa1e857260c6737fa6111af6cd68d5dc1328fa8 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 8 Jun 2026 17:08:10 +0200 Subject: [PATCH 03/43] exec --- datalayer_core/cli/commands/exec.py | 389 +++++++++++++++++++++--- datalayer_core/cli/commands/runtimes.py | 84 +++++ datalayer_core/console/manager.py | 97 ++++-- 3 files changed, 507 insertions(+), 63 deletions(-) diff --git a/datalayer_core/cli/commands/exec.py b/datalayer_core/cli/commands/exec.py index 8cb9d350..824dbc07 100644 --- a/datalayer_core/cli/commands/exec.py +++ b/datalayer_core/cli/commands/exec.py @@ -9,6 +9,7 @@ import signal import sys import tempfile +import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional @@ -19,6 +20,7 @@ from datalayer_core.client.client import DatalayerClient from datalayer_core.console.manager import RuntimeManager +from datalayer_core.utils.network import fetch from datalayer_core.utils.notebook import get_cells # Create the main Typer app for exec functionality @@ -30,6 +32,9 @@ console = Console() +KERNEL_READY_TIMEOUT_SECONDS = 1.0 +KERNEL_PROBE_TIMEOUT_SECONDS = 1.0 + @app.callback() def exec_callback(ctx: typer.Context) -> None: @@ -65,47 +70,72 @@ def handle_sigint(self, *args: Any) -> None: def init_kernel_manager(self, runtime_name: str) -> None: """Initialize the kernel manager and connect to runtime.""" - try: - # Validate runtime only when explicitly provided. - # Empty runtime name delegates selection/creation to RuntimeManager.start_kernel. - if runtime_name: - runtimes = self._client.list_runtimes() - target_runtime = None - - for runtime in runtimes: - if runtime.name == runtime_name or runtime.uid == runtime_name: - target_runtime = runtime - break - - if target_runtime is None: - raise RuntimeError(f"Runtime '{runtime_name}' not found") - - # Get token using the same method as DatalayerClient - token = self._client._get_token() - - # Create a RuntimeManager with proper credentials - self.kernel_manager = RuntimeManager( - run_url=self._client.urls.run_url, - token=token or "", - username="", # Username is not required for token-based auth - ) + max_attempts = 2 + last_error: Exception | None = None + # Set up signal handler once. + signal.signal(signal.SIGINT, self.handle_sigint) - # Set up signal handler - signal.signal(signal.SIGINT, self.handle_sigint) + for attempt in range(1, max_attempts + 1): + try: + # Validate runtime only when explicitly provided. + # Empty runtime name delegates selection/creation to RuntimeManager.start_kernel. + if runtime_name: + runtimes = self._client.list_runtimes() + target_runtime = None + + for runtime in runtimes: + if runtime.name == runtime_name or runtime.uid == runtime_name: + target_runtime = runtime + break + + if target_runtime is None: + raise RuntimeError(f"Runtime '{runtime_name}' not found") + + # Get token using the same method as DatalayerClient + token = self._client._get_token() + + # Create a RuntimeManager with proper credentials + self.kernel_manager = RuntimeManager( + run_url=self._client.urls.run_url, + token=token or "", + username="", # Username is not required for token-based auth + ) + + # Start kernel and get client + self.kernel_manager.start_kernel(name=runtime_name or "") + self.kernel_client = self.kernel_manager.client - # Start kernel and get client - self.kernel_manager.start_kernel(name=runtime_name or "") - self.kernel_client = self.kernel_manager.client + if not self.kernel_client: + raise RuntimeError("Failed to create kernel client") - if self.kernel_client: self.kernel_client.start_channels() + # Fresh runtimes can report healthy before the kernel channels are + # fully ready for requests. Wait explicitly to avoid hanging on + # the first execute call. + self.kernel_client.wait_for_ready(timeout=KERNEL_READY_TIMEOUT_SECONDS) + self._probe_kernel_execution() console.print( f"[green]Connected to runtime: {runtime_name or 'auto-selected'}[/green]" ) - else: - raise RuntimeError("Failed to create kernel client") + return + except Exception as e: + last_error = e + self.cleanup() + self.kernel_manager = None + self.kernel_client = None + if attempt < max_attempts: + console.print( + "[yellow]Kernel not ready yet, retrying connection...[/yellow]" + ) + time.sleep(1.5 * attempt) + continue + break - except Exception as e: + if last_error is None: + last_error = RuntimeError("Unknown runtime initialization failure") + + e = last_error + try: console.print( f"[red]Failed to connect to runtime '{runtime_name}': {e}[/red]" ) @@ -120,16 +150,27 @@ def init_kernel_manager(self, runtime_name: str) -> None: "[yellow] 2. Set DATALAYER_API_KEY environment variable[/yellow]" ) console.print("[yellow] 3. Use --token option if available[/yellow]") - + finally: raise typer.Exit(1) + def _probe_kernel_execution(self) -> None: + """Validate the kernel can execute a trivial statement before running user code.""" + if not self.kernel_client: + raise RuntimeError("Kernel client not initialized") + + self.kernel_client.execute_interactive( + "1+1", + silent=True, + timeout=KERNEL_PROBE_TIMEOUT_SECONDS, + ) + def execute_file( self, filepath: Path, silent: bool = True, timeout: Optional[float] = None, raise_exceptions: bool = False, - ) -> None: + ) -> dict[str, Any]: """ Execute a file or notebook on the connected runtime. @@ -147,19 +188,30 @@ def execute_file( if not self.kernel_client: raise RuntimeError("Kernel client not initialized") + report: dict[str, Any] = { + "input_file": str(filepath), + "cells": [], + } + try: self._executing = True console.print(f"[blue]Executing file: {filepath}[/blue]") + # Guardrail: ensure the selected runtime endpoint is reachable + # before submitting any execute requests. + self._assert_runtime_alive() + # Get cells from the file cells = list(get_cells(filepath)) if not cells: console.print("[yellow]No executable cells found in file[/yellow]") - return + return report total_cells = len(cells) console.print(f"[blue]Found {total_cells} cell(s) to execute[/blue]") + failed_cells = 0 + effective_timeout = float(timeout) if timeout is not None else None # Execute each cell for i, (cell_id, cell_source) in enumerate(cells, 1): @@ -167,11 +219,68 @@ def execute_file( continue console.print(f"[blue]Executing cell {i}/{total_cells}...[/blue]") + captured_outputs: list[dict[str, Any]] = [] + + def output_hook(msg: dict[str, Any]) -> None: + msg_type = str(msg.get("msg_type") or "") + content = msg.get("content") or {} + + if msg_type == "stream": + captured_outputs.append( + { + "output_type": "stream", + "name": content.get("name", "stdout"), + "text": content.get("text", ""), + } + ) + return + + if msg_type in {"display_data", "execute_result"}: + data = content.get("data") or {} + captured_outputs.append( + { + "output_type": msg_type, + "data": data, + "metadata": content.get("metadata") or {}, + "execution_count": content.get("execution_count"), + } + ) + return + + if msg_type == "error": + captured_outputs.append( + { + "output_type": "error", + "ename": content.get("ename"), + "evalue": content.get("evalue"), + "traceback": content.get("traceback") or [], + } + ) + + cell_report: dict[str, Any] = { + "cell_index": i, + "cell_id": cell_id, + "status": "ok", + "outputs": captured_outputs, + } try: - reply = self.kernel_client.execute_interactive( - cell_source, silent=silent, timeout=timeout - ) + try: + reply = self.kernel_client.execute_interactive( + cell_source, + silent=silent, + timeout=effective_timeout, + output_hook=output_hook, + ) + except TypeError: + # Backward compatibility when output_hook is not available. + reply = self.kernel_client.execute_interactive( + cell_source, + silent=silent, + timeout=effective_timeout, + ) + + cell_report["reply"] = reply.get("content") if isinstance(reply, dict) else {} if raise_exceptions and reply["content"]["status"] != "ok": content = reply["content"] @@ -189,6 +298,8 @@ def execute_file( f"Unknown failure: {json.dumps(content)}" ) + self._print_cell_outputs(i, captured_outputs) + # Show success for each cell if not silent if not silent: status = reply["content"]["status"] @@ -201,13 +312,34 @@ def execute_file( f"[yellow]⚠ Cell {i} completed with status: {status}[/yellow]" ) + if reply["content"].get("status") != "ok": + cell_report["status"] = str(reply["content"].get("status") or "error") + failed_cells += 1 + except Exception as e: if raise_exceptions: raise + failed_cells += 1 + cell_report["status"] = "error" + cell_report["error"] = str(e) console.print(f"[yellow]Warning: Cell {i} failed: {e}[/yellow]") + finally: + report["cells"].append(cell_report) - console.print("[green]✓ Execution completed successfully[/green]") + if failed_cells > 0: + console.print( + f"[red]Execution completed with {failed_cells} failed cell(s).[/red]" + ) + report["failed_cells"] = failed_cells + report["success"] = False + else: + console.print("[green]✓ Execution completed successfully[/green]") + report["failed_cells"] = 0 + report["success"] = True + return report + except typer.Exit: + raise except Exception as e: if raise_exceptions: raise @@ -216,6 +348,71 @@ def execute_file( finally: self._executing = False + def _print_cell_outputs(self, cell_index: int, outputs: list[dict[str, Any]]) -> None: + """Print collected outputs for a cell after execution.""" + if not outputs: + console.print(f"[dim]Cell {cell_index} output: (no output)[/dim]") + return + + console.print(f"[cyan]Cell {cell_index} output:[/cyan]") + for output in outputs: + output_type = str(output.get("output_type") or "") + if output_type == "stream": + text = str(output.get("text") or "").rstrip("\n") + if text: + console.print(text) + continue + + if output_type in {"display_data", "execute_result"}: + data = output.get("data") or {} + text_plain = "" + if isinstance(data, dict): + text_plain = str(data.get("text/plain") or "").rstrip("\n") + if text_plain: + console.print(text_plain) + else: + console.print(json.dumps(output, ensure_ascii=False)) + continue + + if output_type == "error": + traceback = output.get("traceback") or [] + if traceback: + console.print("[red]" + "\n".join(str(line) for line in traceback) + "[/red]") + else: + ename = str(output.get("ename") or "Error") + evalue = str(output.get("evalue") or "") + console.print(f"[red]{ename}: {evalue}[/red]") + continue + + console.print(json.dumps(output, ensure_ascii=False)) + + def _assert_runtime_alive(self) -> None: + """Fail early when the selected runtime endpoint is not reachable.""" + if not self.kernel_manager: + raise RuntimeError("Runtime manager is not initialized") + + server_url = str(getattr(self.kernel_manager, "server_url", "") or "").rstrip("/") + runtime_token = str(getattr(self.kernel_manager, "token", "") or "") + if not server_url: + raise RuntimeError("Runtime endpoint is not available") + + attempts = 5 + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + fetch(f"{server_url}/api/kernels", token=runtime_token, timeout=15) + return + except Exception as e: + last_error = e + if attempt < attempts: + time.sleep(0.4 * attempt) + continue + break + + raise RuntimeError( + f"Runtime health check failed for '{server_url}': {last_error}" + ) from last_error + def cleanup(self) -> None: """Clean up resources.""" if self.kernel_client: @@ -242,7 +439,10 @@ def main( False, "--verbose", "-v", help="Show all cell outputs" ), timeout: Optional[float] = typer.Option( - None, "--timeout", "-t", help="Execution timeout for each cell in seconds" + None, + "--timeout", + "-t", + help="Execution timeout for each cell in seconds", ), raise_exceptions: bool = typer.Option( False, "--raise", help="Stop executing if an exception occurs" @@ -262,6 +462,11 @@ def main( "--example-py", help="Create a temporary example Python file, execute it, then remove it.", ), + output_name: Optional[str] = typer.Option( + None, + "--output-name", + help="Output report filename/path. Defaults to .out.json next to the input file.", + ), ) -> None: """Execute a Python file or Jupyter notebook on a Datalayer runtime.""" @@ -335,13 +540,24 @@ def main( exec_service.init_kernel_manager(selected_runtime) # Execute the file - exec_service.execute_file( + execution_report = exec_service.execute_file( filepath=filepath, silent=not verbose, timeout=timeout, raise_exceptions=raise_exceptions, ) + report_path = _resolve_output_report_path(filepath, output_name) + execution_report["output_file"] = str(report_path) + report_path.write_text( + json.dumps(execution_report, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + console.print(f"[green]Saved execution outputs: {report_path}[/green]") + console.print(f"[green]Full output report path: {report_path.resolve()}[/green]") + if int(execution_report.get("failed_cells") or 0) > 0: + raise typer.Exit(1) + finally: # Always cleanup exec_service.cleanup() @@ -364,7 +580,34 @@ def _example_file_path(suffix: str) -> Path: def _create_example_python_file() -> Path: path = _example_file_path(".py") path.write_text( - "print('Hello from datalayer exec --example-py')\n", + "import json\n" + "import pandas as pd\n\n" + "pd.set_option('display.max_rows', None)\n" + "pd.set_option('display.max_columns', None)\n" + "pd.set_option('display.width', None)\n\n" + "print('Python example: building sample sales dataframe')\n" + "df = pd.DataFrame({\n" + " 'day': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],\n" + " 'region': ['north', 'north', 'south', 'south', 'west', 'west'],\n" + " 'orders': [12, 14, 8, 11, 9, 15],\n" + " 'revenue': [240, 310, 175, 220, 190, 360],\n" + "})\n\n" + "print('DataFrame:')\n" + "print(df.to_string(index=False))\n\n" + "print('Grouped summary by region:')\n" + "summary = (\n" + " df.groupby('region', as_index=False)\n" + " .agg(total_orders=('orders', 'sum'), total_revenue=('revenue', 'sum'))\n" + " .sort_values('total_revenue', ascending=False)\n" + ")\n" + "print(summary.to_string(index=False))\n\n" + "payload = {\n" + " 'rows': int(len(df)),\n" + " 'best_region': str(summary.iloc[0]['region']),\n" + " 'total_revenue': int(df['revenue'].sum()),\n" + "}\n" + "print('JSON summary:')\n" + "print(json.dumps(payload, indent=2))\n", encoding="utf-8", ) return path @@ -375,12 +618,53 @@ def _create_example_notebook_file() -> Path: notebook_payload = { "cells": [ { + "id": f"cell-{uuid4().hex[:8]}", "cell_type": "code", "execution_count": None, - "metadata": {}, + "metadata": {"id": f"cell-{uuid4().hex[:8]}", "language": "python"}, "outputs": [], - "source": ["print('Hello from datalayer exec --example-notebook')"], - } + "source": [ + "import pandas as pd\n", + "pd.set_option('display.max_rows', None)\n", + "pd.set_option('display.max_columns', None)\n", + "pd.set_option('display.width', None)\n", + "print('Notebook example: pandas setup complete')\n", + ], + }, + { + "id": f"cell-{uuid4().hex[:8]}", + "cell_type": "code", + "execution_count": None, + "metadata": {"id": f"cell-{uuid4().hex[:8]}", "language": "python"}, + "outputs": [], + "source": [ + "df = pd.DataFrame({\n", + " 'day': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],\n", + " 'region': ['north', 'north', 'south', 'south', 'west', 'west'],\n", + " 'orders': [12, 14, 8, 11, 9, 15],\n", + " 'revenue': [240, 310, 175, 220, 190, 360],\n", + "})\n", + "print('Raw dataframe:')\n", + "print(df.to_string(index=False))\n", + ], + }, + { + "id": f"cell-{uuid4().hex[:8]}", + "cell_type": "code", + "execution_count": None, + "metadata": {"id": f"cell-{uuid4().hex[:8]}", "language": "python"}, + "outputs": [], + "source": [ + "summary = (\n", + " df.groupby('region', as_index=False)\n", + " .agg(total_orders=('orders', 'sum'), total_revenue=('revenue', 'sum'))\n", + " .sort_values('total_revenue', ascending=False)\n", + ")\n", + "print('Revenue summary by region:')\n", + "print(summary.to_string(index=False))\n", + "print('Top region:', summary.iloc[0]['region'])\n", + ], + }, ], "metadata": {}, "nbformat": 4, @@ -390,6 +674,19 @@ def _create_example_notebook_file() -> Path: return path +def _resolve_output_report_path(filepath: Path, output_name: Optional[str]) -> Path: + """Compute output report path for collected execution outputs.""" + if output_name: + candidate = Path(output_name).expanduser() + if candidate.is_absolute(): + return candidate + return filepath.parent / candidate + + # notebook-name.ipynb -> notebook-name.out.json + # script.py -> script.out.json + return filepath.with_suffix(".out.json") + + def _select_runtime(token: Optional[str] = None) -> str: """ Select a runtime to use for execution. diff --git a/datalayer_core/cli/commands/runtimes.py b/datalayer_core/cli/commands/runtimes.py index 7a0de637..d6bffd17 100644 --- a/datalayer_core/cli/commands/runtimes.py +++ b/datalayer_core/cli/commands/runtimes.py @@ -7,9 +7,11 @@ import typer from rich.console import Console +from rich.table import Table from datalayer_core.client.client import DatalayerClient from datalayer_core.displays.runtimes import display_runtimes +from datalayer_core.utils.network import fetch from datalayer_core.utils.urls import DatalayerURLs # Create a Typer app for runtime commands @@ -310,3 +312,85 @@ def runtimes_ls( ) -> None: """List running runtimes (root command alias).""" list_runtimes(token=token, iam_url=iam_url, runtimes_url=runtimes_url) + + +@app.command(name="health") +def runtime_health( + runtime: Optional[str] = typer.Option( + None, + "--runtime", + "-r", + help="Runtime identifier (pod name, uid, or given name). Defaults to first running runtime.", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), + iam_url: Optional[str] = typer.Option( + None, + "--iam-url", + help="Datalayer IAM server URL", + ), + runtimes_url: Optional[str] = typer.Option( + None, + "--runtimes-url", + help="Datalayer Runtimes server URL", + ), +) -> None: + """Check health/reachability of a runtime endpoint.""" + try: + client = _make_client(token=token, iam_url=iam_url, runtimes_url=runtimes_url) + runtimes = client.list_runtimes() + if not runtimes: + console.print("[yellow]No running runtimes found.[/yellow]") + raise typer.Exit(1) + + selected = None + if runtime: + for candidate in runtimes: + if runtime in {candidate.pod_name, candidate.uid, candidate.name}: + selected = candidate + break + if selected is None: + console.print(f"[red]Runtime '{runtime}' not found.[/red]") + raise typer.Exit(1) + else: + selected = runtimes[0] + + pod_name = selected.pod_name or "" + refreshed = client.get_runtime(pod_name) + + endpoint = str(refreshed.ingress or "").rstrip("/") + runtime_token = str(refreshed.jupyter_token or "") + health_status = "unreachable" + detail = "Missing ingress URL" + + if endpoint: + try: + response = fetch(f"{endpoint}/api/kernels", token=runtime_token, timeout=15) + kernels = response.json() if response.content else [] + kernel_count = len(kernels) if isinstance(kernels, list) else "n/a" + health_status = "alive" + detail = f"Jupyter API reachable (kernels={kernel_count})" + except Exception as e: + detail = str(e) + + table = Table(title="Runtime Health") + table.add_column("Field", style="cyan") + table.add_column("Value") + table.add_row("Runtime", str(refreshed.name or pod_name)) + table.add_row("Pod", str(pod_name)) + table.add_row("UID", str(refreshed.uid or "")) + table.add_row("Ingress", endpoint or "n/a") + table.add_row("Status", health_status) + table.add_row("Detail", detail) + console.print(table) + + if health_status != "alive": + raise typer.Exit(1) + except typer.Exit: + raise + except Exception as e: + console.print(f"[red]Error checking runtime health: {e}[/red]") + raise typer.Exit(1) diff --git a/datalayer_core/console/manager.py b/datalayer_core/console/manager.py index 8b3c6edc..fedf4639 100644 --- a/datalayer_core/console/manager.py +++ b/datalayer_core/console/manager.py @@ -200,7 +200,7 @@ def start_kernel( runtimes = self._client.list_runtimes() # Use the first available runtime - if runtimes: + if runtime is None and runtimes: r = runtimes[0] runtime = { "pod_name": r.pod_name, @@ -216,14 +216,31 @@ def start_kernel( self.server_url = runtime["ingress"] self.token = runtime.get("token", "") - # Get runtime information. + # Ensure runtime endpoint is ready and a usable kernel exists. + self._kernel_id = self._ensure_kernel_id() + + kernel_model = self.refresh_model() + msg = f"RuntimeManager using existing runtime {runtime_name}" + expired_at = runtime.get("expired_at") + if expired_at is not None: + msg += f" expiring at {timestamp_to_local_date(expired_at)}" + self.log.info(msg) + + return kernel_model + + def _ensure_kernel_id(self) -> str: + """Return a usable kernel id, creating one if needed.""" from datalayer_core.utils.network import fetch response = None - max_attempts = 4 + max_attempts = 8 for attempt in range(1, max_attempts + 1): try: - response = fetch(f"{self.server_url}/api/kernels", token=self.token) + response = fetch( + f"{self.server_url.rstrip('/')}/api/kernels", + token=self.token, + timeout=20, + ) break except requests.exceptions.HTTPError as e: status = ( @@ -231,28 +248,74 @@ def start_kernel( if getattr(e, "response", None) is not None else None ) - if status in (502, 503, 504) and attempt < max_attempts: - time.sleep(2 ** (attempt - 1)) + if status in (404, 502, 503, 504) and attempt < max_attempts: + time.sleep(min(2.0, 0.5 * attempt)) continue raise except requests.exceptions.ConnectionError: if attempt < max_attempts: - time.sleep(2 ** (attempt - 1)) + time.sleep(min(2.0, 0.5 * attempt)) continue raise if response is None: raise RuntimeError("Failed to query kernel endpoint for runtime") - kernels = response.json() - if kernels: - self._kernel_id = kernels[0]["id"] + kernels = response.json() if response.content else [] + if isinstance(kernels, list) and kernels: + kernel_id = kernels[0].get("id") + if kernel_id: + return str(kernel_id) + + # No running kernels yet: create one and wait for it to become reachable. + kernel_name = self._get_default_kernel_name() or "python3" + create_response = fetch( + f"{self.server_url.rstrip('/')}/api/kernels", + token=self.token, + method="POST", + json={"name": kernel_name}, + timeout=30, + ) + created = create_response.json() if create_response.content else {} + kernel_id = str(created.get("id") or "") + if not kernel_id: + raise RuntimeError("Runtime returned no kernel id after kernel creation") + + max_checks = 10 + for attempt in range(1, max_checks + 1): + try: + fetch( + f"{self.server_url.rstrip('/')}/api/kernels/{kernel_id}", + token=self.token, + timeout=20, + ) + return kernel_id + except Exception: + if attempt == max_checks: + raise + time.sleep(min(2.0, 0.3 * attempt)) - kernel_model = self.refresh_model() - msg = f"RuntimeManager using existing runtime {runtime_name}" - expired_at = runtime.get("expired_at") - if expired_at is not None: - msg += f" expiring at {timestamp_to_local_date(expired_at)}" - self.log.info(msg) + return kernel_id - return kernel_model + def _get_default_kernel_name(self) -> str: + """Best-effort resolution of the runtime default kernelspec name.""" + from datalayer_core.utils.network import fetch + + try: + response = fetch( + f"{self.server_url.rstrip('/')}/api/kernelspecs", + token=self.token, + timeout=20, + ) + payload = response.json() if response.content else {} + default_name = str(payload.get("default") or "").strip() + if default_name: + return default_name + + specs = payload.get("kernelspecs") + if isinstance(specs, dict) and specs: + return str(next(iter(specs.keys()))) + except Exception: + return "" + + return "" From c4585363ca608314ce74c6366bbf039bcac94985 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 8 Jun 2026 19:46:53 +0200 Subject: [PATCH 04/43] time --- datalayer_core/cli/commands/exec.py | 190 +++++++++++- datalayer_core/cli/commands/runtimes.py | 94 ++++++ datalayer_core/console/manager.py | 303 +++++++++----------- src/components/display/LiveRelativeTime.tsx | 34 +-- src/components/index.ts | 1 + src/components/time/LiveRelativeTime.tsx | 64 +++++ src/components/time/index.ts | 6 + src/state/substates/CoreState.ts | 2 +- src/utils/Date.ts | 59 +++- src/utils/{logger.ts => Logger.ts} | 0 src/utils/index.ts | 1 + 11 files changed, 537 insertions(+), 217 deletions(-) create mode 100644 src/components/time/LiveRelativeTime.tsx create mode 100644 src/components/time/index.ts rename src/utils/{logger.ts => Logger.ts} (100%) diff --git a/datalayer_core/cli/commands/exec.py b/datalayer_core/cli/commands/exec.py index 824dbc07..fcf9c24c 100644 --- a/datalayer_core/cli/commands/exec.py +++ b/datalayer_core/cli/commands/exec.py @@ -17,6 +17,7 @@ import typer from rich.console import Console +from rich.table import Table from datalayer_core.client.client import DatalayerClient from datalayer_core.console.manager import RuntimeManager @@ -32,8 +33,9 @@ console = Console() -KERNEL_READY_TIMEOUT_SECONDS = 1.0 -KERNEL_PROBE_TIMEOUT_SECONDS = 1.0 +KERNEL_READY_TIMEOUT_SECONDS = 20.0 +KERNEL_PROBE_TIMEOUT_SECONDS = 20.0 +DEFAULT_EXEC_TIMEOUT_SECONDS = 10.0 @app.callback() @@ -103,6 +105,10 @@ def init_kernel_manager(self, runtime_name: str) -> None: # Start kernel and get client self.kernel_manager.start_kernel(name=runtime_name or "") + + if bool(getattr(self.kernel_manager, "runtime_created_in_start", False)): + self._inspect_created_runtime_kernels() + self.kernel_client = self.kernel_manager.client if not self.kernel_client: @@ -114,9 +120,18 @@ def init_kernel_manager(self, runtime_name: str) -> None: # the first execute call. self.kernel_client.wait_for_ready(timeout=KERNEL_READY_TIMEOUT_SECONDS) self._probe_kernel_execution() - console.print( - f"[green]Connected to runtime: {runtime_name or 'auto-selected'}[/green]" - ) + manager_runtime_name = str(getattr(self.kernel_manager, "runtime_name", "") or runtime_name or "auto-selected") + manager_runtime_uid = str(getattr(self.kernel_manager, "runtime_uid", "") or "") + manager_kernel_id = str(getattr(self.kernel_manager, "_kernel_id", "") or "") + if manager_runtime_uid or manager_kernel_id: + runtime_ref = f"{manager_runtime_uid}#{manager_kernel_id}".strip("#") + console.print( + f"[green]Connected to runtime: {manager_runtime_name} ({runtime_ref})[/green]" + ) + else: + console.print( + f"[green]Connected to runtime: {runtime_name or 'auto-selected'}[/green]" + ) return except Exception as e: last_error = e @@ -153,15 +168,68 @@ def init_kernel_manager(self, runtime_name: str) -> None: finally: raise typer.Exit(1) + def _inspect_created_runtime_kernels(self) -> None: + """Inspect kernels after runtime auto-creation and fail fast when count != 1.""" + if not self.kernel_manager: + raise RuntimeError("Runtime manager is not initialized") + + server_url = str(getattr(self.kernel_manager, "server_url", "") or "").rstrip("/") + runtime_token = str(getattr(self.kernel_manager, "token", "") or "") + runtime_name = str(getattr(self.kernel_manager, "runtime_name", "") or "") + runtime_uid = str(getattr(self.kernel_manager, "runtime_uid", "") or "") + runtime_pod = str(getattr(self.kernel_manager, "runtime_pod_name", "") or "") + + response = fetch(f"{server_url}/api/kernels", token=runtime_token, timeout=15) + kernels = response.json() if response.content else [] + if not isinstance(kernels, list): + kernels = [] + + summary = Table(title="Runtime Inspection (auto-created by exec)") + summary.add_column("Field", style="cyan") + summary.add_column("Value") + summary.add_row("Runtime", runtime_name or runtime_pod) + summary.add_row("Pod", runtime_pod) + summary.add_row("UID", runtime_uid) + summary.add_row("Ingress", server_url) + summary.add_row("Kernels", str(len(kernels))) + console.print(summary) + + kernels_table = Table(title="Available Kernels") + kernels_table.add_column("ID", style="green") + kernels_table.add_column("Name") + kernels_table.add_column("State") + kernels_table.add_column("Connections") + kernels_table.add_column("Last Activity") + for kernel in kernels: + kernels_table.add_row( + str((kernel or {}).get("id") or ""), + str((kernel or {}).get("name") or ""), + str((kernel or {}).get("execution_state") or ""), + str((kernel or {}).get("connections") or "0"), + str((kernel or {}).get("last_activity") or ""), + ) + if kernels: + console.print(kernels_table) + + if len(kernels) != 1: + raise RuntimeError( + f"Auto-created runtime expected exactly one kernel, found {len(kernels)}" + ) + def _probe_kernel_execution(self) -> None: """Validate the kernel can execute a trivial statement before running user code.""" if not self.kernel_client: raise RuntimeError("Kernel client not initialized") + def _noop_output_hook(msg: dict[str, Any]) -> None: + # A stream-based probe validates the same IOPub path used by cells. + _ = msg + self.kernel_client.execute_interactive( - "1+1", - silent=True, + "print('__datalayer_probe__')", + silent=False, timeout=KERNEL_PROBE_TIMEOUT_SECONDS, + output_hook=_noop_output_hook, ) def execute_file( @@ -200,6 +268,7 @@ def execute_file( # Guardrail: ensure the selected runtime endpoint is reachable # before submitting any execute requests. self._assert_runtime_alive() + self._prepare_kernel_before_execution() # Get cells from the file cells = list(get_cells(filepath)) @@ -211,7 +280,11 @@ def execute_file( total_cells = len(cells) console.print(f"[blue]Found {total_cells} cell(s) to execute[/blue]") failed_cells = 0 - effective_timeout = float(timeout) if timeout is not None else None + effective_timeout = ( + float(timeout) + if timeout is not None + else DEFAULT_EXEC_TIMEOUT_SECONDS + ) # Execute each cell for i, (cell_id, cell_source) in enumerate(cells, 1): @@ -219,6 +292,7 @@ def execute_file( continue console.print(f"[blue]Executing cell {i}/{total_cells}...[/blue]") + self._print_cell_source(i, cell_source) captured_outputs: list[dict[str, Any]] = [] def output_hook(msg: dict[str, Any]) -> None: @@ -386,6 +460,13 @@ def _print_cell_outputs(self, cell_index: int, outputs: list[dict[str, Any]]) -> console.print(json.dumps(output, ensure_ascii=False)) + def _print_cell_source(self, cell_index: int, source: str) -> None: + """Print the source code that will be sent to the kernel for execution.""" + console.print(f"[cyan]Cell {cell_index} source:[/cyan]") + console.print("[dim][/dim]") + console.print(source.rstrip("\n")) + console.print("[dim][/dim]") + def _assert_runtime_alive(self) -> None: """Fail early when the selected runtime endpoint is not reachable.""" if not self.kernel_manager: @@ -413,6 +494,57 @@ def _assert_runtime_alive(self) -> None: f"Runtime health check failed for '{server_url}': {last_error}" ) from last_error + def _prepare_kernel_before_execution(self) -> None: + """List kernels visible on the runtime before execution starts.""" + kernels = self._fetch_runtime_kernels() + self._print_available_kernels( + title="Kernels available before execution:", + kernels=kernels, + ) + + def _fetch_runtime_kernels(self) -> list[dict[str, Any]]: + """Fetch kernels from the current runtime.""" + if not self.kernel_manager: + return [] + + server_url = str(getattr(self.kernel_manager, "server_url", "") or "").rstrip("/") + runtime_token = str(getattr(self.kernel_manager, "token", "") or "") + if not server_url: + return [] + + response = fetch(f"{server_url}/api/kernels", token=runtime_token, timeout=15) + kernels = response.json() if response.content else [] + if not isinstance(kernels, list): + return [] + return [kernel for kernel in kernels if isinstance(kernel, dict)] + + def _print_available_kernels( + self, + title: str, + kernels: list[dict[str, Any]], + ) -> None: + """Print kernels currently visible on the runtime.""" + selected_kernel_id = str(getattr(self.kernel_manager, "_kernel_id", "") or "") + + if not kernels: + console.print(f"[yellow]{title} none[/yellow]") + return + + console.print(f"[blue]{title}[/blue]") + for kernel in sorted( + kernels, + key=lambda kernel: str((kernel or {}).get("id") or ""), + ): + kernel_id = str((kernel or {}).get("id") or "") + kernel_name = str((kernel or {}).get("name") or "") + execution_state = str((kernel or {}).get("execution_state") or "") + connections = (kernel or {}).get("connections") + last_activity = str((kernel or {}).get("last_activity") or "") + marker = "*" if selected_kernel_id and kernel_id == selected_kernel_id else " " + console.print( + f" [{marker}] id={kernel_id} name={kernel_name} state={execution_state} connections={connections} last_activity={last_activity}" + ) + def cleanup(self) -> None: """Clean up resources.""" if self.kernel_client: @@ -708,14 +840,48 @@ def _select_runtime(token: Optional[str] = None) -> str: runtimes = client.list_runtimes() if not runtimes: - # Return an empty runtime name to trigger RuntimeManager's built-in - # interactive flow that can launch a runtime from an environment. - return "" + environment_hint = "" + try: + environments = client.list_environments() + if environments: + environment_hint = str(environments[0].name or environment_hint) + except Exception: + pass + + console.print("[red]No Runtime running.[/red]") + console.print("[yellow]Launch one with:[/yellow]") + console.print( + f"[yellow] datalayer runtimes create {environment_hint} --time-reservation 10[/yellow]" + ) + raise typer.Exit(1) # Use the first available runtime selected = runtimes[0] + runtime_uid = str(selected.uid or "") + kernel_id = "" + try: + runtime_token = str(getattr(selected, "jupyter_token", "") or client._get_token() or "") + ingress = str(getattr(selected, "ingress", "") or "").rstrip("/") + if ingress and runtime_token: + response = fetch(f"{ingress}/api/kernels", token=runtime_token, timeout=10) + kernels = response.json() if response.content else [] + if isinstance(kernels, list) and kernels: + ordered = sorted( + ( + str((kernel or {}).get("id") or "") + for kernel in kernels + ) + ) + kernel_id = ordered[0] if ordered else "" + except Exception: + kernel_id = "" + + runtime_ref = runtime_uid + if runtime_uid and kernel_id: + runtime_ref = f"{runtime_uid}#{kernel_id}" + console.print( - f"[blue]No runtime specified, using: {selected.name} ({selected.uid})[/blue]" + f"[blue]No runtime specified, using: {selected.name} ({runtime_ref})[/blue]" ) return selected.name or selected.uid or "" diff --git a/datalayer_core/cli/commands/runtimes.py b/datalayer_core/cli/commands/runtimes.py index d6bffd17..562f9e1d 100644 --- a/datalayer_core/cli/commands/runtimes.py +++ b/datalayer_core/cli/commands/runtimes.py @@ -314,6 +314,100 @@ def runtimes_ls( list_runtimes(token=token, iam_url=iam_url, runtimes_url=runtimes_url) +@app.command(name="inspect") +def inspect_runtime( + runtime: Optional[str] = typer.Option( + None, + "--runtime", + "-r", + help="Runtime identifier (pod name, uid, or given name). Defaults to first running runtime.", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), + iam_url: Optional[str] = typer.Option( + None, + "--iam-url", + help="Datalayer IAM server URL", + ), + runtimes_url: Optional[str] = typer.Option( + None, + "--runtimes-url", + help="Datalayer Runtimes server URL", + ), +) -> None: + """Inspect a runtime and list available kernels.""" + try: + client = _make_client(token=token, iam_url=iam_url, runtimes_url=runtimes_url) + runtimes = client.list_runtimes() + if not runtimes: + console.print("[yellow]No running runtimes found.[/yellow]") + raise typer.Exit(1) + + selected = None + if runtime: + for candidate in runtimes: + if runtime in {candidate.pod_name, candidate.uid, candidate.name}: + selected = candidate + break + if selected is None: + console.print(f"[red]Runtime '{runtime}' not found.[/red]") + raise typer.Exit(1) + else: + selected = runtimes[0] + + pod_name = selected.pod_name or "" + refreshed = client.get_runtime(pod_name) + endpoint = str(refreshed.ingress or "").rstrip("/") + runtime_token = str(refreshed.jupyter_token or client._get_token() or "") + if not endpoint: + console.print("[red]Runtime has no ingress endpoint.[/red]") + raise typer.Exit(1) + + response = fetch(f"{endpoint}/api/kernels", token=runtime_token, timeout=15) + kernels = response.json() if response.content else [] + if not isinstance(kernels, list): + kernels = [] + + summary = Table(title="Runtime Inspection") + summary.add_column("Field", style="cyan") + summary.add_column("Value") + summary.add_row("Runtime", str(refreshed.name or pod_name)) + summary.add_row("Pod", str(pod_name)) + summary.add_row("UID", str(refreshed.uid or "")) + summary.add_row("Ingress", endpoint) + summary.add_row("Kernels", str(len(kernels))) + console.print(summary) + + kernels_table = Table(title="Available Kernels") + kernels_table.add_column("ID", style="green") + kernels_table.add_column("Name") + kernels_table.add_column("State") + kernels_table.add_column("Connections") + kernels_table.add_column("Last Activity") + + for kernel in kernels: + kernels_table.add_row( + str((kernel or {}).get("id") or ""), + str((kernel or {}).get("name") or ""), + str((kernel or {}).get("execution_state") or ""), + str((kernel or {}).get("connections") or "0"), + str((kernel or {}).get("last_activity") or ""), + ) + + if kernels: + console.print(kernels_table) + else: + console.print("[yellow]No kernels returned by runtime API.[/yellow]") + except typer.Exit: + raise + except Exception as e: + console.print(f"[red]Error inspecting runtime: {e}[/red]") + raise typer.Exit(1) + + @app.command(name="health") def runtime_health( runtime: Optional[str] = typer.Option( diff --git a/datalayer_core/console/manager.py b/datalayer_core/console/manager.py index fedf4639..c1238ce2 100644 --- a/datalayer_core/console/manager.py +++ b/datalayer_core/console/manager.py @@ -14,7 +14,6 @@ from jupyter_server.utils import url_path_join from datalayer_core.client.client import DatalayerClient -from datalayer_core.displays.runtimes import display_runtimes from datalayer_core.utils.date import timestamp_to_local_date from datalayer_core.utils.urls import DatalayerURLs @@ -57,6 +56,10 @@ def __init__( _ = kwargs.pop("kernel_id", None) # kernel_id not supported super().__init__(server_url="", token="", username=username, **kwargs) self._kernel_id = "" + self.runtime_uid = "" + self.runtime_name = "" + self.runtime_pod_name = "" + self.runtime_created_in_start = False self.run_url = run_url self.run_token = token self.username = username @@ -114,101 +117,56 @@ def start_kernel( "A kernel is already started. Shutdown it before starting a new one." ) + # Reset per-start state markers. + self.runtime_created_in_start = False + runtime_name = name runtime = None - # Use DatalayerClient to get runtime information - if runtime_name: - # Get specific runtime by name - runtimes = self._client.list_runtimes() - for r in runtimes: - if r.name == runtime_name: - runtime = { - "pod_name": r.pod_name, - "ingress": r.ingress, - "token": r.jupyter_token, - "expired_at": r.expired_at, - } - break - else: + # Use DatalayerClient to get runtime information. + runtimes = self._client.list_runtimes() + + if not runtime_name: self.log.debug( "No Runtime name provided. Picking the first available Runtime…" ) - # Get list of available runtimes - runtimes = self._client.list_runtimes() - - # If no runtime is running, let the user decide to start one from the first environment if not runtimes: - environments = self._client.list_environments() - if not environments: - raise RuntimeError( - "No environments available to create a runtime from." - ) - - first_environment = environments[0] - first_environment_name = first_environment.name - - # Calculate credits limit based on environment - credits_limit = ( - first_environment.burning_rate * 60.0 * 10.0 - ) # 10 minutes default - - user_input = ( - input( - f"No Runtime running.\nDo you want to launch a runtime from the environment {first_environment_name} with {credits_limit:.2f} reserved credits? (yes/no) [default: yes]: " - ) - or "yes" - ) - if user_input.lower() != "yes": - raise RuntimeError( - "No Runtime running. Please start one Runtime using `datalayer runtimes create `." - ) - - # Create new runtime using the client - new_runtime = self._client.create_runtime( - name=f"console-runtime-{first_environment_name}", - environment=first_environment_name, - time_reservation=10.0, # 10 minutes default + raise RuntimeError( + "No Runtime running. Start one first with: `d runtimes create --time-reservation 10`" ) - # Start the runtime to get connection details - new_runtime._start() - - runtime = { - "pod_name": new_runtime.pod_name, - "ingress": new_runtime.ingress, - "token": new_runtime.jupyter_token, - "expired_at": new_runtime.expired_at, - } - - # Display the created runtime - runtime_dict = { - "given_name": new_runtime.name, - "environment_name": new_runtime.environment, - "pod_name": new_runtime.pod_name, - "ingress": new_runtime.ingress, - "reservation_id": getattr(new_runtime, "reservation_id", ""), - "uid": new_runtime.uid, - "burning_rate": getattr(new_runtime, "burning_rate", 0.0), - "token": new_runtime.jupyter_token, - "started_at": getattr(new_runtime, "started_at", ""), - "expired_at": new_runtime.expired_at, - } - display_runtimes([runtime_dict]) - - # Refresh runtime list - runtimes = self._client.list_runtimes() - - # Use the first available runtime - if runtime is None and runtimes: - r = runtimes[0] - runtime = { - "pod_name": r.pod_name, - "ingress": r.ingress, - "token": r.jupyter_token, - "expired_at": r.expired_at, - } - runtime_name = r.pod_name or "" + selected = self._pick_accessible_runtime(runtimes) + + if selected is None: + raise RuntimeError("No accessible Runtime found after startup") + + runtime_name = selected.name or selected.uid or selected.pod_name or "" + self.runtime_uid = str(selected.uid or "") + self.runtime_name = str(selected.name or runtime_name or "") + self.runtime_pod_name = str(selected.pod_name or "") + runtime = { + "pod_name": selected.pod_name, + "ingress": selected.ingress, + "token": selected.jupyter_token or self.run_token, + "expired_at": selected.expired_at, + } + else: + selected = None + for r in runtimes: + if r.name == runtime_name or r.uid == runtime_name: + selected = r + break + if selected is None: + raise RuntimeError(f"Runtime '{runtime_name}' not found") + self.runtime_uid = str(selected.uid or "") + self.runtime_name = str(selected.name or runtime_name or "") + self.runtime_pod_name = str(selected.pod_name or "") + runtime = { + "pod_name": selected.pod_name, + "ingress": selected.ingress, + "token": selected.jupyter_token or self.run_token, + "expired_at": selected.expired_at, + } if runtime is None: raise RuntimeError("Unable to find a Runtime.") @@ -228,94 +186,111 @@ def start_kernel( return kernel_model + def _pick_accessible_runtime(self, runtimes: list[Any]) -> Optional[Any]: + """Return first runtime that responds on /api/kernels with its runtime token.""" + for runtime in runtimes: + if self._runtime_is_accessible(runtime): + return runtime + return None + + def _wait_for_listed_accessible_runtime(self, preferred_uid: str) -> Optional[Any]: + """Wait for a launched runtime to be listed and reachable before use.""" + attempts = 30 + for _ in range(attempts): + runtimes = self._client.list_runtimes() + + if preferred_uid: + for runtime in runtimes: + if str(runtime.uid or "") == preferred_uid and self._runtime_is_accessible(runtime): + return runtime + + selected = self._pick_accessible_runtime(runtimes) + if selected is not None: + return selected + + time.sleep(1.0) + + return None + + def _runtime_is_accessible(self, runtime: Any) -> bool: + """Best-effort HTTP accessibility check for runtime ingress and token.""" + ingress = str(getattr(runtime, "ingress", "") or "").rstrip("/") + token = str(getattr(runtime, "jupyter_token", "") or self.run_token or "") + if not ingress or not token: + return False + + from datalayer_core.utils.network import fetch + + try: + fetch(f"{ingress}/api/kernels", token=token, timeout=10) + return True + except Exception: + return False + def _ensure_kernel_id(self) -> str: - """Return a usable kernel id, creating one if needed.""" + """Return the runtime's existing kernel id. + + Datalayer runtimes are provisioned with a kernel already running and + wired to the runtime ingress. We must connect to that existing kernel + instead of creating a new one: a freshly created kernel id is not the + one the ingress routes to, which leads to no execution output and 404 + responses on kernel endpoints (e.g. /interrupt). + """ from datalayer_core.utils.network import fetch - response = None - max_attempts = 8 + kernels_url = f"{self.server_url.rstrip('/')}/api/kernels" + max_attempts = 30 + last_error: Exception | None = None for attempt in range(1, max_attempts + 1): try: - response = fetch( - f"{self.server_url.rstrip('/')}/api/kernels", - token=self.token, - timeout=20, - ) - break + response = fetch(kernels_url, token=self.token, timeout=20) + kernels = response.json() if response.content else [] + if isinstance(kernels, list) and kernels: + # Freshly launched runtimes can briefly expose stale kernel IDs + # in the list endpoint; verify a kernel can be read directly + # before selecting it. + ordered_kernels = sorted( + kernels, + key=lambda kernel: str((kernel or {}).get("id") or ""), + ) + for kernel in ordered_kernels: + kernel_id = str((kernel or {}).get("id") or "") + if not kernel_id: + continue + try: + fetch( + f"{kernels_url}/{kernel_id}", + token=self.token, + timeout=20, + ) + return kernel_id + except requests.exceptions.HTTPError as e: + status = ( + e.response.status_code + if getattr(e, "response", None) is not None + else None + ) + if status in (404, 410): + # Kernel disappeared while ingress was warming. + continue + last_error = e + except requests.exceptions.ConnectionError as e: + last_error = e except requests.exceptions.HTTPError as e: status = ( e.response.status_code if getattr(e, "response", None) is not None else None ) - if status in (404, 502, 503, 504) and attempt < max_attempts: - time.sleep(min(2.0, 0.5 * attempt)) - continue - raise - except requests.exceptions.ConnectionError: - if attempt < max_attempts: - time.sleep(min(2.0, 0.5 * attempt)) - continue - raise - - if response is None: - raise RuntimeError("Failed to query kernel endpoint for runtime") - - kernels = response.json() if response.content else [] - if isinstance(kernels, list) and kernels: - kernel_id = kernels[0].get("id") - if kernel_id: - return str(kernel_id) - - # No running kernels yet: create one and wait for it to become reachable. - kernel_name = self._get_default_kernel_name() or "python3" - create_response = fetch( - f"{self.server_url.rstrip('/')}/api/kernels", - token=self.token, - method="POST", - json={"name": kernel_name}, - timeout=30, - ) - created = create_response.json() if create_response.content else {} - kernel_id = str(created.get("id") or "") - if not kernel_id: - raise RuntimeError("Runtime returned no kernel id after kernel creation") - - max_checks = 10 - for attempt in range(1, max_checks + 1): - try: - fetch( - f"{self.server_url.rstrip('/')}/api/kernels/{kernel_id}", - token=self.token, - timeout=20, - ) - return kernel_id - except Exception: - if attempt == max_checks: + if status not in (404, 502, 503, 504): raise - time.sleep(min(2.0, 0.3 * attempt)) - - return kernel_id + last_error = e + except requests.exceptions.ConnectionError as e: + last_error = e - def _get_default_kernel_name(self) -> str: - """Best-effort resolution of the runtime default kernelspec name.""" - from datalayer_core.utils.network import fetch + # The kernel may still be registering on a freshly launched runtime. + time.sleep(1.0) - try: - response = fetch( - f"{self.server_url.rstrip('/')}/api/kernelspecs", - token=self.token, - timeout=20, - ) - payload = response.json() if response.content else {} - default_name = str(payload.get("default") or "").strip() - if default_name: - return default_name - - specs = payload.get("kernelspecs") - if isinstance(specs, dict) and specs: - return str(next(iter(specs.keys()))) - except Exception: - return "" - - return "" + raise RuntimeError( + f"Runtime has no available kernel at '{kernels_url}': {last_error}" + ) diff --git a/src/components/display/LiveRelativeTime.tsx b/src/components/display/LiveRelativeTime.tsx index 7aa84eec..0b8790e5 100644 --- a/src/components/display/LiveRelativeTime.tsx +++ b/src/components/display/LiveRelativeTime.tsx @@ -1,35 +1,7 @@ /* - * Copyright (c) 2023-2025 Datalayer, Inc. + * Copyright (c) 2023-2026 Datalayer, Inc. * Distributed under the terms of the Modified BSD License. */ -import { useEffect, useMemo, useState } from 'react'; -import { formatRelativeTime } from '../../utils'; - -type ILiveRelativeTimeProps = { - value?: Date | string | number; - refreshIntervalMs?: number; - fallback?: string; -}; - -/** - * Display a live-updating relative time label (e.g. "5m ago"). - */ -export function LiveRelativeTime({ - value, - refreshIntervalMs = 1000, - fallback = '—', -}: ILiveRelativeTimeProps): JSX.Element { - const [now, setNow] = useState(() => new Date()); - - useEffect(() => { - const timer = window.setInterval(() => { - setNow(new Date()); - }, refreshIntervalMs); - return () => window.clearInterval(timer); - }, [refreshIntervalMs]); - - const label = useMemo(() => formatRelativeTime(value, now), [value, now]); - - return <>{label ?? fallback}; -} +// Backward compatibility export. Use ../../components/time/LiveRelativeTime. +export { LiveRelativeTime } from '../time/LiveRelativeTime'; diff --git a/src/components/index.ts b/src/components/index.ts index d292b021..b3c148a5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,3 +7,4 @@ export * from './auth'; export * from './billing'; export * from './sharing'; export * from './sparklines'; +export * from './time'; diff --git a/src/components/time/LiveRelativeTime.tsx b/src/components/time/LiveRelativeTime.tsx new file mode 100644 index 00000000..4fe4aada --- /dev/null +++ b/src/components/time/LiveRelativeTime.tsx @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023-2026 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { Tooltip } from '@primer/react'; +import { formatDateTimeDetails, formatRelativeTime } from '../../utils'; + +type ILiveRelativeTimeProps = { + value?: Date | string | number; + refreshIntervalMs?: number; + fallback?: string; + showTooltip?: boolean; +}; + +/** + * Display a live-updating relative time label (e.g. "5m ago", "in 2m"). + * + * When `showTooltip` is true, a Primer tooltip shows ISO + localized datetime + * details with timezone information. + */ +export function LiveRelativeTime({ + value, + refreshIntervalMs = 30_000, + fallback = '—', + showTooltip = true, +}: ILiveRelativeTimeProps): JSX.Element { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const timer = window.setInterval(() => { + setNow(new Date()); + }, refreshIntervalMs); + return () => window.clearInterval(timer); + }, [refreshIntervalMs]); + + const label = useMemo(() => formatRelativeTime(value, now), [value, now]); + const tooltip = useMemo(() => formatDateTimeDetails(value), [value]); + const content = ( + + ); + + if (!showTooltip || !tooltip) { + return content; + } + + return {content}; +} diff --git a/src/components/time/index.ts b/src/components/time/index.ts new file mode 100644 index 00000000..6bf1385a --- /dev/null +++ b/src/components/time/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) 2023-2026 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +export * from './LiveRelativeTime'; diff --git a/src/state/substates/CoreState.ts b/src/state/substates/CoreState.ts index 3d95dc54..b4703317 100644 --- a/src/state/substates/CoreState.ts +++ b/src/state/substates/CoreState.ts @@ -6,7 +6,7 @@ import { createStore } from 'zustand/vanilla'; import { useStore } from 'zustand'; import type { IDatalayerCoreConfig } from '../../config/Configuration'; -import { configLogger } from '../../utils/logger'; +import { configLogger } from '../../utils/Logger'; let loadConfigurationFromServer = true; diff --git a/src/utils/Date.ts b/src/utils/Date.ts index 6fd8d75f..0ae6feb6 100644 --- a/src/utils/Date.ts +++ b/src/utils/Date.ts @@ -95,25 +95,66 @@ export const formatRelativeTime = ( return typeof value === 'string' ? value : undefined; } - const diffMs = Math.max(0, now.getTime() - ts); - const seconds = Math.floor(diffMs / 1000); + const diffMs = ts - now.getTime(); + const inFuture = diffMs > 0; + const absSeconds = Math.floor(Math.abs(diffMs) / 1000); - if (seconds < 60) return 'just now'; + if (absSeconds < 30) return inFuture ? 'in a few seconds' : 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; + const withDirection = (value: number, unit: string): string => { + return inFuture ? `in ${value}${unit}` : `${value}${unit} ago`; + }; + + if (absSeconds < 60) return withDirection(absSeconds, 's'); + + const minutes = Math.floor(absSeconds / 60); + if (minutes < 60) return withDirection(minutes, 'm'); const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return withDirection(hours, 'h'); const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return withDirection(days, 'd'); const weeks = Math.floor(days / 7); - if (weeks < 52) return `${weeks}w ago`; + if (weeks < 52) return withDirection(weeks, 'w'); const years = Math.floor(days / 365); - return `${years}y ago`; + return withDirection(years, 'y'); +}; + +/** + * Build a detailed datetime string suitable for tooltips. + * + * Example: + * "2026-06-08T16:15:32.531Z • Jun 8, 2026, 6:15:32 PM CEST (Europe/Paris)" + */ +export const formatDateTimeDetails = ( + value?: Date | string | number, +): string | undefined => { + if (value === undefined || value === null) { + return undefined; + } + + const date = + value instanceof Date + ? value + : typeof value === 'number' + ? new Date(value) + : new Date(value); + + const ts = date.getTime(); + if (Number.isNaN(ts)) { + return typeof value === 'string' ? value : undefined; + } + + const iso = date.toISOString(); + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; + const local = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'long', + }).format(date); + return `${iso} • ${local} (${zone})`; }; /** diff --git a/src/utils/logger.ts b/src/utils/Logger.ts similarity index 100% rename from src/utils/logger.ts rename to src/utils/Logger.ts diff --git a/src/utils/index.ts b/src/utils/index.ts index 31de484e..9ad4f63c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -20,6 +20,7 @@ export * from './Ids'; export * from './Jwt'; export * from './Jupyter'; export * from './Lazy'; +export * from './Logger'; export * from './Msc'; export * from './Name'; export * from './Notebook'; From 184fb42b80ba0931629bd7268ddae64a9c1f2c8a Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 8 Jun 2026 20:03:29 +0200 Subject: [PATCH 05/43] exec --- datalayer_core/cli/commands/exec.py | 238 ++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 12 deletions(-) diff --git a/datalayer_core/cli/commands/exec.py b/datalayer_core/cli/commands/exec.py index fcf9c24c..2cb22679 100644 --- a/datalayer_core/cli/commands/exec.py +++ b/datalayer_core/cli/commands/exec.py @@ -21,6 +21,7 @@ from datalayer_core.client.client import DatalayerClient from datalayer_core.console.manager import RuntimeManager +from datalayer_core.utils.defaults import DEFAULT_ENVIRONMENT from datalayer_core.utils.network import fetch from datalayer_core.utils.notebook import get_cells @@ -823,7 +824,8 @@ def _select_runtime(token: Optional[str] = None) -> str: """ Select a runtime to use for execution. - Returns the first available runtime, or prompts to create one if none exist. + Returns the first available runtime, or interactively provisions one when + no runtime is available. Parameters ---------- @@ -840,20 +842,112 @@ def _select_runtime(token: Optional[str] = None) -> str: runtimes = client.list_runtimes() if not runtimes: - environment_hint = "" - try: - environments = client.list_environments() - if environments: - environment_hint = str(environments[0].name or environment_hint) - except Exception: - pass + console.print("[yellow]No Runtime running.[/yellow]") + + should_create = typer.confirm( + "No runtime is available. Create one now?", + default=True, + ) + if not should_create: + console.print("[red]Execution aborted: no runtime selected.[/red]") + raise typer.Exit(1) + + environment = DEFAULT_ENVIRONMENT + burn_rate = _get_environment_burning_rate(client, environment) + remaining_credits = _get_remaining_credits_after_reservations(client) + default_seconds = _default_runtime_seconds( + remaining_credits=remaining_credits, + burn_rate=burn_rate, + ) - console.print("[red]No Runtime running.[/red]") - console.print("[yellow]Launch one with:[/yellow]") console.print( - f"[yellow] datalayer runtimes create {environment_hint} --time-reservation 10[/yellow]" + f"[blue]Environment: {environment} (burning_rate={burn_rate:.6f} credits/s)[/blue]" + ) + console.print( + f"[blue]Remaining credits (after reservations): {remaining_credits:.6f}[/blue]" + ) + console.print( + f"[blue]Suggested runtime duration: {default_seconds:.2f} seconds (33% of remaining credits)[/blue]" + ) + + requested_seconds = typer.prompt( + "Runtime duration in seconds", + type=float, + default=default_seconds, + show_default=True, + ) + if requested_seconds <= 0: + console.print("[red]Runtime duration must be greater than 0 seconds.[/red]") + raise typer.Exit(1) + + requested_credits = burn_rate * requested_seconds + time_reservation_minutes = requested_seconds / 60.0 + console.print( + f"[blue]Requested reservation: {requested_seconds:.2f}s -> {requested_credits:.6f} credits[/blue]" + ) + + created_runtime = client.create_runtime( + environment=environment, + time_reservation=time_reservation_minutes, + ) + + runtime_name = str(created_runtime.name or "") + runtime_uid = str(created_runtime.uid or "") + runtime_pod = str(created_runtime.pod_name or "") + runtime_ingress = str(created_runtime.ingress or "").rstrip("/") + runtime_token = str( + created_runtime.jupyter_token or client._get_token() or "" + ) + + if not runtime_ingress or not runtime_token: + console.print( + "[red]Runtime created but ingress/token is not available for inspection.[/red]" + ) + raise typer.Exit(1) + + pre_confirm_kernel_id = _inspect_runtime_kernels_unique( + runtime_name=runtime_name or runtime_pod or runtime_uid, + runtime_uid=runtime_uid, + runtime_pod=runtime_pod, + runtime_ingress=runtime_ingress, + runtime_token=runtime_token, + inspection_label="post-create", + ) + + proceed = typer.confirm( + "Proceed with execution on this runtime?", + default=True, + ) + if not proceed: + console.print("[red]Execution aborted by user.[/red]") + raise typer.Exit(1) + + post_confirm_kernel_id = _inspect_runtime_kernels_unique( + runtime_name=runtime_name or runtime_pod or runtime_uid, + runtime_uid=runtime_uid, + runtime_pod=runtime_pod, + runtime_ingress=runtime_ingress, + runtime_token=runtime_token, + inspection_label="pre-exec confirmation", ) - raise typer.Exit(1) + + if post_confirm_kernel_id != pre_confirm_kernel_id: + console.print( + "[red]Kernel changed between inspections. Failing fast before execution.[/red]" + ) + raise typer.Exit(1) + + selected_name = runtime_uid or runtime_name or runtime_pod + if not selected_name: + console.print( + "[red]Runtime created but no runtime identifier is available.[/red]" + ) + raise typer.Exit(1) + + console.print( + f"[green]Using newly created runtime: {selected_name}#{post_confirm_kernel_id}[/green]" + ) + return selected_name # Use the first available runtime selected = runtimes[0] @@ -896,5 +990,125 @@ def _select_runtime(token: Optional[str] = None) -> str: raise typer.Exit(1) +def _get_environment_burning_rate(client: DatalayerClient, environment: str) -> float: + """Get environment burning rate in credits/second.""" + environments = client.list_environments() + for env in environments: + if str(env.name or "") == environment: + burn_rate = float(env.burning_rate or 0.0) + if burn_rate <= 0: + raise RuntimeError( + f"Environment '{environment}' has invalid burning rate: {burn_rate}" + ) + return burn_rate + raise RuntimeError( + f"Environment '{environment}' not found. Available environments: {[str(env.name or '') for env in environments]}" + ) + + +def _to_float(value: Any, default: float = 0.0) -> float: + """Safely parse a float-like value.""" + try: + if value is None: + return default + return float(value) + except Exception: + return default + + +def _get_remaining_credits_after_reservations(client: DatalayerClient) -> float: + """Compute remaining credits after reservations from usage payload.""" + usage = client.get_usage_credits() + if not usage.get("success", True): + raise RuntimeError( + f"Failed to load usage credits: {usage.get('message', 'Unknown error')}" + ) + + credits = usage.get("credits", {}) or {} + reservations = usage.get("reservations", []) or [] + + credits_value = _to_float(credits.get("credits"), 0.0) + quota = credits.get("quota") + + if quota is None: + available_before_reservations = credits_value + else: + available_before_reservations = _to_float(quota, 0.0) - credits_value + + reserved_total = 0.0 + for reservation in reservations: + if not isinstance(reservation, dict): + continue + reserved_total += _to_float(reservation.get("credits"), 0.0) + + remaining = available_before_reservations - reserved_total + return max(0.0, remaining) + + +def _default_runtime_seconds(remaining_credits: float, burn_rate: float) -> float: + """Suggest runtime duration in seconds using 33% of remaining credits.""" + proposed_credits = max(0.0, remaining_credits * 0.33) + if burn_rate <= 0: + raise RuntimeError("Burning rate must be positive to compute duration") + seconds = proposed_credits / burn_rate + # Keep a practical positive default even when credits are very low. + return max(10.0, seconds) + + +def _inspect_runtime_kernels_unique( + runtime_name: str, + runtime_uid: str, + runtime_pod: str, + runtime_ingress: str, + runtime_token: str, + inspection_label: str, +) -> str: + """Inspect runtime kernels and return the unique kernel id. + + Fails fast if the runtime does not expose exactly one kernel. + """ + response = fetch(f"{runtime_ingress}/api/kernels", token=runtime_token, timeout=15) + kernels = response.json() if response.content else [] + if not isinstance(kernels, list): + kernels = [] + + summary = Table(title=f"Runtime Inspection ({inspection_label})") + summary.add_column("Field", style="cyan") + summary.add_column("Value") + summary.add_row("Runtime", runtime_name) + summary.add_row("Pod", runtime_pod) + summary.add_row("UID", runtime_uid) + summary.add_row("Ingress", runtime_ingress) + summary.add_row("Kernels", str(len(kernels))) + console.print(summary) + + kernels_table = Table(title="Available Kernels") + kernels_table.add_column("ID", style="green") + kernels_table.add_column("Name") + kernels_table.add_column("State") + kernels_table.add_column("Connections") + kernels_table.add_column("Last Activity") + for kernel in kernels: + kernels_table.add_row( + str((kernel or {}).get("id") or ""), + str((kernel or {}).get("name") or ""), + str((kernel or {}).get("execution_state") or ""), + str((kernel or {}).get("connections") or "0"), + str((kernel or {}).get("last_activity") or ""), + ) + if kernels: + console.print(kernels_table) + + if len(kernels) != 1: + raise RuntimeError( + f"Runtime inspection requires exactly one kernel; found {len(kernels)}" + ) + + kernel_id = str((kernels[0] or {}).get("id") or "").strip() + if not kernel_id: + raise RuntimeError("Runtime inspection returned a kernel without an id") + return kernel_id + + if __name__ == "__main__": app() From ebf6cf548adfa0480ed80a3991a6bec99cf3aa56 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Tue, 9 Jun 2026 15:15:59 +0200 Subject: [PATCH 06/43] timeline --- datalayer_core/cli/commands/ray.py | 18 +- datalayer_core/mixins/ray.py | 2 +- datalayer_core/tests/test_ray.py | 14 +- datalayer_core/utils/urls.py | 20 -- src/api/constants.ts | 3 + src/api/index.ts | 1 + src/api/scheduler/index.ts | 12 + src/api/scheduler/schedules.ts | 158 ++++++++++ src/components/index.ts | 2 + src/components/scheduler/ScheduleMenu.tsx | 359 ++++++++++++++++++++++ src/components/scheduler/index.ts | 6 + src/components/timeline/Timeline.tsx | 171 +++++++++++ src/components/timeline/index.ts | 6 + 13 files changed, 740 insertions(+), 32 deletions(-) create mode 100644 src/api/scheduler/index.ts create mode 100644 src/api/scheduler/schedules.ts create mode 100644 src/components/scheduler/ScheduleMenu.tsx create mode 100644 src/components/scheduler/index.ts create mode 100644 src/components/timeline/Timeline.tsx create mode 100644 src/components/timeline/index.ts diff --git a/datalayer_core/cli/commands/ray.py b/datalayer_core/cli/commands/ray.py index b9060c9e..fedbe189 100644 --- a/datalayer_core/cli/commands/ray.py +++ b/datalayer_core/cli/commands/ray.py @@ -39,13 +39,25 @@ ) console = Console() +_RAY_RUNTIMES_URL_OVERRIDE: Optional[str] = None _ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") @app.callback() -def ray_callback(ctx: typer.Context) -> None: +def ray_callback( + ctx: typer.Context, + runtimes_url: Optional[str] = typer.Option( + None, + "--runtimes-url", + help="Datalayer Runtimes server URL.", + ), +) -> None: """Ray management commands.""" + global _RAY_RUNTIMES_URL_OVERRIDE + _RAY_RUNTIMES_URL_OVERRIDE = ( + str(runtimes_url).strip().rstrip("/") if runtimes_url else None + ) if ctx.invoked_subcommand is None: typer.echo(ctx.get_help()) @@ -67,9 +79,7 @@ def jobs_callback(ctx: typer.Context) -> None: def _make_client( token: Optional[str] = None, ) -> DatalayerClient: - urls = DatalayerURLs.from_environment() - # Ray CLI is intentionally routed via runtimes, never directly to ray_url. - urls.ray_url = urls.runtimes_url + urls = DatalayerURLs.from_environment(runtimes_url=_RAY_RUNTIMES_URL_OVERRIDE) return DatalayerClient(urls=urls, token=token) diff --git a/datalayer_core/mixins/ray.py b/datalayer_core/mixins/ray.py index 7de8b647..db5d4693 100644 --- a/datalayer_core/mixins/ray.py +++ b/datalayer_core/mixins/ray.py @@ -30,7 +30,7 @@ def _ray_request( prefixes = self._get_ray_api_prefixes() prefix = prefixes[0] response = self._fetch( # type: ignore - f"{self.urls.ray_url}{prefix}{path}", # type: ignore + f"{self.urls.runtimes_url}{prefix}{path}", # type: ignore method=method, params=params, json=json_body, diff --git a/datalayer_core/tests/test_ray.py b/datalayer_core/tests/test_ray.py index 3b2b193f..8ad95113 100644 --- a/datalayer_core/tests/test_ray.py +++ b/datalayer_core/tests/test_ray.py @@ -19,7 +19,7 @@ def json(self): class _FakeRayClient(RayMixin): def __init__(self): - self.urls = DatalayerURLs.from_environment(ray_url="https://ray.example") + self.urls = DatalayerURLs.from_environment(runtimes_url="https://ray.example") self.calls = [] def _fetch(self, url: str, **kwargs): @@ -27,16 +27,16 @@ def _fetch(self, url: str, **kwargs): return _FakeResponse({"success": True, "url": url, "kwargs": kwargs}) -def test_urls_resolve_ray_url_from_environment(monkeypatch): - monkeypatch.setenv("DATALAYER_RAY_URL", "https://ray-from-env.example/") +def test_urls_resolve_runtimes_url_from_environment(monkeypatch): + monkeypatch.setenv("DATALAYER_RUNTIMES_URL", "https://runtimes-from-env.example/") urls = DatalayerURLs.from_environment() - assert urls.ray_url == "https://ray-from-env.example" + assert urls.runtimes_url == "https://runtimes-from-env.example" -def test_urls_resolve_ray_url_from_default(monkeypatch): - monkeypatch.delenv("DATALAYER_RAY_URL", raising=False) +def test_urls_resolve_runtimes_url_from_default(monkeypatch): + monkeypatch.delenv("DATALAYER_RUNTIMES_URL", raising=False) urls = DatalayerURLs.from_environment() - assert urls.ray_url == "https://prod1.datalayer.run" + assert urls.runtimes_url == "https://r1.datalayer.run" def test_ray_mixin_job_logs_and_events_paths(): diff --git a/datalayer_core/utils/urls.py b/datalayer_core/utils/urls.py index 93d1b57f..9f8f3835 100644 --- a/datalayer_core/utils/urls.py +++ b/datalayer_core/utils/urls.py @@ -35,8 +35,6 @@ DEFAULT_DATALAYER_AI_INFERENCE_URL = DEFAULT_DATALAYER_RUN_URL -DEFAULT_DATALAYER_RAY_URL = DEFAULT_DATALAYER_RUN_URL - DEFAULT_DATALAYER_MCP_SERVERS_URL = DEFAULT_DATALAYER_RUN_URL DEFAULT_DATALAYER_OTEL_URL = DEFAULT_DATALAYER_RUN_URL @@ -90,8 +88,6 @@ class DatalayerURLs: The Datalayer support service URL mcp_server_url : str The Datalayer MCP server service URL - ray_url : str - The Datalayer Ray service URL scheduler_url : str The Datalayer scheduler service URL """ @@ -110,7 +106,6 @@ class DatalayerURLs: status_url: str support_url: str mcp_server_url: str - ray_url: str scheduler_url: str @classmethod @@ -130,7 +125,6 @@ def from_environment( status_url: Optional[str] = None, support_url: Optional[str] = None, mcp_server_url: Optional[str] = None, - ray_url: Optional[str] = None, scheduler_url: Optional[str] = None, ) -> "DatalayerURLs": """ @@ -180,9 +174,6 @@ def from_environment( mcp_server_url : Optional[str] Override for the MCP server URL. If None, will check DATALAYER_MCP_SERVER_URL env var then fallback to DEFAULT_DATALAYER_MCP_SERVER_URL. - ray_url : Optional[str] - Override for the Ray URL. If None, will check DATALAYER_RAY_URL env var - then fallback to DEFAULT_DATALAYER_RAY_URL. scheduler_url : Optional[str] Override for the scheduler URL. If None, will check DATALAYER_SCHEDULER_URL env var then fallback to DEFAULT_DATALAYER_SCHEDULER_URL. @@ -295,12 +286,6 @@ def from_environment( or base_url_for_services or DEFAULT_DATALAYER_MCP_SERVERS_URL ) - resolved_ray_url = ( - ray_url - or os.environ.get("DATALAYER_RAY_URL") - or base_url_for_services - or DEFAULT_DATALAYER_RAY_URL - ) resolved_scheduler_url = ( scheduler_url or os.environ.get("DATALAYER_SCHEDULER_URL") @@ -323,7 +308,6 @@ def from_environment( resolved_status_url = resolved_status_url.rstrip("/") resolved_support_url = resolved_support_url.rstrip("/") resolved_mcp_server_url = resolved_mcp_server_url.rstrip("/") - resolved_ray_url = resolved_ray_url.rstrip("/") resolved_scheduler_url = resolved_scheduler_url.rstrip("/") return cls( @@ -341,7 +325,6 @@ def from_environment( status_url=resolved_status_url, support_url=resolved_support_url, mcp_server_url=resolved_mcp_server_url, - ray_url=resolved_ray_url, scheduler_url=resolved_scheduler_url, ) @@ -361,7 +344,6 @@ def __post_init__(self) -> None: self.status_url = self.status_url.rstrip("/") self.support_url = self.support_url.rstrip("/") self.mcp_server_url = self.mcp_server_url.rstrip("/") - self.ray_url = self.ray_url.rstrip("/") self.scheduler_url = self.scheduler_url.rstrip("/") def as_dict(self) -> dict[str, str]: @@ -385,7 +367,6 @@ def get_all_urls( status_url: Optional[str] = None, support_url: Optional[str] = None, mcp_server_url: Optional[str] = None, - ray_url: Optional[str] = None, scheduler_url: Optional[str] = None, ) -> dict[str, str]: """Resolve and return all service URLs with optional overrides.""" @@ -404,6 +385,5 @@ def get_all_urls( status_url=status_url, support_url=support_url, mcp_server_url=mcp_server_url, - ray_url=ray_url, scheduler_url=scheduler_url, ).as_dict() diff --git a/src/api/constants.ts b/src/api/constants.ts index b90f6431..c0e312d4 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -11,6 +11,7 @@ export const API_BASE_PATHS = { IAM: '/api/iam/v1', OTEL: '/api/otel/v1', RUNTIMES: '/api/runtimes/v1', + SCHEDULER: '/api/scheduler/v1', SPACER: '/api/spacer/v1', } as const; @@ -26,6 +27,8 @@ export const DEFAULT_SERVICE_URLS = { OTEL: 'https://prod1.datalayer.run', /** Default URL for Runtimes service */ RUNTIMES: 'https://r1.datalayer.run', + /** Default URL for Scheduler (cron schedules and runs) service */ + SCHEDULER: 'https://prod1.datalayer.run', /** Default URL for Spacer (workspaces and collaboration) service */ SPACER: 'https://prod1.datalayer.run', } as const; diff --git a/src/api/index.ts b/src/api/index.ts index 6a464843..c3d63093 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -24,6 +24,7 @@ export type { IRequestDatalayerAPIOptions } from './DatalayerApi'; export * as iam from './iam'; export * as otel from './otel'; export * as runtimes from './runtimes'; +export * as scheduler from './scheduler'; export * as spacer from './spacer'; /** diff --git a/src/api/scheduler/index.ts b/src/api/scheduler/index.ts new file mode 100644 index 00000000..9f3cfba4 --- /dev/null +++ b/src/api/scheduler/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2023-2025 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +/** + * Scheduler API module for the Datalayer platform. + * + * @module api/scheduler + */ + +export * from './schedules'; diff --git a/src/api/scheduler/schedules.ts b/src/api/scheduler/schedules.ts new file mode 100644 index 00000000..c4f0de3d --- /dev/null +++ b/src/api/scheduler/schedules.ts @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2023-2025 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +/** + * Scheduler schedules API functions for the Datalayer platform. + * + * Provides functions for listing, creating, and updating cron schedules that + * trigger automated notebook executions. + * + * @module api/scheduler/schedules + */ + +import { requestDatalayerAPI } from '../DatalayerApi'; +import { API_BASE_PATHS, DEFAULT_SERVICE_URLS } from '../constants'; + +/** + * Raw schedule document as returned by the scheduler service. + * + * Fields follow the Solr suffix convention (`_s`, `_b`, `_dt`, `_i`). + */ +export interface ScheduleDoc { + uid?: string; + type_s?: string; + notebook_uid_s?: string; + owner_uid_s?: string; + cron_expression_s?: string; + preset_s?: string; + enabled_b?: boolean; + [key: string]: unknown; +} + +/** + * Request payload to create or update a notebook schedule. + */ +export interface UpsertScheduleRequest { + /** Target notebook uid. */ + notebookUid: string; + /** Cron expression (e.g. `* * * * *`). */ + cronExpression: string; + /** Optional preset identifier (e.g. `every-minute`, `hourly`, `daily`, `custom`). */ + preset?: string; + /** Whether the schedule is enabled. Defaults to `true`. */ + enabled?: boolean; +} + +/** + * Request payload to update an existing schedule by uid. + */ +export interface UpdateScheduleRequest { + cronExpression?: string; + preset?: string; + enabled?: boolean; +} + +/** + * Response shape for listing schedules. + */ +export interface ListSchedulesResponse { + success: boolean; + message: string; + schedules: ScheduleDoc[]; +} + +/** + * Response shape for a single schedule mutation. + */ +export interface ScheduleResponse { + success: boolean; + message: string; + schedule: ScheduleDoc; +} + +/** + * List the schedules owned by the authenticated user. + * @param token - Authentication token + * @param baseUrl - Base URL for the scheduler service (defaults to production) + * @param includeDisabled - Include disabled schedules in the result + * @returns Promise resolving to the list of schedules + */ +export const listSchedules = async ( + token: string, + baseUrl: string = DEFAULT_SERVICE_URLS.SCHEDULER, + includeDisabled: boolean = false, +): Promise => { + const query = includeDisabled ? '?includeDisabled=true' : ''; + return requestDatalayerAPI({ + url: `${baseUrl}${API_BASE_PATHS.SCHEDULER}/schedules${query}`, + method: 'GET', + token, + }); +}; + +/** + * Create or update the schedule for a notebook. + * @param token - Authentication token + * @param data - Schedule configuration + * @param baseUrl - Base URL for the scheduler service (defaults to production) + * @returns Promise resolving to the upserted schedule + */ +export const upsertSchedule = async ( + token: string, + data: UpsertScheduleRequest, + baseUrl: string = DEFAULT_SERVICE_URLS.SCHEDULER, +): Promise => { + return requestDatalayerAPI({ + url: `${baseUrl}${API_BASE_PATHS.SCHEDULER}/schedules`, + method: 'POST', + token, + body: { + enabled: true, + preset: 'custom', + ...data, + }, + }); +}; + +/** + * Update an existing schedule by uid. + * @param token - Authentication token + * @param scheduleUid - The schedule uid + * @param data - Fields to update + * @param baseUrl - Base URL for the scheduler service (defaults to production) + * @returns Promise resolving to the updated schedule + */ +export const updateSchedule = async ( + token: string, + scheduleUid: string, + data: UpdateScheduleRequest, + baseUrl: string = DEFAULT_SERVICE_URLS.SCHEDULER, +): Promise => { + return requestDatalayerAPI({ + url: `${baseUrl}${API_BASE_PATHS.SCHEDULER}/schedules/${scheduleUid}`, + method: 'PUT', + token, + body: data, + }); +}; + +/** + * Disable a schedule by uid. + * @param token - Authentication token + * @param scheduleUid - The schedule uid + * @param baseUrl - Base URL for the scheduler service (defaults to production) + * @returns Promise resolving to the disabled schedule + */ +export const disableSchedule = async ( + token: string, + scheduleUid: string, + baseUrl: string = DEFAULT_SERVICE_URLS.SCHEDULER, +): Promise => { + return requestDatalayerAPI({ + url: `${baseUrl}${API_BASE_PATHS.SCHEDULER}/schedules/${scheduleUid}/disable`, + method: 'POST', + token, + }); +}; diff --git a/src/components/index.ts b/src/components/index.ts index b3c148a5..2c774990 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,8 @@ export * from './auth'; export * from './billing'; +export * from './scheduler'; export * from './sharing'; export * from './sparklines'; export * from './time'; +export * from './timeline'; diff --git a/src/components/scheduler/ScheduleMenu.tsx b/src/components/scheduler/ScheduleMenu.tsx new file mode 100644 index 00000000..18622a68 --- /dev/null +++ b/src/components/scheduler/ScheduleMenu.tsx @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2023-2025 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +import { useMemo, useState } from 'react'; +import { + ActionList, + ActionMenu, + Box, + Button, + Dialog, + FormControl, + IconButton, + Spinner, + Text, + TextInput, +} from '@primer/react'; +import { ClockIcon, TrashIcon, type Icon } from '@primer/octicons-react'; +import { + disableSchedule, + updateSchedule, + upsertSchedule, + type ScheduleDoc, +} from '../../api/scheduler/schedules'; +import { DEFAULT_SERVICE_URLS } from '../../api/constants'; + +/** + * Supported schedule presets. + */ +export type SchedulePreset = 'every-minute' | 'hourly' | 'daily' | 'custom'; + +/** + * Props for the self-contained {@link ScheduleMenu} component. + * + * The component performs the scheduler service calls internally. Provide the + * `notebookUid`, an authentication `token`, and the scheduler service + * `baseUrl` (configurable via props) to enable scheduling. + */ +export type ScheduleMenuProps = { + /** Target notebook uid to schedule (used to create/update a schedule). */ + notebookUid?: string; + /** + * Existing schedule uid to update in place. When provided, changes are + * persisted with a PUT update instead of a notebook upsert. + */ + scheduleUid?: string; + /** Authentication token used for scheduler API calls. */ + token?: string; + /** Base URL of the scheduler service. Defaults to the production service. */ + baseUrl?: string; + /** Force-disable the menu. When omitted, the menu is enabled if a token and notebook uid are present. */ + enabled?: boolean; + /** Icon shown on the anchor button (replaced by a spinner while saving). */ + icon?: Icon; + /** Optional color for the anchor icon. */ + iconColor?: string; + /** Accessible label for the anchor button. */ + ariaLabel?: string; + /** Initial preset selection. */ + initialPreset?: SchedulePreset; + /** Initial cron expression. */ + initialCronExpression?: string; + /** Called after a schedule is successfully saved. */ + onSaved?: (schedule: ScheduleDoc) => void; + /** Called after a schedule is successfully deleted/disabled. */ + onDeleted?: (schedule: ScheduleDoc) => void; + /** Called when saving a schedule fails. */ + onError?: (error: unknown) => void; +}; + +const SCHEDULE_PRESETS: Array<{ + id: Exclude; + label: string; + cron: string; +}> = [ + { id: 'every-minute', label: 'Every minute', cron: '* * * * *' }, + { id: 'hourly', label: 'Every hour', cron: '0 * * * *' }, + { id: 'daily', label: 'Every day', cron: '0 0 * * *' }, +]; + +const presetFromCron = (cronExpression: string): SchedulePreset => { + const normalized = cronExpression.trim(); + const preset = SCHEDULE_PRESETS.find(item => item.cron === normalized); + return preset ? preset.id : 'custom'; +}; + +/** + * Self-contained schedule menu that lets a user pick a cron schedule for a + * notebook and persists it to the scheduler service. + * + * The component owns its loading state and renders a spinner in place of the + * anchor icon while a save request is in flight. The scheduler service URL is + * configurable through the `baseUrl` prop. + */ +export const ScheduleMenu = ({ + notebookUid, + scheduleUid, + token, + baseUrl = DEFAULT_SERVICE_URLS.SCHEDULER, + enabled, + icon = ClockIcon, + iconColor, + ariaLabel = 'Schedule execution', + initialPreset = 'every-minute', + initialCronExpression = '* * * * *', + onSaved, + onDeleted, + onError, +}: ScheduleMenuProps) => { + const initialCron = + initialCronExpression || + SCHEDULE_PRESETS.find(item => item.id === initialPreset)?.cron || + '* * * * *'; + const [cronExpression, setCronExpression] = useState(initialCron); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deleteConfirmUid, setDeleteConfirmUid] = useState(''); + const preset = useMemo( + () => presetFromCron(cronExpression), + [cronExpression], + ); + + const isEnabled = + (enabled ?? true) && + Boolean(token) && + Boolean(notebookUid || scheduleUid) && + !isSaving && + !isDeleting; + + const selectedPresetDescription = useMemo(() => { + const selected = SCHEDULE_PRESETS.find( + item => item.cron === cronExpression.trim(), + ); + if (selected) { + return `${selected.label} (${selected.cron})`; + } + return `Custom (${cronExpression || 'Cron expression not set'})`; + }, [cronExpression]); + + const saveSchedule = async (nextPreset: SchedulePreset, nextCron: string) => { + const cron = nextCron.trim(); + if (!token || (!notebookUid && !scheduleUid) || !cron) { + return; + } + setIsSaving(true); + try { + let schedule: ScheduleDoc; + if (scheduleUid) { + const response = await updateSchedule( + token, + scheduleUid, + { + cronExpression: cron, + preset: nextPreset, + }, + baseUrl, + ); + schedule = response.schedule; + } else { + const response = await upsertSchedule( + token, + { + notebookUid: notebookUid as string, + cronExpression: cron, + preset: nextPreset, + enabled: true, + }, + baseUrl, + ); + schedule = response.schedule; + } + onSaved?.(schedule); + } catch (error) { + onError?.(error); + } finally { + setIsSaving(false); + } + }; + + const selectPreset = (nextPreset: Exclude) => { + const next = SCHEDULE_PRESETS.find(item => item.id === nextPreset); + if (!next) { + return; + } + setCronExpression(next.cron); + void saveSchedule(nextPreset, next.cron); + }; + + const applyCustomCron = () => { + const value = cronExpression.trim(); + const inferredPreset = presetFromCron(value); + void saveSchedule(inferredPreset, value); + }; + + const performDeleteSchedule = async () => { + if (!token || !scheduleUid) { + return; + } + setIsDeleting(true); + try { + const response = await disableSchedule(token, scheduleUid, baseUrl); + setIsDeleteDialogOpen(false); + setDeleteConfirmUid(''); + onDeleted?.(response.schedule); + } catch (error) { + onError?.(error); + } finally { + setIsDeleting(false); + } + }; + + return ( + <> + + + : icon} + variant="invisible" + sx={iconColor ? { color: iconColor } : undefined} + aria-label={ariaLabel} + title={ + isSaving + ? `${ariaLabel} (saving…)` + : isEnabled + ? ariaLabel + : `${ariaLabel} (disabled)` + } + /> + + + + + Schedule + + + + {SCHEDULE_PRESETS.map(option => ( + selectPreset(option.id)} + > + {option.label} + + {option.cron} + + + ))} + + + + Cron Expression + + setCronExpression(event.currentTarget.value)} + placeholder="* * * * *" + aria-label="Cron expression" + block + disabled={!isEnabled} + /> + + + {scheduleUid ? ( + + ) : null} + + + Current: {selectedPresetDescription} + + + + + {isDeleteDialogOpen && scheduleUid ? ( + { + if (isDeleting) { + return; + } + setIsDeleteDialogOpen(false); + setDeleteConfirmUid(''); + }} + width="medium" + > + + + This action will disable the schedule and remove planned runs. + Type{' '} + + {scheduleUid} + {' '} + to confirm. + + + Schedule UID + + setDeleteConfirmUid(event.currentTarget.value) + } + placeholder={scheduleUid} + autoFocus + /> + + + + + + + + ) : null} + + ); +}; + +export default ScheduleMenu; diff --git a/src/components/scheduler/index.ts b/src/components/scheduler/index.ts new file mode 100644 index 00000000..19bfa586 --- /dev/null +++ b/src/components/scheduler/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) 2023-2025 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +export * from './ScheduleMenu'; diff --git a/src/components/timeline/Timeline.tsx b/src/components/timeline/Timeline.tsx new file mode 100644 index 00000000..caa0f8cb --- /dev/null +++ b/src/components/timeline/Timeline.tsx @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023-2026 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +import { type ReactNode } from 'react'; +import { Box, Text } from '@primer/react'; + +export type TimelineStatus = + | 'done' + | 'current' + | 'pending' + | 'failed' + | 'neutral'; + +export type TimelineItem = { + id: string; + title: string; + timestamp?: string; + status?: TimelineStatus; + subtitle?: ReactNode; +}; + +export type TimelineProps = { + items: TimelineItem[]; + renderTimestamp?: (value?: string) => ReactNode; +}; + +const statusStyles: Record< + TimelineStatus, + { + dotBackground: string; + dotBorder: string; + lineColor: string; + titleColor: string; + } +> = { + done: { + dotBackground: 'success.emphasis', + dotBorder: 'success.emphasis', + lineColor: 'success.muted', + titleColor: 'fg.default', + }, + current: { + dotBackground: 'attention.emphasis', + dotBorder: 'attention.emphasis', + lineColor: 'attention.muted', + titleColor: 'fg.default', + }, + pending: { + dotBackground: 'canvas.default', + dotBorder: 'border.default', + lineColor: 'border.default', + titleColor: 'fg.muted', + }, + failed: { + dotBackground: 'danger.emphasis', + dotBorder: 'danger.emphasis', + lineColor: 'danger.muted', + titleColor: 'danger.fg', + }, + neutral: { + dotBackground: 'accent.emphasis', + dotBorder: 'accent.emphasis', + lineColor: 'accent.muted', + titleColor: 'fg.default', + }, +}; + +/** + * Horizontal metro-style timeline with connected markers and labels. + * + * The first item is rendered on the left. Pass items in the desired order + * (for example: newest to oldest). + */ +export const Timeline = ({ items, renderTimestamp }: TimelineProps) => { + if (!items.length) { + return null; + } + + return ( + + + {items.map((item, index) => { + const status = item.status || 'neutral'; + const style = statusStyles[status]; + const hasNext = index < items.length - 1; + return ( + + + + {hasNext ? ( + + ) : null} + + + {item.title} + + + {renderTimestamp + ? renderTimestamp(item.timestamp) + : item.timestamp || 'n/a'} + + {item.subtitle ? ( + + {item.subtitle} + + ) : null} + + ); + })} + + + ); +}; diff --git a/src/components/timeline/index.ts b/src/components/timeline/index.ts new file mode 100644 index 00000000..3be900b0 --- /dev/null +++ b/src/components/timeline/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) 2023-2026 Datalayer, Inc. + * Distributed under the terms of the Modified BSD License. + */ + +export * from './Timeline'; From 85faea9455f603ed0ba9bb77b6ee38eba3cde1ef Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Tue, 9 Jun 2026 19:32:13 +0200 Subject: [PATCH 07/43] schedule --- src/components/scheduler/ScheduleMenu.tsx | 44 +++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/components/scheduler/ScheduleMenu.tsx b/src/components/scheduler/ScheduleMenu.tsx index 18622a68..099ac39f 100644 --- a/src/components/scheduler/ScheduleMenu.tsx +++ b/src/components/scheduler/ScheduleMenu.tsx @@ -3,7 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ActionList, ActionMenu, @@ -79,6 +79,8 @@ const SCHEDULE_PRESETS: Array<{ { id: 'daily', label: 'Every day', cron: '0 0 * * *' }, ]; +const DEFAULT_CRON_EXPRESSION = '* * * * *'; + const presetFromCron = (cronExpression: string): SchedulePreset => { const normalized = cronExpression.trim(); const preset = SCHEDULE_PRESETS.find(item => item.cron === normalized); @@ -103,23 +105,37 @@ export const ScheduleMenu = ({ iconColor, ariaLabel = 'Schedule execution', initialPreset = 'every-minute', - initialCronExpression = '* * * * *', + initialCronExpression, onSaved, onDeleted, onError, }: ScheduleMenuProps) => { - const initialCron = - initialCronExpression || - SCHEDULE_PRESETS.find(item => item.id === initialPreset)?.cron || - '* * * * *'; + const providedInitialCron = (initialCronExpression || '').trim(); + const hasProvidedInitialCron = providedInitialCron.length > 0; + const initialCron = hasProvidedInitialCron + ? providedInitialCron + : DEFAULT_CRON_EXPRESSION; const [cronExpression, setCronExpression] = useState(initialCron); + const [hasExplicitCron, setHasExplicitCron] = useState( + hasProvidedInitialCron, + ); const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deleteConfirmUid, setDeleteConfirmUid] = useState(''); + + useEffect(() => { + const nextProvidedCron = (initialCronExpression || '').trim(); + const nextHasExplicitCron = nextProvidedCron.length > 0; + setHasExplicitCron(nextHasExplicitCron); + setCronExpression( + nextHasExplicitCron ? nextProvidedCron : DEFAULT_CRON_EXPRESSION, + ); + }, [initialCronExpression, scheduleUid, notebookUid]); + const preset = useMemo( - () => presetFromCron(cronExpression), - [cronExpression], + () => (hasExplicitCron ? presetFromCron(cronExpression) : 'custom'), + [cronExpression, hasExplicitCron], ); const isEnabled = @@ -130,6 +146,9 @@ export const ScheduleMenu = ({ !isDeleting; const selectedPresetDescription = useMemo(() => { + if (!hasExplicitCron) { + return 'Not scheduled'; + } const selected = SCHEDULE_PRESETS.find( item => item.cron === cronExpression.trim(), ); @@ -137,7 +156,7 @@ export const ScheduleMenu = ({ return `${selected.label} (${selected.cron})`; } return `Custom (${cronExpression || 'Cron expression not set'})`; - }, [cronExpression]); + }, [cronExpression, hasExplicitCron]); const saveSchedule = async (nextPreset: SchedulePreset, nextCron: string) => { const cron = nextCron.trim(); @@ -184,6 +203,7 @@ export const ScheduleMenu = ({ if (!next) { return; } + setHasExplicitCron(true); setCronExpression(next.cron); void saveSchedule(nextPreset, next.cron); }; @@ -266,7 +286,11 @@ export const ScheduleMenu = ({ setCronExpression(event.currentTarget.value)} + onChange={event => { + const value = event.currentTarget.value; + setHasExplicitCron(value.trim().length > 0); + setCronExpression(value); + }} placeholder="* * * * *" aria-label="Cron expression" block From 842badf595fa265af23f2ae96f42e370db414d44 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 10 Jun 2026 11:17:36 +0200 Subject: [PATCH 08/43] env --- datalayer_core/cli/commands/exec.py | 11 +++++++++-- datalayer_core/cli/commands/runtimes.py | 15 ++++++++++++++- datalayer_core/client/client.py | 20 ++++++++++++++++---- datalayer_core/runtimes/runtime.py | 5 ++++- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/datalayer_core/cli/commands/exec.py b/datalayer_core/cli/commands/exec.py index 2cb22679..bb4866e7 100644 --- a/datalayer_core/cli/commands/exec.py +++ b/datalayer_core/cli/commands/exec.py @@ -585,6 +585,11 @@ def main( "--token", help="Authentication token (Bearer token for API requests).", ), + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Authentication API key (alias for --token).", + ), example_notebook: bool = typer.Option( False, "--example-notebook", @@ -603,6 +608,8 @@ def main( ) -> None: """Execute a Python file or Jupyter notebook on a Datalayer runtime.""" + auth_token = token or api_key + if example_notebook and example_py: console.print( "[red]Error: --example-notebook and --example-py are mutually exclusive[/red]" @@ -663,10 +670,10 @@ def main( # Determine which runtime to use selected_runtime = runtime if selected_runtime is None: - selected_runtime = _select_runtime(token=token) + selected_runtime = _select_runtime(token=auth_token) # Create exec service and execute - exec_service = RuntimesExecService(token=token) + exec_service = RuntimesExecService(token=auth_token) try: # Initialize connection to runtime diff --git a/datalayer_core/cli/commands/runtimes.py b/datalayer_core/cli/commands/runtimes.py index 562f9e1d..9351c4a8 100644 --- a/datalayer_core/cli/commands/runtimes.py +++ b/datalayer_core/cli/commands/runtimes.py @@ -31,12 +31,13 @@ def runtimes_callback(ctx: typer.Context) -> None: def _make_client( token: Optional[str] = None, + api_key: Optional[str] = None, iam_url: Optional[str] = None, runtimes_url: Optional[str] = None, ) -> DatalayerClient: """Create a DatalayerClient with optional runtimes URL override.""" urls = DatalayerURLs.from_environment(iam_url=iam_url, runtimes_url=runtimes_url) - return DatalayerClient(urls=urls, token=token) + return DatalayerClient(urls=urls, token=token or api_key) @app.command(name="ls") @@ -129,6 +130,11 @@ def create_runtime( "--token", help="Authentication token (Bearer token for API requests).", ), + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Authentication API key (alias for --token).", + ), iam_url: Optional[str] = typer.Option( None, "--iam-url", @@ -146,6 +152,7 @@ def create_runtime( try: client = _make_client( token=token, + api_key=api_key, iam_url=iam_url, runtimes_url=runtimes_url, ) @@ -207,6 +214,11 @@ def terminate_runtime( "--token", help="Authentication token (Bearer token for API requests).", ), + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Authentication API key (alias for --token).", + ), iam_url: Optional[str] = typer.Option( None, "--iam-url", @@ -224,6 +236,7 @@ def terminate_runtime( try: client = _make_client( token=token, + api_key=api_key, iam_url=iam_url, runtimes_url=runtimes_url, ) diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index 8bd226fa..2532659e 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -269,6 +269,7 @@ def create_runtime( billable_account_uid: Optional[str] = None, billable_account_type: Optional[str] = None, billable_account_handle: Optional[str] = None, + api_key: Optional[str] = None, ) -> RuntimeService: """ Create a new runtime (kernel) for code execution. @@ -312,6 +313,10 @@ def create_runtime( # print(f"Runtime {name}") + client_for_request = self + if api_key: + client_for_request = DatalayerClient(urls=self._urls, token=api_key) + if snapshot_name is not None: snapshots = self.list_snapshots() snapshot_uid = None @@ -325,7 +330,7 @@ def create_runtime( f"Snapshot '{snapshot_name}' not found. Available snapshots: {[s.name for s in snapshots]}" ) - response = self._create_runtime( + response = client_for_request._create_runtime( given_name=name, environment_name=environment, from_snapshot_uid=snapshot_uid, @@ -338,7 +343,7 @@ def create_runtime( ) else: # Create runtime without snapshot - response = self._create_runtime( + response = client_for_request._create_runtime( given_name=name, environment_name=environment, agent_spec_id=agent_spec_id, @@ -374,7 +379,7 @@ def create_runtime( environment=runtime_data["environment_name"], run_url=self._urls.run_url, iam_url=self._urls.iam_url, - token=self._token, + token=api_key or self._token, ingress=runtime_data["ingress"], jupyter_token=runtime_data["token"], pod_name=runtime_data["pod_name"], @@ -434,7 +439,11 @@ def list_runtimes(self) -> list[RuntimeService]: ) return runtime_services - def terminate_runtime(self, runtime: Union[RuntimeService, str]) -> bool: + def terminate_runtime( + self, + runtime: Union[RuntimeService, str], + api_key: Optional[str] = None, + ) -> bool: """ Terminate a running Runtime. @@ -450,6 +459,9 @@ def terminate_runtime(self, runtime: Union[RuntimeService, str]) -> bool: """ pod_name = runtime.pod_name if isinstance(runtime, RuntimeService) else runtime if pod_name is not None: + if api_key: + client_for_request = DatalayerClient(urls=self._urls, token=api_key) + return client_for_request._terminate_runtime(pod_name).get("success", False) return self._terminate_runtime(pod_name)["success"] else: return False diff --git a/datalayer_core/runtimes/runtime.py b/datalayer_core/runtimes/runtime.py index dd292ccc..e1ce7c17 100644 --- a/datalayer_core/runtimes/runtime.py +++ b/datalayer_core/runtimes/runtime.py @@ -60,6 +60,7 @@ def __init__( run_url: str = DEFAULT_DATALAYER_RUN_URL, iam_url: Optional[str] = None, token: Optional[str] = None, + api_key: Optional[str] = None, pod_name: Optional[str] = None, ingress: Optional[str] = None, reservation_id: Optional[str] = None, @@ -86,6 +87,8 @@ def __init__( Datalayer IAM server URL. If not provided, defaults to run_url. token : Optional[str] Authentication token (can also be set via DATALAYER_API_KEY env var). + api_key : Optional[str] + Authentication API key alias for ``token``. pod_name : Optional[str] Name of the pod running the runtime. ingress : Optional[str] @@ -110,7 +113,7 @@ def __init__( time_reservation=time_reservation, run_url=run_url, iam_url=iam_url or run_url, - token=token, + token=token or api_key, external_token=None, pod_name=pod_name, ingress=ingress, From ed1193fcb0504f9ab248ceb8f5eedf3a2bb6be09 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 10 Jun 2026 14:50:46 +0200 Subject: [PATCH 09/43] api key --- datalayer_core/cli/commands/runtimes.py | 44 ++++++---- datalayer_core/client/client.py | 107 ++++++++++++++++++++++++ datalayer_core/utils/notebook.py | 6 +- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/datalayer_core/cli/commands/runtimes.py b/datalayer_core/cli/commands/runtimes.py index 9351c4a8..67ab7906 100644 --- a/datalayer_core/cli/commands/runtimes.py +++ b/datalayer_core/cli/commands/runtimes.py @@ -434,6 +434,11 @@ def runtime_health( "--token", help="Authentication token (Bearer token for API requests).", ), + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Authentication API key (alias for --token).", + ), iam_url: Optional[str] = typer.Option( None, "--iam-url", @@ -445,9 +450,14 @@ def runtime_health( help="Datalayer Runtimes server URL", ), ) -> None: - """Check health/reachability of a runtime endpoint.""" + """Check runtime health by executing a probe on the sandbox.""" try: - client = _make_client(token=token, iam_url=iam_url, runtimes_url=runtimes_url) + client = _make_client( + token=token, + api_key=api_key, + iam_url=iam_url, + runtimes_url=runtimes_url, + ) runtimes = client.list_runtimes() if not runtimes: console.print("[yellow]No running runtimes found.[/yellow]") @@ -465,23 +475,16 @@ def runtime_health( else: selected = runtimes[0] - pod_name = selected.pod_name or "" + pod_name = selected.pod_name or selected.uid or selected.name or "" refreshed = client.get_runtime(pod_name) + health = client.check_runtime_health( + pod_name, + api_key=api_key, + ) - endpoint = str(refreshed.ingress or "").rstrip("/") - runtime_token = str(refreshed.jupyter_token or "") - health_status = "unreachable" - detail = "Missing ingress URL" - - if endpoint: - try: - response = fetch(f"{endpoint}/api/kernels", token=runtime_token, timeout=15) - kernels = response.json() if response.content else [] - kernel_count = len(kernels) if isinstance(kernels, list) else "n/a" - health_status = "alive" - detail = f"Jupyter API reachable (kernels={kernel_count})" - except Exception as e: - detail = str(e) + health_status = "alive" if bool(health.get("success")) else "unreachable" + detail = str(health.get("message") or "health probe failed") + probe_mode = str(health.get("probe_mode") or "n/a") table = Table(title="Runtime Health") table.add_column("Field", style="cyan") @@ -489,11 +492,16 @@ def runtime_health( table.add_row("Runtime", str(refreshed.name or pod_name)) table.add_row("Pod", str(pod_name)) table.add_row("UID", str(refreshed.uid or "")) - table.add_row("Ingress", endpoint or "n/a") + table.add_row("Ingress", str(refreshed.ingress or "n/a")) + table.add_row("Probe", probe_mode) table.add_row("Status", health_status) table.add_row("Detail", detail) console.print(table) + stdout_tail = str(health.get("stdout_tail") or "").strip() + if stdout_tail: + console.print(f"[dim]Probe stdout: {stdout_tail}[/dim]") + if health_status != "alive": raise typer.Exit(1) except typer.Exit: diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index 2532659e..a08c7432 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -14,6 +14,8 @@ from functools import lru_cache from typing import Any, Optional, Union +from jupyter_kernel_client import KernelClient + from datalayer_core.mixins.authn import AuthnMixin from datalayer_core.mixins.environments import EnvironmentsMixin from datalayer_core.mixins.evals import EvalsMixin @@ -551,6 +553,111 @@ def update_runtime( raise RuntimeError(f"Failed to update runtime '{pod_name}': {message}") return True + def check_runtime_health( + self, + runtime: Union[RuntimeService, str], + probe_code: str = "print('datalayer runtime health probe')", + timeout: float = 20.0, + api_key: Optional[str] = None, + ) -> dict[str, Any]: + """Check runtime reachability and execute a probe on the sandbox. + + Parameters + ---------- + runtime : Union[RuntimeService, str] + Runtime object or runtime identifier (pod name/uid/name). + probe_code : str + Python code to execute as health probe on the sandbox. + timeout : float + Probe execution timeout in seconds. + api_key : Optional[str] + Optional API key override used for runtime lookup. + + Returns + ------- + dict[str, Any] + Health result with success flag and diagnostics. + """ + client_for_request = self + if api_key: + client_for_request = DatalayerClient(urls=self._urls, token=api_key) + + runtime_service = ( + runtime if isinstance(runtime, RuntimeService) else client_for_request.get_runtime(runtime) + ) + + endpoint = str(runtime_service.ingress or "").rstrip("/") + runtime_token = str( + runtime_service.jupyter_token + or client_for_request._get_token() + or "" + ).strip() + + result: dict[str, Any] = { + "success": False, + "runtime_uid": runtime_service.uid, + "runtime_pod_name": runtime_service.pod_name, + "runtime_name": runtime_service.name, + "ingress": endpoint, + "probe_mode": "sandbox_execute_code", + } + + if not endpoint: + result["message"] = "runtime ingress is missing" + return result + if not runtime_token: + result["message"] = "runtime token is missing" + return result + + kernel_client: Optional[KernelClient] = None + try: + kernel_client = KernelClient(server_url=endpoint, token=runtime_token) + kernel_client.start() + reply = kernel_client.execute(probe_code, timeout=timeout) + outputs = reply.get("outputs", []) + if not isinstance(outputs, list): + outputs = [] + + error_outputs = [ + output + for output in outputs + if isinstance(output, dict) + and str(output.get("output_type") or "") == "error" + ] + + if error_outputs: + first_error = error_outputs[0] + result["message"] = "sandbox probe execution failed" + result["error_name"] = first_error.get("ename") + result["error_value"] = first_error.get("evalue") + traceback_lines = first_error.get("traceback") + if isinstance(traceback_lines, list): + result["traceback_tail"] = "\n".join( + [str(line) for line in traceback_lines if line is not None] + )[-4000:] + return result + + stream_text_parts = [] + for output in outputs: + if not isinstance(output, dict): + continue + if str(output.get("output_type") or "") == "stream": + stream_text_parts.append(str(output.get("text") or "")) + + result["success"] = True + result["message"] = "runtime reachable and sandbox probe executed" + result["stdout_tail"] = "".join(stream_text_parts)[-1000:] + return result + except Exception as exc: + result["message"] = f"runtime health probe exception: {exc}" + return result + finally: + if kernel_client is not None: + try: + kernel_client.stop() + except Exception: + pass + def list_secrets(self) -> list[SecretModel]: """ List all secrets available in the Datalayer environment. diff --git a/datalayer_core/utils/notebook.py b/datalayer_core/utils/notebook.py index f91ddddb..2117900d 100644 --- a/datalayer_core/utils/notebook.py +++ b/datalayer_core/utils/notebook.py @@ -32,6 +32,10 @@ def get_cells(filepath: Path) -> t.Iterator[tuple[Optional[str], str]]: return for cell in nb.cells: if cell.cell_type == "code": - yield cell.id, cell.source + # Some notebooks do not include a cell id; keep execution robust. + cell_id = getattr(cell, "id", None) + if cell_id is None and isinstance(cell, dict): + cell_id = cell.get("id") + yield (str(cell_id) if cell_id is not None else None), cell.source else: yield None, filepath.read_text(encoding="utf-8") From 5f880ce4b9cbc04bab6af91ea3341b573614b421 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 11 Jun 2026 06:13:12 +0200 Subject: [PATCH 10/43] api keys --- datalayer_core/cli/__main__.py | 8 +- datalayer_core/cli/commands/api_keys.py | 192 ++++++++++++++++++ datalayer_core/cli/commands/tokens.py | 174 ---------------- datalayer_core/client/client.py | 73 +++---- datalayer_core/displays/api_keys.py | 64 ++++++ datalayer_core/displays/tokens.py | 64 ------ datalayer_core/mixins/__init__.py | 4 +- .../mixins/{tokens.py => api_keys.py} | 58 +++--- datalayer_core/models/__init__.py | 7 +- datalayer_core/models/api_key.py | 39 ++++ datalayer_core/tests/test_cli.py | 4 +- datalayer_core/tests/test_client.py | 6 +- examples/nextjs/README.md | 2 +- examples/nextjs/src/app/welcome/page.tsx | 8 +- src/hooks/useCache.ts | 10 +- src/views/iam-tokens/IAMTokenEdit.tsx | 4 +- src/views/iam-tokens/IAMTokenNew.tsx | 4 +- src/views/iam-tokens/IAMTokens.tsx | 54 ++--- src/views/iam-tokens/Tokens.tsx | 58 +++--- 19 files changed, 445 insertions(+), 388 deletions(-) create mode 100644 datalayer_core/cli/commands/api_keys.py delete mode 100644 datalayer_core/cli/commands/tokens.py create mode 100644 datalayer_core/displays/api_keys.py delete mode 100644 datalayer_core/displays/tokens.py rename datalayer_core/mixins/{tokens.py => api_keys.py} (55%) create mode 100644 datalayer_core/models/api_key.py diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index 9d887a2a..484dbadb 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -47,8 +47,8 @@ from datalayer_core.cli.commands.secrets import secrets_ls from datalayer_core.cli.commands.subscription import app as subscription_app from datalayer_core.cli.commands.subscription import subscription_root -from datalayer_core.cli.commands.tokens import app as tokens_app -from datalayer_core.cli.commands.tokens import tokens_ls +from datalayer_core.cli.commands.api_keys import app as api_keys_app +from datalayer_core.cli.commands.api_keys import api_keys_ls from datalayer_core.cli.commands.usage import app as usage_app from datalayer_core.cli.commands.usage import usage_root from datalayer_core.cli.commands.plans import app as plans_app @@ -203,7 +203,7 @@ def main_callback( app.add_typer(secrets_app) app.add_typer(snapshots_app) app.add_typer(subscription_app) -app.add_typer(tokens_app) +app.add_typer(api_keys_app) app.add_typer(users_app) app.add_typer(usage_app) app.add_typer(plans_app) @@ -226,7 +226,7 @@ def main_callback( app.command(name="secrets-ls")(secrets_ls) app.command(name="snapshots-ls")(snapshots_ls) app.command(name="checkpoints-ls")(checkpoints_ls) -app.command(name="tokens-ls")(tokens_ls) +app.command(name="api-keys-ls")(api_keys_ls) app.command(name="agent-nodes-ls")(agent_nodes_ls) app.command(name="agents-ls")(agents_ls) diff --git a/datalayer_core/cli/commands/api_keys.py b/datalayer_core/cli/commands/api_keys.py new file mode 100644 index 00000000..d481078f --- /dev/null +++ b/datalayer_core/cli/commands/api_keys.py @@ -0,0 +1,192 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""API key commands for Datalayer CLI.""" + +from typing import Optional + +import typer +from rich.console import Console + +from datalayer_core.client.client import DatalayerClient +from datalayer_core.displays.api_keys import display_api_keys +from datalayer_core.models.api_key import ApiKeyType + +# Create a Typer app for API key commands +app = typer.Typer( + name="api-keys", + help="API key management commands", + invoke_without_command=True, +) + +console = Console() + + +@app.callback() +def api_keys_callback(ctx: typer.Context) -> None: + """API key management commands.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +@app.command(name="ls") +def list_api_keys( + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """List all API keys.""" + try: + client = DatalayerClient(token=token) + api_keys = client.list_api_keys() + + # Convert to dict format for display_api_keys + api_key_dicts = [] + for api_key in api_keys: + api_key_dicts.append( + { + "uid": api_key.uid, + "name_s": api_key.name, + "description_t": api_key.description, + "variant_s": api_key.api_key_type, + } + ) + + display_api_keys(api_key_dicts) + + except Exception as e: + console.print(f"[red]Error listing API keys: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="list") +def list_api_keys_verbose( + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """List all API keys.""" + list_api_keys(token=token) + + +@app.command(name="create") +def create_api_key( + name: str = typer.Argument(..., help="Name of the API key"), + description: str = typer.Argument(..., help="Description of the API key"), + expiration_date: Optional[int] = typer.Option( + 0, + "--expiration-date", + help="Expiration date in seconds since epoch (0 for no expiration)", + ), + api_key_type: str = typer.Option( + ApiKeyType.USER, + "--api-key-type", + help="Type of the API key (user, admin)", + ), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """Create a new API key.""" + try: + client = DatalayerClient(token=token) + + result = client.create_api_key( + name=name, + description=description, + expiration_date=expiration_date or 0, + api_key_type=api_key_type, + ) + + if result.get("success", False): + api_key_data = result.get("api_key", result.get("token", {})) + console.print( + f"[green]API key '{name}' created successfully![/green]" + ) + console.print( + f"[yellow]API key value: {result.get('access_token', 'N/A')}[/yellow]" + ) + console.print( + "[dim]Please save this API key value securely - it won't be shown again![/dim]" + ) + + # Display the created API key info. + if api_key_data: + display_api_keys( + [ + { + "uid": api_key_data.get("uid"), + "name_s": api_key_data.get("name_s", name), + "description_t": api_key_data.get( + "description_t", description + ), + "variant_s": api_key_data.get( + "variant_s", api_key_type + ), + } + ] + ) + else: + console.print( + f"[red]Failed to create API key: {result.get('message', 'Unknown error')}[/red]" + ) + raise typer.Exit(1) + + except Exception as e: + console.print(f"[red]Error creating API key: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="delete") +def delete_api_key( + uid: str = typer.Argument(..., help="UID of the API key to delete"), + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """Delete an API key.""" + try: + client = DatalayerClient(token=token) + + success = client.delete_api_key(uid) + + if success: + console.print(f"[green]API key '{uid}' deleted successfully![/green]") + else: + console.print(f"[red]Failed to delete API key '{uid}'[/red]") + raise typer.Exit(1) + + except Exception as e: + console.print(f"[red]Error deleting API key: {e}[/red]") + raise typer.Exit(1) + + +# Root level commands for convenience +def api_keys_list( + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """List all API keys (root command).""" + list_api_keys(token=token) + + +def api_keys_ls( + token: Optional[str] = typer.Option( + None, + "--token", + help="Authentication token (Bearer token for API requests).", + ), +) -> None: + """List all API keys (root command alias).""" + list_api_keys(token=token) diff --git a/datalayer_core/cli/commands/tokens.py b/datalayer_core/cli/commands/tokens.py deleted file mode 100644 index 3d7d50f4..00000000 --- a/datalayer_core/cli/commands/tokens.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) 2023-2025 Datalayer, Inc. -# Distributed under the terms of the Modified BSD License. - -"""Token commands for Datalayer CLI.""" - -from typing import Optional - -import typer -from rich.console import Console - -from datalayer_core.client.client import DatalayerClient -from datalayer_core.displays.tokens import display_tokens -from datalayer_core.models.token import TokenType - -# Create a Typer app for token commands -app = typer.Typer( - name="tokens", help="Token management commands", invoke_without_command=True -) - -console = Console() - - -@app.callback() -def tokens_callback(ctx: typer.Context) -> None: - """Token management commands.""" - if ctx.invoked_subcommand is None: - typer.echo(ctx.get_help()) - - -@app.command(name="ls") -def list_tokens( - token: Optional[str] = typer.Option( - None, - "--token", - help="Authentication token (Bearer token for API requests).", - ), -) -> None: - """List all tokens.""" - try: - client = DatalayerClient(token=token) - tokens = client.list_tokens() - - # Convert to dict format for display_tokens - token_dicts = [] - for token in tokens: - token_dicts.append( - { - "uid": token.uid, - "name_s": token.name, - "description_t": token.description, - "variant_s": token.token_type, - } - ) - - display_tokens(token_dicts) - - except Exception as e: - console.print(f"[red]Error listing tokens: {e}[/red]") - raise typer.Exit(1) - - -@app.command(name="create") -def create_token( - name: str = typer.Argument(..., help="Name of the token"), - description: str = typer.Argument(..., help="Description of the token"), - expiration_date: Optional[int] = typer.Option( - 0, - "--expiration-date", - help="Expiration date in seconds since epoch (0 for no expiration)", - ), - token_type: str = typer.Option( - TokenType.USER, - "--token-type", - help="Type of the token (user, admin)", - ), - token: Optional[str] = typer.Option( - None, - "--token", - help="Authentication token (Bearer token for API requests).", - ), -) -> None: - """Create a new token.""" - try: - client = DatalayerClient(token=token) - - result = client.create_token( - name=name, - description=description, - expiration_date=expiration_date or 0, - token_type=token_type, - ) - - if result.get("success", False): - token_data = result.get("token", {}) - console.print(f"[green]Token '{name}' created successfully![/green]") - console.print( - f"[yellow]Token value: {result.get('access_token', 'N/A')}[/yellow]" - ) - console.print( - "[dim]Please save this token value securely - it won't be shown again![/dim]" - ) - - # Display the created token info - if token_data: - display_tokens( - [ - { - "uid": token_data.get("uid"), - "name_s": token_data.get("name_s", name), - "description_t": token_data.get( - "description_t", description - ), - "variant_s": token_data.get("variant_s", token_type), - } - ] - ) - else: - console.print( - f"[red]Failed to create token: {result.get('message', 'Unknown error')}[/red]" - ) - raise typer.Exit(1) - - except Exception as e: - console.print(f"[red]Error creating token: {e}[/red]") - raise typer.Exit(1) - - -@app.command(name="delete") -def delete_token( - uid: str = typer.Argument(..., help="UID of the token to delete"), - token: Optional[str] = typer.Option( - None, - "--token", - help="Authentication token (Bearer token for API requests).", - ), -) -> None: - """Delete a token.""" - try: - client = DatalayerClient(token=token) - - success = client.delete_token(uid) - - if success: - console.print(f"[green]Token '{uid}' deleted successfully![/green]") - else: - console.print(f"[red]Failed to delete token '{uid}'[/red]") - raise typer.Exit(1) - - except Exception as e: - console.print(f"[red]Error deleting token: {e}[/red]") - raise typer.Exit(1) - - -# Root level commands for convenience -def tokens_list( - token: Optional[str] = typer.Option( - None, - "--token", - help="Authentication token (Bearer token for API requests).", - ), -) -> None: - """List all tokens (root command).""" - list_tokens(token=token) - - -def tokens_ls( - token: Optional[str] = typer.Option( - None, - "--token", - help="Authentication token (Bearer token for API requests).", - ), -) -> None: - """List all tokens (root command alias).""" - list_tokens(token=token) diff --git a/datalayer_core/client/client.py b/datalayer_core/client/client.py index a08c7432..6fa209ab 100644 --- a/datalayer_core/client/client.py +++ b/datalayer_core/client/client.py @@ -24,14 +24,14 @@ from datalayer_core.mixins.sandbox_snapshots import SandboxSnapshotsMixin from datalayer_core.mixins.runtimes import RuntimesMixin from datalayer_core.mixins.secrets import SecretsMixin -from datalayer_core.mixins.tokens import TokensMixin +from datalayer_core.mixins.api_keys import ApiKeysMixin from datalayer_core.mixins.usage import UsageMixin from datalayer_core.mixins.whoami import WhoamiAppMixin from datalayer_core.models import UserModel +from datalayer_core.models.api_key import ApiKeyModel, ApiKeyType from datalayer_core.models.environment import EnvironmentModel from datalayer_core.models.sandbox_snapshot import SandboxSnapshotModel from datalayer_core.models.secret import SecretModel, SecretVariant -from datalayer_core.models.token import TokenModel, TokenType from datalayer_core.runtimes.runtime import RuntimeService from datalayer_core.runtimes.sandbox_snapshot import ( as_code_sandbox_snapshots, @@ -56,7 +56,7 @@ class DatalayerClient( RayMixin, SecretsMixin, SandboxSnapshotsMixin, - TokensMixin, + ApiKeysMixin, UsageMixin, WhoamiAppMixin, ): @@ -855,76 +855,77 @@ def delete_snapshot( ) return self._delete_snapshot(snapshot_uid) - def create_token( + def create_api_key( self, name: str, description: str, expiration_date: int = 0, - token_type: Union[str, TokenType] = TokenType.USER, + api_key_type: Union[str, ApiKeyType] = ApiKeyType.USER, ) -> dict[str, Any]: """ - Create a new token. + Create a new API key. Parameters ---------- name : str - Name of the token. + Name of the API key. description : str - Description of the token. + Description of the API key. expiration_date : int, default 0 - Expiration date of the token in seconds since epoch. - token_type : Union[str, TokenType], default TokenType.USER - Type of the token (e.g., "user", "admin"). + Expiration date of the API key in seconds since epoch. + api_key_type : Union[str, ApiKeyType], default ApiKeyType.USER + Type of the API key (e.g., "user", "admin"). Returns ------- dict[str, Any] - A dictionary containing the created token and its details. + A dictionary containing the created API key and its details. """ - return self._create_token( + return self._create_api_key( name=name, description=description, expiration_date=expiration_date, - token_type=token_type, + api_key_type=api_key_type, ) - def list_tokens(self) -> list[TokenModel]: + def list_api_keys(self) -> list[ApiKeyModel]: """ - List all tokens. + List all API keys. Returns ------- - list[Token] - A list of tokens associated with the user. - """ - response = self._list_tokens() - if response.get("success") and "tokens" in response: - token_objects = [] - for token_data in response["tokens"]: - token = TokenModel( - uid=token_data["uid"], - name=token_data.get("name_s", ""), - description=token_data.get("description_t", ""), - token_type=token_data.get("variant_s", "user"), + list[ApiKeyModel] + A list of API keys associated with the user. + """ + response = self._list_api_keys() + if response.get("success"): + payload = response.get("api_keys", response.get("tokens", [])) + api_key_objects = [] + for api_key_data in payload: + api_key = ApiKeyModel( + uid=api_key_data["uid"], + name=api_key_data.get("name_s", ""), + description=api_key_data.get("description_t", ""), + api_key_type=api_key_data.get("variant_s", "user"), ) - token_objects.append(token) - return token_objects + api_key_objects.append(api_key) + return api_key_objects return [] - def delete_token(self, token: Union[str, TokenModel]) -> bool: + def delete_api_key(self, api_key: Union[str, ApiKeyModel]) -> bool: """ - Delete a specific token. + Delete a specific API key. Parameters ---------- - token : Union[str, Token] - Token object or UID string to delete. + api_key : Union[str, ApiKeyModel] + API key object or UID string to delete. Returns ------- bool The result of the deletion operation. """ - token_uid = token.uid if isinstance(token, TokenModel) else token - response = self._delete_token(token_uid) + api_key_uid = api_key.uid if isinstance(api_key, ApiKeyModel) else api_key + response = self._delete_api_key(api_key_uid) return response.get("success", False) diff --git a/datalayer_core/displays/api_keys.py b/datalayer_core/displays/api_keys.py new file mode 100644 index 00000000..588cf9e0 --- /dev/null +++ b/datalayer_core/displays/api_keys.py @@ -0,0 +1,64 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""Display functions for Datalayer core.""" + +from __future__ import annotations + +from rich.console import Console +from rich.table import Table + + +def _new_api_keys_table(title: str = "API Keys") -> Table: + """ + Create a new API keys table. + + Parameters + ---------- + title : str, default "API Keys" + The title for the table. + + Returns + ------- + Table + A rich Table configured for displaying API keys. + """ + table = Table(title=title) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Variant", style="cyan", no_wrap=True) + return table + + +def _add_api_key_to_table(table: Table, api_key: dict[str, str]) -> None: + """ + Add an API key row to the table. + + Parameters + ---------- + table : Table + The rich Table to add the row to. + api_key : dict[str, str] + Dictionary containing API key information with keys: uid, name_s, description_t, variant_s. + """ + table.add_row( + api_key["uid"], + api_key["name_s"], + api_key["variant_s"], + ) + + +def display_api_keys(api_keys: list[dict[str, str]]) -> None: + """ + Display a list of API keys in the console. + + Parameters + ---------- + api_keys : list[dict[str, str]] + List of API key dictionaries to display. + """ + table = _new_api_keys_table(title="API Keys") + for api_key in api_keys: + _add_api_key_to_table(table, api_key) + console = Console() + console.print(table) diff --git a/datalayer_core/displays/tokens.py b/datalayer_core/displays/tokens.py deleted file mode 100644 index 9a72eb41..00000000 --- a/datalayer_core/displays/tokens.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 2023-2025 Datalayer, Inc. -# Distributed under the terms of the Modified BSD License. - -"""Display functions for Datalayer core.""" - -from __future__ import annotations - -from rich.console import Console -from rich.table import Table - - -def _new_tokens_table(title: str = "Tokens") -> Table: - """ - Create a new tokens table. - - Parameters - ---------- - title : str, default "tokens" - The title for the table. - - Returns - ------- - Table - A rich Table configured for displaying tokens. - """ - table = Table(title=title) - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="cyan", no_wrap=True) - table.add_column("Variant", style="cyan", no_wrap=True) - return table - - -def _add_token_to_table(table: Table, token: dict[str, str]) -> None: - """ - Add a token row to the table. - - Parameters - ---------- - table : Table - The rich Table to add the row to. - token : dict[str, str] - Dictionary containing token information with keys: uid, name_s, description_t, variant_s. - """ - table.add_row( - token["uid"], - token["name_s"], - token["variant_s"], - ) - - -def display_tokens(tokens: list[dict[str, str]]) -> None: - """ - Display a list of tokens in the console. - - Parameters - ---------- - tokens : list[dict[str, str]] - List of token dictionaries to display. - """ - table = _new_tokens_table(title="Tokens") - for token in tokens: - _add_token_to_table(table, token) - console = Console() - console.print(table) diff --git a/datalayer_core/mixins/__init__.py b/datalayer_core/mixins/__init__.py index 8370f351..8980b223 100644 --- a/datalayer_core/mixins/__init__.py +++ b/datalayer_core/mixins/__init__.py @@ -5,7 +5,7 @@ from .sandbox_snapshots import SandboxSnapshotsMixin from .runtimes import RuntimesMixin from .secrets import SecretsMixin -from .tokens import TokensMixin +from .api_keys import ApiKeysMixin from .usage import UsageMixin from .whoami import WhoamiAppMixin @@ -15,7 +15,7 @@ "SandboxSnapshotsMixin", "RuntimesMixin", "SecretsMixin", - "TokensMixin", + "ApiKeysMixin", "UsageMixin", "WhoamiAppMixin", ] diff --git a/datalayer_core/mixins/tokens.py b/datalayer_core/mixins/api_keys.py similarity index 55% rename from datalayer_core/mixins/tokens.py rename to datalayer_core/mixins/api_keys.py index e5810e03..8b848785 100644 --- a/datalayer_core/mixins/tokens.py +++ b/datalayer_core/mixins/api_keys.py @@ -3,22 +3,22 @@ from typing import Any, Union -from datalayer_core.models.token import TokenType +from datalayer_core.models.api_key import ApiKeyType from datalayer_core.utils import btoa -class TokensCreateMixin: - """Mixin for creating tokens in Datalayer.""" +class ApiKeysCreateMixin: + """Mixin for creating API keys in Datalayer.""" - def _create_token( + def _create_api_key( self, name: str, description: str, expiration_date: int = 0, - token_type: Union[str, TokenType] = TokenType.USER, + api_key_type: Union[str, ApiKeyType] = ApiKeyType.USER, ) -> dict[str, Any]: """ - Create a Token with the given parameters. + Create an API key with the given parameters. Parameters ---------- @@ -27,10 +27,10 @@ def _create_token( description : str Description of the secret. expiration_date : float - Expiration date of the token. - token_type : str, TokenType - Variant or type of the token. Defaults to "user_token". - Type of the token (e.g., "user"). + Expiration date of the API key. + api_key_type : str, ApiKeyType + Variant or type of the API key. Defaults to "user_token". + Type of the API key (e.g., "user"). Returns ------- @@ -40,14 +40,14 @@ def _create_token( body = { "name": name, "description": btoa(description), - "variant": token_type.value - if isinstance(token_type, TokenType) - else token_type, + "variant": api_key_type.value + if isinstance(api_key_type, ApiKeyType) + else api_key_type, "expiration_date": expiration_date, } try: response = self._fetch( # type: ignore - "{}/api/iam/v1/tokens".format(self.urls.iam_url), # type: ignore + "{}/api/iam/v1/api-keys".format(self.urls.iam_url), # type: ignore method="POST", json=body, ) @@ -56,17 +56,17 @@ def _create_token( return {"success": False, "message": str(e)} -class TokensDeleteMixin: - """Mixin for deleting tokens in Datalayer.""" +class ApiKeysDeleteMixin: + """Mixin for deleting API keys in Datalayer.""" - def _delete_token(self, token_uid: str) -> dict[str, Any]: + def _delete_api_key(self, api_key_uid: str) -> dict[str, Any]: """ - Delete a token by its unique identifier. + Delete an API key by its unique identifier. Parameters ---------- - token_uid : str - Unique identifier of the token to delete. + api_key_uid : str + Unique identifier of the API key to delete. Returns ------- @@ -75,7 +75,7 @@ def _delete_token(self, token_uid: str) -> dict[str, Any]: """ try: response = self._fetch( # type: ignore - "{}/api/iam/v1/tokens/{}".format(self.urls.iam_url, token_uid), # type: ignore + "{}/api/iam/v1/api-keys/{}".format(self.urls.iam_url, api_key_uid), # type: ignore method="DELETE", ) return response.json() @@ -83,21 +83,21 @@ def _delete_token(self, token_uid: str) -> dict[str, Any]: return {"success": False, "message": str(e)} -class TokensListMixin: - """Mixin class for listing tokens.""" +class ApiKeysListMixin: + """Mixin class for listing API keys.""" - def _list_tokens(self) -> dict[str, Any]: + def _list_api_keys(self) -> dict[str, Any]: """ - List all tokens in the Datalayer environment. + List all API keys in the Datalayer environment. Returns ------- dict[str, Any] - Dictionary containing tokens information. + Dictionary containing API key information. """ try: response = self._fetch( # type: ignore - "{}/api/iam/v1/tokens".format(self.urls.iam_url), # type: ignore + "{}/api/iam/v1/api-keys".format(self.urls.iam_url), # type: ignore method="GET", ) return response.json() @@ -105,5 +105,5 @@ def _list_tokens(self) -> dict[str, Any]: return {"sucess": False, "error": str(e)} -class TokensMixin(TokensCreateMixin, TokensDeleteMixin, TokensListMixin): - """A mixin that combines create, delete, and list functionalities for tokens.""" +class ApiKeysMixin(ApiKeysCreateMixin, ApiKeysDeleteMixin, ApiKeysListMixin): + """A mixin that combines create, delete, and list functionalities for API keys.""" diff --git a/datalayer_core/models/__init__.py b/datalayer_core/models/__init__.py index c128d2c2..053da4a4 100644 --- a/datalayer_core/models/__init__.py +++ b/datalayer_core/models/__init__.py @@ -21,6 +21,7 @@ HealthResponseData, ModelsResponseData, ) +from .api_key import ApiKeyModel, ApiKeyType from .base import ( BaseResponse, DataResponse, @@ -83,10 +84,11 @@ from .runtime import RuntimeModel from .sandbox_snapshot import SandboxSnapshotModel from .secret import SecretModel, SecretVariant -from .token import TokenModel, TokenType __all__ = [ "BaseResponse", + "ApiKeyModel", + "ApiKeyType", "ChatMessage", "ChatRequest", "ChatResponseData", @@ -146,9 +148,6 @@ "TeamListResponseData", "TeamMemberModel", "TeamRequest", - "TokenModel", - "TokenModel", - "TokenType", "UsageData", "User", "UserModel", diff --git a/datalayer_core/models/api_key.py b/datalayer_core/models/api_key.py new file mode 100644 index 00000000..1156f813 --- /dev/null +++ b/datalayer_core/models/api_key.py @@ -0,0 +1,39 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +""" +API key models for Datalayer. + +Provides data structures for API key management in Datalayer environments. +""" + +from enum import Enum +from typing import Any, Dict, Union + +from pydantic import BaseModel, Field + + +class ApiKeyType(str, Enum): + """Enum for API key variants.""" + + USER = "user_token" + + +class ApiKeyModel(BaseModel): + """ + Pydantic model representing an API key in Datalayer. + """ + + uid: str = Field(..., description="Unique identifier for the API key") + name: str = Field(..., description="Name of the API key") + description: str = Field(..., description="Description of the API key") + api_key_type: Union[str, ApiKeyType] = Field( + default=ApiKeyType.USER, + description='Type of the API key (e.g., "user", "admin")', + ) + kwargs: Dict[str, Any] = Field( + default_factory=dict, description="Additional keyword arguments" + ) + + def __repr__(self) -> str: + return f"ApiKeyModel(uid='{self.uid}', name='{self.name}')" diff --git a/datalayer_core/tests/test_cli.py b/datalayer_core/tests/test_cli.py index 9083a986..b9253b7f 100644 --- a/datalayer_core/tests/test_cli.py +++ b/datalayer_core/tests/test_cli.py @@ -74,8 +74,8 @@ def test_cli(args: List[str], expected_output: str) -> None: # TODO Disabled for now, we need to create a stable test account # (["snapshots", "list", "--token", TEST_DATALAYER_API_KEY], "Snapshots"), # (["snapshots", "ls", "--token", TEST_DATALAYER_API_KEY], "Snapshots"), - (["tokens", "list", "--token", TEST_DATALAYER_API_KEY], "Tokens"), - (["tokens", "ls", "--token", TEST_DATALAYER_API_KEY], "Tokens"), + (["api-keys", "list", "--token", TEST_DATALAYER_API_KEY], "API Keys"), + (["api-keys", "ls", "--token", TEST_DATALAYER_API_KEY], "API Keys"), (["whoami", "--token", TEST_DATALAYER_API_KEY], "User:"), (["logout"], "Stored token cleared"), ], diff --git a/datalayer_core/tests/test_client.py b/datalayer_core/tests/test_client.py index 532fa133..753d7d37 100644 --- a/datalayer_core/tests/test_client.py +++ b/datalayer_core/tests/test_client.py @@ -185,9 +185,9 @@ def test_profile() -> None: not bool(TEST_DATALAYER_API_KEY), reason="TEST_DATALAYER_API_KEY is not set, skipping secret tests.", ) -def test_tokens_list() -> None: +def test_api_keys_list() -> None: """ - Test the listing of tokens + Test the listing of API keys. """ client = DatalayerClient(token=TEST_DATALAYER_API_KEY) - assert client.list_tokens() + assert isinstance(client.list_api_keys(), list) diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index d6a8f7f3..7c9a7961 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -32,7 +32,7 @@ This example showcases: - Node.js 20+ - npm -- Datalayer account and API token ([Create one here](https://datalayer.app/settings/iam/tokens)) +- Datalayer account and API key ([Create one here](https://datalayer.app/settings/iam/api-keys)) ## Installation diff --git a/examples/nextjs/src/app/welcome/page.tsx b/examples/nextjs/src/app/welcome/page.tsx index 44fffd5a..ef3e94ac 100644 --- a/examples/nextjs/src/app/welcome/page.tsx +++ b/examples/nextjs/src/app/welcome/page.tsx @@ -171,21 +171,21 @@ export default function WelcomePage() { }} > - Don't have a token yet? + Don't have an API key yet? - Create a token on Datalayer + Create an API key on Datalayer - Your token is stored locally and used to authenticate with + Your API key is stored locally and used to authenticate with Datalayer's API diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index 4aced5dc..61425ecb 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -2638,7 +2638,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryKey: queryKeys.tokens.all(), queryFn: async () => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/tokens`, + url: `${configuration.iamRunUrl}/api/iam/v1/api-keys`, method: 'GET', }); if (resp.success && resp.tokens) { @@ -2670,7 +2670,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { return useMutation({ mutationFn: async (token: Omit) => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/tokens`, + url: `${configuration.iamRunUrl}/api/iam/v1/api-keys`, method: 'POST', body: { ...token, @@ -3057,7 +3057,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { queryKey: queryKeys.tokens.detail(tokenId), queryFn: async () => { const resp = await requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/tokens/${tokenId}`, + url: `${configuration.iamRunUrl}/api/iam/v1/api-keys/${tokenId}`, method: 'GET', }); if (resp.success && resp.token) { @@ -3077,7 +3077,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { return useMutation({ mutationFn: async (token: IIAMToken) => { return requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/tokens/${token.id}`, + url: `${configuration.iamRunUrl}/api/iam/v1/api-keys/${token.id}`, method: 'PUT', body: { ...token }, }); @@ -3101,7 +3101,7 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { return useMutation({ mutationFn: async (tokenId: string) => { return requestDatalayer({ - url: `${configuration.iamRunUrl}/api/iam/v1/tokens/${tokenId}`, + url: `${configuration.iamRunUrl}/api/iam/v1/api-keys/${tokenId}`, method: 'DELETE', }); }, diff --git a/src/views/iam-tokens/IAMTokenEdit.tsx b/src/views/iam-tokens/IAMTokenEdit.tsx index a7439e85..1d5858d7 100644 --- a/src/views/iam-tokens/IAMTokenEdit.tsx +++ b/src/views/iam-tokens/IAMTokenEdit.tsx @@ -34,12 +34,12 @@ interface FormData { } export type IAMTokenEditProps = { - /** Route to navigate after delete. Defaults to '/settings/iam/tokens'. */ + /** Route to navigate after delete. Defaults to '/settings/iam/api-keys'. */ tokensListRoute?: string; }; export const IAMTokenEdit = ({ - tokensListRoute = '/settings/iam/tokens', + tokensListRoute = '/settings/iam/api-keys', }: IAMTokenEditProps = {}) => { const { tokenId } = useParams(); const runStore = useRunStore(); diff --git a/src/views/iam-tokens/IAMTokenNew.tsx b/src/views/iam-tokens/IAMTokenNew.tsx index 409f9f91..5e3c6432 100644 --- a/src/views/iam-tokens/IAMTokenNew.tsx +++ b/src/views/iam-tokens/IAMTokenNew.tsx @@ -36,14 +36,14 @@ interface ValidationData { } export type IAMTokenNewProps = { - /** Route to navigate when clicking "List my Tokens". Defaults to '/settings/iam/tokens'. */ + /** Route to navigate when clicking "List my API keys". Defaults to '/settings/iam/api-keys'. */ tokensListRoute?: string; /** Whether to render the "New API Key" title header in create mode. Defaults to true. */ showTitle?: boolean; }; export const IAMTokenNew = ({ - tokensListRoute = '/settings/iam/tokens', + tokensListRoute = '/settings/iam/api-keys', showTitle = true, }: IAMTokenNewProps = {}) => { const runStore = useRunStore(); diff --git a/src/views/iam-tokens/IAMTokens.tsx b/src/views/iam-tokens/IAMTokens.tsx index 4c5f79ec..99f55e24 100644 --- a/src/views/iam-tokens/IAMTokens.tsx +++ b/src/views/iam-tokens/IAMTokens.tsx @@ -26,9 +26,9 @@ import { IIAMToken } from '../../models'; import { useCache, useNavigate, useToast } from '../../hooks'; export type IAMTokensProps = { - /** Route to navigate when clicking "New API Key" button. Defaults to '/new/token'. */ + /** Route to navigate when clicking "New API Key" button. Defaults to '/new/api-key'. */ newTokenRoute?: string; - /** Base route for the tokens list (used for edit navigation). Defaults to current relative path. */ + /** Base route for the API keys list (used for edit navigation). Defaults to current relative path. */ tokensListRoute?: string; /** Whether to display view titles/headings. Defaults to true. */ showTitle?: boolean; @@ -46,39 +46,39 @@ const TokensTable = ({ const { useTokens, useDeleteToken } = useCache(); const { enqueueToast } = useToast(); - const getTokensQuery = useTokens(); + const getApiKeysQuery = useTokens(); const deleteTokenMutation = useDeleteToken(); const navigate = useNavigate(); - const [tokens, setTokens] = useState([]); + const [apiKeys, setApiKeys] = useState([]); const [deletingToken, setDeletingToken] = useState(null); const returnFocusRef = useRef(null); useEffect(() => { - if (getTokensQuery.data) { - setTokens(getTokensQuery.data); + if (getApiKeysQuery.data) { + setApiKeys(getApiKeysQuery.data); } - }, [getTokensQuery.data]); + }, [getApiKeysQuery.data]); const handleDeleteConfirm = () => { if (!deletingToken) return; deleteTokenMutation.mutate(deletingToken.id, { onSuccess: (resp: any) => { if (resp.success) { - enqueueToast(`Token "${deletingToken.name}" deleted.`, { + enqueueToast(`API key "${deletingToken.name}" deleted.`, { variant: 'success', }); } else { - enqueueToast(resp.message || 'Failed to delete token.', { + enqueueToast(resp.message || 'Failed to delete API key.', { variant: 'error', }); } }, onError: () => { - enqueueToast('Failed to delete token.', { variant: 'error' }); + enqueueToast('Failed to delete API key.', { variant: 'error' }); }, onSettled: () => setDeletingToken(null), }); }; - return tokens.length === 0 ? ( + return apiKeys.length === 0 ? ( {showTitle && API Keys} @@ -90,23 +90,23 @@ const TokensTable = ({ {showTitle && ( <> - + API Keys - - Your tokens. + + Your API keys. )} , + renderCell: apiKey => , }, { header: 'Name', @@ -120,14 +120,14 @@ const TokensTable = ({ { header: 'Expiration date', field: 'expirationDate', - renderCell: token => ( - + renderCell: apiKey => ( + ), }, { header: '', field: 'id', - renderCell: token => ( + renderCell: apiKey => ( navigate( tokensListRoute - ? `${tokensListRoute}/${token.id}` - : `${token.id}`, + ? `${tokensListRoute}/${apiKey.id}` + : `${apiKey.id}`, e, ) } @@ -150,7 +150,7 @@ const TokensTable = ({ size="small" variant="invisible" sx={{ color: 'danger.fg' }} - onClick={() => setDeletingToken(token)} + onClick={() => setDeletingToken(apiKey)} /> ), @@ -160,7 +160,7 @@ const TokensTable = ({ {deletingToken && ( { if (gesture === 'confirm') handleDeleteConfirm(); else setDeletingToken(null); @@ -168,7 +168,7 @@ const TokensTable = ({ confirmButtonContent="Delete" confirmButtonType="danger" > - Are you sure you want to delete the token{' '} + Are you sure you want to delete the API key{' '} {deletingToken.name}? This action cannot be undone. )} @@ -177,7 +177,7 @@ const TokensTable = ({ }; export const IAMTokens = ({ - newTokenRoute = '/new/token', + newTokenRoute = '/new/api-key', tokensListRoute, showTitle = true, showNewButton = true, diff --git a/src/views/iam-tokens/Tokens.tsx b/src/views/iam-tokens/Tokens.tsx index 1663983f..a904e3f5 100644 --- a/src/views/iam-tokens/Tokens.tsx +++ b/src/views/iam-tokens/Tokens.tsx @@ -29,63 +29,63 @@ const TokensTable = () => { const { useTokens, useDeleteToken } = useCache(); const { enqueueToast } = useToast(); - const getTokensQuery = useTokens(); + const getApiKeysQuery = useTokens(); const deleteTokenMutation = useDeleteToken(); const navigate = useNavigate(); - const [tokens, setTokens] = useState([]); + const [apiKeys, setApiKeys] = useState([]); const [deletingToken, setDeletingToken] = useState(null); const returnFocusRef = useRef(null); useEffect(() => { - if (getTokensQuery.data) { - setTokens(getTokensQuery.data); + if (getApiKeysQuery.data) { + setApiKeys(getApiKeysQuery.data); } - }, [getTokensQuery.data]); + }, [getApiKeysQuery.data]); const handleDeleteConfirm = () => { if (!deletingToken) return; deleteTokenMutation.mutate(deletingToken.id, { onSuccess: (resp: any) => { if (resp.success) { - enqueueToast(`Token "${deletingToken.name}" deleted.`, { + enqueueToast(`API key "${deletingToken.name}" deleted.`, { variant: 'success', }); } else { - enqueueToast(resp.message || 'Failed to delete token.', { + enqueueToast(resp.message || 'Failed to delete API key.', { variant: 'error', }); } }, onError: () => { - enqueueToast('Failed to delete token.', { variant: 'error' }); + enqueueToast('Failed to delete API key.', { variant: 'error' }); }, onSettled: () => setDeletingToken(null), }); }; - return tokens.length === 0 ? ( + return apiKeys.length === 0 ? ( - Tokens + API Keys - No Tokens found. + No API Keys found. ) : ( <> - - Tokens + + API Keys - - Your tokens. + + Your API keys. , + renderCell: apiKey => , }, { header: 'Name', @@ -99,21 +99,21 @@ const TokensTable = () => { { header: 'Expiration date', field: 'expirationDate', - renderCell: token => ( - + renderCell: apiKey => ( + ), }, { header: '', field: 'id', - renderCell: token => ( + renderCell: apiKey => ( navigate(`${token.id}`, e)} + onClick={e => navigate(`${apiKey.id}`, e)} /> { size="small" variant="invisible" sx={{ color: 'danger.fg' }} - onClick={() => setDeletingToken(token)} + onClick={() => setDeletingToken(apiKey)} /> ), @@ -132,7 +132,7 @@ const TokensTable = () => { {deletingToken && ( { if (gesture === 'confirm') handleDeleteConfirm(); else setDeletingToken(null); @@ -140,7 +140,7 @@ const TokensTable = () => { confirmButtonContent="Delete" confirmButtonType="danger" > - Are you sure you want to delete the token{' '} + Are you sure you want to delete the API key{' '} {deletingToken.name}? This action cannot be undone. )} @@ -159,15 +159,15 @@ export const Tokens = () => { - Tokens + API Keys From 101ac1d849bfe518d86fb60c3c78232dfbdfbf9f Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 11 Jun 2026 06:58:48 +0200 Subject: [PATCH 11/43] api keys --- src/hooks/useCache.ts | 22 ++- .../APIKeyEdit.tsx} | 90 +++++------ .../APIKeyNew.tsx} | 141 ++++++++++++------ .../IAMTokens.tsx => api-keys/APIKeys.tsx} | 138 +++++++++++------ .../APIKeysStandalone.tsx} | 114 ++++++++++---- src/views/{iam-tokens => api-keys}/index.ts | 6 +- 6 files changed, 334 insertions(+), 177 deletions(-) rename src/views/{iam-tokens/IAMTokenEdit.tsx => api-keys/APIKeyEdit.tsx} (76%) rename src/views/{iam-tokens/IAMTokenNew.tsx => api-keys/APIKeyNew.tsx} (69%) rename src/views/{iam-tokens/IAMTokens.tsx => api-keys/APIKeys.tsx} (59%) rename src/views/{iam-tokens/Tokens.tsx => api-keys/APIKeysStandalone.tsx} (57%) rename src/views/{iam-tokens => api-keys}/index.ts (54%) diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index 61425ecb..460d004c 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -2641,8 +2641,11 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { url: `${configuration.iamRunUrl}/api/iam/v1/api-keys`, method: 'GET', }); - if (resp.success && resp.tokens) { - const tokens = resp.tokens + const tokenItems = asArray( + resp?.apiKeys ?? resp?.api_keys ?? resp?.tokens, + ); + if (resp.success && tokenItems.length > 0) { + const tokens = tokenItems .map((t: unknown) => { const token = toToken(t); if (token) { @@ -2678,8 +2681,9 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }, }); // Transform the token in the response - if (resp.success && resp.token) { - resp.token = toToken(resp.token); + const tokenPayload = resp?.apiKey ?? resp?.api_key ?? resp?.token; + if (resp.success && tokenPayload) { + resp.token = toToken(tokenPayload); } return resp; }, @@ -3060,8 +3064,9 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { url: `${configuration.iamRunUrl}/api/iam/v1/api-keys/${tokenId}`, method: 'GET', }); - if (resp.success && resp.token) { - return toToken(resp.token); + const tokenPayload = resp?.apiKey ?? resp?.api_key ?? resp?.token; + if (resp.success && tokenPayload) { + return toToken(tokenPayload); } return null; }, @@ -3083,8 +3088,9 @@ export const useCache = ({ loginRoute = '/login' }: CacheProps = {}) => { }); }, onSuccess: resp => { - if (resp.success && resp.token) { - const tok = toToken(resp.token); + const tokenPayload = resp?.apiKey ?? resp?.api_key ?? resp?.token; + if (resp.success && tokenPayload) { + const tok = toToken(tokenPayload); if (tok) { queryClient.setQueryData(queryKeys.tokens.detail(tok.id), tok); } diff --git a/src/views/iam-tokens/IAMTokenEdit.tsx b/src/views/api-keys/APIKeyEdit.tsx similarity index 76% rename from src/views/iam-tokens/IAMTokenEdit.tsx rename to src/views/api-keys/APIKeyEdit.tsx index 1d5858d7..df744b1f 100644 --- a/src/views/iam-tokens/IAMTokenEdit.tsx +++ b/src/views/api-keys/APIKeyEdit.tsx @@ -17,7 +17,7 @@ import { } from '@primer/react'; import { Box } from '@datalayer/primer-addons'; import { BoringAvatar } from '../../components/avatars'; -import { IIAMToken as AnyToken } from '../../models'; +import { IIAMToken as IAPIKey } from '../../models'; import { useCache, useNavigate, useToast } from '../../hooks'; import { useRunStore } from '../../state'; @@ -33,29 +33,33 @@ interface FormData { description: string; } -export type IAMTokenEditProps = { +export type APIKeyEditProps = { /** Route to navigate after delete. Defaults to '/settings/iam/api-keys'. */ - tokensListRoute?: string; + apiKeysListRoute?: string; }; -export const IAMTokenEdit = ({ - tokensListRoute = '/settings/iam/api-keys', -}: IAMTokenEditProps = {}) => { - const { tokenId } = useParams(); +export const APIKeyEdit = ({ + apiKeysListRoute = '/settings/iam/api-keys', +}: APIKeyEditProps = {}) => { + const { tokenId: apiKeyId } = useParams(); const runStore = useRunStore(); const navigate = useNavigate(); const { enqueueToast } = useToast(); - const { useUpdateToken, useToken, useDeleteToken } = useCache(); + const { + useUpdateToken: useUpdateAPIKey, + useToken: useAPIKey, + useDeleteToken: useDeleteAPIKey, + } = useCache(); - const getTokenQuery = useToken(tokenId!); - const updateTokenMutation = useUpdateToken(); - const deleteTokenMutation = useDeleteToken(); + const getAPIKeyQuery = useAPIKey(apiKeyId!); + const updateAPIKeyMutation = useUpdateAPIKey(); + const deleteAPIKeyMutation = useDeleteAPIKey(); - const [token, setToken] = useState(); + const [apiKey, setAPIKey] = useState(); const [formValues, setFormValues] = useState({ - name: token?.name!, + name: apiKey?.name!, nameConfirm: '', - description: token?.description!, + description: apiKey?.description!, }); const [validationResult, setValidationResult] = useState({ name: undefined, @@ -63,12 +67,12 @@ export const IAMTokenEdit = ({ description: undefined, }); useEffect(() => { - if (getTokenQuery.data) { - const token = getTokenQuery.data; - setToken(token); - setFormValues({ ...token, nameConfirm: '' }); + if (getAPIKeyQuery.data) { + const key = getAPIKeyQuery.data; + setAPIKey(key); + setFormValues({ ...key, nameConfirm: '' }); } - }, [getTokenQuery.data]); + }, [getAPIKeyQuery.data]); const nameNameChange = (event: React.ChangeEvent) => { setFormValues(prevFormValues => ({ ...prevFormValues, @@ -98,7 +102,7 @@ export const IAMTokenEdit = ({ : formValues.name.length > 2 ? true : false, - nameConfirm: formValues.nameConfirm === token?.name ? true : false, + nameConfirm: formValues.nameConfirm === apiKey?.name ? true : false, description: formValues.description === undefined ? undefined @@ -107,20 +111,20 @@ export const IAMTokenEdit = ({ : false, }); }, [formValues]); - const nameSubmit = async () => { + const submitAPIKey = async () => { runStore.layout().showBackdrop(); - const updatedToken = { - ...token!, + const updatedAPIKey = { + ...apiKey!, name: formValues.name, description: formValues.description, }; - updateTokenMutation.mutate(updatedToken, { + updateAPIKeyMutation.mutate(updatedAPIKey, { onSuccess: (resp: any) => { if (resp.success) { - enqueueToast('The token is successfully updated.', { + enqueueToast('The API key was updated successfully.', { variant: 'success', }); - setToken(updatedToken); + setAPIKey(updatedAPIKey); } }, onSettled: () => { @@ -128,23 +132,23 @@ export const IAMTokenEdit = ({ }, }); }; - const handleDelete = async () => { - runStore.layout().showBackdrop('Deleting the token...'); - deleteTokenMutation.mutate(token!.id, { + const deleteAPIKey = async () => { + runStore.layout().showBackdrop('Deleting the API key...'); + deleteAPIKeyMutation.mutate(apiKey!.id, { onSuccess: (resp: any) => { if (resp.success) { - enqueueToast('The token is successfully deleted.', { + enqueueToast('The API key was deleted successfully.', { variant: 'success', }); - navigate(tokensListRoute); + navigate(apiKeysListRoute); } else { - enqueueToast(resp.message || 'Failed to delete token.', { + enqueueToast(resp.message || 'Failed to delete API key.', { variant: 'error', }); } }, onError: () => { - enqueueToast('Failed to delete token.', { variant: 'error' }); + enqueueToast('Failed to delete API key.', { variant: 'error' }); }, onSettled: () => { runStore.layout().hideBackdrop(); @@ -159,15 +163,15 @@ export const IAMTokenEdit = ({ - {token?.name} + {apiKey?.name} - + @@ -203,7 +207,7 @@ export const IAMTokenEdit = ({ Expiration date @@ -212,9 +216,9 @@ export const IAMTokenEdit = ({ variant="primary" disabled={!validationResult.name || !validationResult.description} sx={{ marginTop: 3 }} - onClick={nameSubmit} + onClick={submitAPIKey} > - Update token + Update API key @@ -245,7 +249,7 @@ export const IAMTokenEdit = ({ - Confirm the token name to delete + Confirm the API key name to delete - Delete token + Delete API key @@ -273,4 +277,4 @@ export const IAMTokenEdit = ({ ); }; -export default IAMTokenEdit; +export default APIKeyEdit; diff --git a/src/views/iam-tokens/IAMTokenNew.tsx b/src/views/api-keys/APIKeyNew.tsx similarity index 69% rename from src/views/iam-tokens/IAMTokenNew.tsx rename to src/views/api-keys/APIKeyNew.tsx index 5e3c6432..a71677ec 100644 --- a/src/views/iam-tokens/IAMTokenNew.tsx +++ b/src/views/api-keys/APIKeyNew.tsx @@ -18,11 +18,14 @@ import { Box } from '@datalayer/primer-addons'; import { CopyIcon } from '@primer/octicons-react'; import { Calendar, defaultCalendarStrings } from '@fluentui/react'; import { useCache, useNavigate, useToast } from '../../hooks'; -import { IIAMToken, IIAMTokenVariant } from '../../models'; +import { + IIAMToken as IAPIKey, + IIAMTokenVariant as IAPIKeyVariant, +} from '../../models'; import { useRunStore } from '../../state'; interface FormData { - variant: IIAMTokenVariant; + variant: IAPIKeyVariant; name?: string; description?: string; expirationDate?: Date; @@ -35,26 +38,26 @@ interface ValidationData { expirationDate?: boolean; } -export type IAMTokenNewProps = { +export type APIKeyNewProps = { /** Route to navigate when clicking "List my API keys". Defaults to '/settings/iam/api-keys'. */ - tokensListRoute?: string; + apiKeysListRoute?: string; /** Whether to render the "New API Key" title header in create mode. Defaults to true. */ showTitle?: boolean; }; -export const IAMTokenNew = ({ - tokensListRoute = '/settings/iam/api-keys', +export const APIKeyNew = ({ + apiKeysListRoute = '/settings/iam/api-keys', showTitle = true, -}: IAMTokenNewProps = {}) => { +}: APIKeyNewProps = {}) => { const runStore = useRunStore(); - const { useCreateToken } = useCache(); - const createTokenMutation = useCreateToken(); + const { useCreateToken: useCreateAPIKey } = useCache(); + const createAPIKeyMutation = useCreateAPIKey(); const navigate = useNavigate(); const { enqueueToast } = useToast(); const [today, _] = useState(new Date()); - const [showToken, setShowToken] = useState(false); - const [token, setToken] = useState(); + const [showAPIKey, setShowAPIKey] = useState(false); + const [apiKey, setAPIKey] = useState(); const [formValues, setFormValues] = useState({ variant: 'user_token', name: undefined, @@ -70,7 +73,7 @@ export const IAMTokenNew = ({ const valueVariantChange = (event: React.ChangeEvent) => { setFormValues(prevFormValues => ({ ...prevFormValues, - variant: event.target.value as IIAMTokenVariant, + variant: event.target.value as IAPIKeyVariant, })); }; const valueNameChange = (event: React.ChangeEvent) => { @@ -116,9 +119,9 @@ export const IAMTokenNew = ({ : false, }); }, [formValues]); - const valueSubmit = () => { - runStore.layout().showBackdrop('Creating an token...'); - createTokenMutation.mutate( + const submitAPIKey = () => { + runStore.layout().showBackdrop('Creating an API key...'); + createAPIKeyMutation.mutate( { name: formValues.name!, variant: formValues.variant, @@ -129,8 +132,8 @@ export const IAMTokenNew = ({ onSuccess: (resp: any) => { if (resp.success && resp.token) { enqueueToast(resp.message, { variant: 'success' }); - setToken(resp.token); - setShowToken(true); + setAPIKey(resp.token); + setShowAPIKey(true); } }, onSettled: () => { @@ -141,51 +144,94 @@ export const IAMTokenNew = ({ }; return ( - {showToken ? ( + {showAPIKey ? ( <> - Your API Key is created + Your API Key Is Created - - - Take note of the API Key value, you will not be able to see it - after. + + + Important + + Save this API key now. You will not be able to see the full value + again after leaving this page. - - Name: {token?.name} - - - Description: {token?.description} - - - Expiration date: {token?.expirationDate.toISOString()} - - - Value: - + + + + Name + {apiKey?.name || '-'} + + Description + {apiKey?.description || '-'} + + Expiration date + + {apiKey?.expirationDate + ? `${apiKey.expirationDate.toLocaleString()} (${apiKey.expirationDate.toISOString()})` + : '-'} + + + + API key value + - {token?.value} + {apiKey?.value} { - if (token?.value) { - navigator.clipboard.writeText(token.value); - enqueueToast('Token copied to clipboard', { + if (apiKey?.value) { + navigator.clipboard.writeText(apiKey.value); + enqueueToast('API key copied to clipboard', { variant: 'success', }); } @@ -193,8 +239,9 @@ export const IAMTokenNew = ({ /> + - @@ -212,7 +259,7 @@ export const IAMTokenNew = ({ - Token type + Type