From a291451640b14321d8ccdb8ee2a1edec085d89fd Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 27 Jul 2026 14:38:55 +0200 Subject: [PATCH 01/43] bump --- datalayer_core/cli/__main__.py | 79 ++++++++++++++++++++++++++- datalayer_core/tests/test_cli_main.py | 50 +++++++++++++++++ package.json | 4 +- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index 5265d6e2..e6522edf 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -4,6 +4,8 @@ """Command line interface for Datalayer based on Typer.""" import os +import shutil +import subprocess import sys import typer @@ -236,6 +238,31 @@ def main_callback( "--version", } +_ROOT_COMMANDS = { + "about", + "auth", + "cluster", + "config", + "memberships", + "orgs", + "teams", + "otel", + "secrets", + "subscription", + "api-keys", + "users", + "usage", + "plans", + "web", + "login", + "logout", + "whoami", + "secrets-ls", + "api-keys-ls", + "orgs-ls", + "teams-ls", +} + def _normalize_global_options(argv: list[str]) -> list[str]: """Hoist supported global options so they work at any argument position.""" @@ -285,9 +312,59 @@ def _normalize_global_options(argv: list[str]) -> list[str]: return [argv[0], *extracted, *remaining] +def _find_root_command(args: list[str]) -> tuple[str | None, int | None]: + """Return the root command token and its index within normalized args.""" + i = 0 + while i < len(args): + token = args[i] + if token == "--": + i += 1 + break + if token in _GLOBAL_OPTIONS_NO_VALUES: + i += 1 + continue + if token in _GLOBAL_OPTIONS_WITH_VALUES: + i += 2 + continue + if any(token.startswith(f"{option}=") for option in _GLOBAL_OPTIONS_WITH_VALUES): + i += 1 + continue + if token.startswith("-"): + return None, None + return token, i + + if i < len(args): + token = args[i] + if token.startswith("-"): + return None, None + return token, i + + return None, None + + +def _try_external_command(args: list[str]) -> int | None: + """Run datalayer- when an unknown root command is invoked.""" + command, command_index = _find_root_command(args) + if command is None or command_index is None or command in _ROOT_COMMANDS: + return None + + executable = shutil.which(f"datalayer-{command}") + if executable is None: + return None + + forwarded_args = args[command_index + 1 :] + completed = subprocess.run([executable, *forwarded_args], check=False) + return completed.returncode + + def main() -> None: """Main entry point for the Datalayer Typer CLI.""" - app(args=_normalize_global_options(sys.argv)[1:]) + normalized_args = _normalize_global_options(sys.argv)[1:] + external_exit_code = _try_external_command(normalized_args) + if external_exit_code is not None: + raise SystemExit(external_exit_code) + + app(args=normalized_args) if __name__ == "__main__": diff --git a/datalayer_core/tests/test_cli_main.py b/datalayer_core/tests/test_cli_main.py index 44b48432..3cd01d4a 100644 --- a/datalayer_core/tests/test_cli_main.py +++ b/datalayer_core/tests/test_cli_main.py @@ -6,6 +6,8 @@ """Tests for CLI main argument normalization.""" +import datalayer_core.cli.__main__ as cli_main + from datalayer_core.cli.__main__ import _normalize_global_options @@ -37,3 +39,51 @@ def test_normalize_global_options_preserves_equals_syntax(): normalized = _normalize_global_options(argv) assert normalized == ["d", "--iam-url=https://iam.example", "whoami"] + + +def test_find_root_command_skips_global_options(): + args = [ + "--api-key", + "token", + "--runtimes-url=https://runtimes.example", + "growth", + "events", + ] + + command, index = cli_main._find_root_command(args) + + assert command == "growth" + assert index == 3 + + +def test_try_external_command_runs_datalayer_prefixed_binary(monkeypatch): + def fake_which(name: str) -> str | None: + assert name == "datalayer-growth" + return "/tmp/datalayer-growth" + + captured: dict[str, list[str]] = {} + + class FakeCompleted: + returncode = 0 + + def fake_run(command: list[str], check: bool): + captured["command"] = command + captured["check"] = [str(check)] + return FakeCompleted() + + monkeypatch.setattr(cli_main.shutil, "which", fake_which) + monkeypatch.setattr(cli_main.subprocess, "run", fake_run) + + exit_code = cli_main._try_external_command(["growth", "events", "ls"]) + + assert exit_code == 0 + assert captured["command"] == ["/tmp/datalayer-growth", "events", "ls"] + assert captured["check"] == ["False"] + + +def test_try_external_command_ignores_known_root_command(monkeypatch): + monkeypatch.setattr(cli_main.shutil, "which", lambda _name: "/tmp/not-used") + + exit_code = cli_main._try_external_command(["usage"]) + + assert exit_code is None diff --git a/package.json b/package.json index f349359b..dceeb7ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datalayer/core", - "version": "1.1.48", + "version": "1.1.49", "type": "module", "workspaces": [ ".", @@ -224,7 +224,7 @@ "playwright": "^1.53.2", "prettier": "^3.6.2", "react-router-dom": "^6.22.3", - "rimraf": "^6.0.1", + "rimraf": "^6.1.3", "storybook": "^9.1.1", "typedoc-plugin-markdown": "^4.0.0", "typescript": "^5.8.3", From 53c901d62f5af9b34f6fb2837a298179b56cbddb Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 30 Jul 2026 14:41:35 +0200 Subject: [PATCH 02/43] user avatar --- src/components/avatars/UserAvatar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/avatars/UserAvatar.tsx b/src/components/avatars/UserAvatar.tsx index 313ed720..f037c72b 100644 --- a/src/components/avatars/UserAvatar.tsx +++ b/src/components/avatars/UserAvatar.tsx @@ -37,6 +37,10 @@ export type UserAvatarProps = { square?: boolean; /** Fallback icon size. Defaults to ~48% of `size`. */ iconSize?: number; + /** Optional background color override for the default (non-photo) avatar. */ + fallbackBackground?: string; + /** Optional icon foreground color override for the default avatar. */ + fallbackForeground?: string; }; export const UserAvatar = ({ @@ -44,6 +48,8 @@ export const UserAvatar = ({ size = 100, square = true, iconSize, + fallbackBackground, + fallbackForeground, }: UserAvatarProps): JSX.Element => { const palette = useColorPalette(); if (hasRealAvatar(avatarUrl)) { @@ -56,11 +62,11 @@ export const UserAvatar = ({ width: size, height: size, borderRadius: square ? 2 : '50%', - bg: 'accent.subtle', + bg: fallbackBackground || 'accent.subtle', display: 'flex', alignItems: 'center', justifyContent: 'center', - '--datalayer-icon-fg': palette.primary, + '--datalayer-icon-fg': fallbackForeground || palette.primary, }} > From c9f4436af8fa285a8827d999845e5d718f3ec377 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 1 Aug 2026 11:49:28 +0200 Subject: [PATCH 03/43] runtimes --- src/api/__tests__/runtimes.integration.test.ts | 10 +++++----- src/models/__tests__/Runtime.test.ts | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/api/__tests__/runtimes.integration.test.ts b/src/api/__tests__/runtimes.integration.test.ts index 077b857d..55bf2e38 100644 --- a/src/api/__tests__/runtimes.integration.test.ts +++ b/src/api/__tests__/runtimes.integration.test.ts @@ -97,7 +97,7 @@ describe.skipIf(skipTests || skipInCi)( expect(pythonResponse.success).toBe(true); expect(pythonResponse.runtime).toBeDefined(); expect(pythonResponse.runtime.pod_name).toBeDefined(); - expect(pythonResponse.runtime.environment_name).toBe( + expect(pythonResponse.runtime.environment?.name).toBe( environments.python, ); expect(pythonResponse.runtime.given_name).toBe( @@ -128,7 +128,7 @@ describe.skipIf(skipTests || skipInCi)( expect(aiResponse.success).toBe(true); expect(aiResponse.runtime).toBeDefined(); expect(aiResponse.runtime.pod_name).toBeDefined(); - expect(aiResponse.runtime.environment_name).toBe(environments.ai); + expect(aiResponse.runtime.environment?.name).toBe(environments.ai); expect(aiResponse.runtime.given_name).toBe('test-ai-runtime'); aiRuntimePodName = aiResponse.runtime.pod_name; @@ -166,11 +166,11 @@ describe.skipIf(skipTests || skipInCi)( expect(pythonRuntime).toBeDefined(); expect(pythonRuntime?.given_name).toBe('test-python-runtime'); - expect(pythonRuntime?.environment_name).toBe(environments.python); + expect(pythonRuntime?.environment?.name).toBe(environments.python); expect(aiRuntime).toBeDefined(); expect(aiRuntime?.given_name).toBe('test-ai-runtime'); - expect(aiRuntime?.environment_name).toBe(environments.ai); + expect(aiRuntime?.environment?.name).toBe(environments.ai); console.log( `Found both runtimes in list. Total runtimes: ${response.runtimes.length}`, @@ -551,7 +551,7 @@ describe.skipIf(skipTests || skipInCi)( expect(firstRuntime).toHaveProperty('pod_name'); expect(firstRuntime).toHaveProperty('uid'); - expect(firstRuntime).toHaveProperty('environment_name'); + expect(firstRuntime).toHaveProperty('environment'); expect(firstRuntime).toHaveProperty('burning_rate'); } }); diff --git a/src/models/__tests__/Runtime.test.ts b/src/models/__tests__/Runtime.test.ts index 356adef5..b428d0e6 100644 --- a/src/models/__tests__/Runtime.test.ts +++ b/src/models/__tests__/Runtime.test.ts @@ -12,10 +12,12 @@ describe('Runtime Model', () => { uid: 'runtime-123', pod_name: 'jupyter-pod-123', given_name: 'My Runtime', - environment_name: 'python-cpu', + environment: { + name: 'python-cpu', + title: '', + }, type: 'notebook', burning_rate: 10, - environment_title: '', token: '', ingress: '', started_at: '', From 45e95ce2298c0524920352a536382f1ec32d7494 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 1 Aug 2026 13:00:36 +0200 Subject: [PATCH 04/43] bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dceeb7ac..c05c5a33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datalayer/core", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "workspaces": [ ".", From 5166e6f50fed84f2666e0d1f401b5c1bab417f09 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 2 Aug 2026 09:54:56 +0200 Subject: [PATCH 05/43] tests --- .../runtimes.environments.integration.test.ts | 179 ------------------ .../runtimes.healthz.integration.test.ts | 70 ------- 2 files changed, 249 deletions(-) delete mode 100644 src/api/__tests__/runtimes.environments.integration.test.ts delete mode 100644 src/api/__tests__/runtimes.healthz.integration.test.ts diff --git a/src/api/__tests__/runtimes.environments.integration.test.ts b/src/api/__tests__/runtimes.environments.integration.test.ts deleted file mode 100644 index 621f7de6..00000000 --- a/src/api/__tests__/runtimes.environments.integration.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2023-2025 Datalayer, Inc. - * Distributed under the terms of the Modified BSD License. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { environments } from '../runtimes'; -import { - testConfig, - debugLog, - skipIfNoToken, -} from '../../__tests__/shared/test-config'; - -let DATALAYER_API_KEY: string; -let BASE_URL: string; - -// Skip all tests if no token is available -const skipTests = skipIfNoToken(); - -beforeAll(async () => { - if (skipTests) { - console.log( - 'WARNING: Skipping Runtimes Environments integration tests: No Datalayer API token configured', - ); - console.log( - ' Set DATALAYER_API_TOKEN env var or TEST_DATALAYER_API_KEY in .env.test', - ); - return; - } - - // Get token and base URL from test config - DATALAYER_API_KEY = testConfig.getToken(); - BASE_URL = testConfig.getBaseUrl('RUNTIMES'); - - debugLog('Test configuration loaded'); - debugLog('Base URL:', BASE_URL); - debugLog('Token available:', !!DATALAYER_API_KEY); -}); - -describe.skipIf(skipTests)('Runtimes Environments Integration Tests', () => { - describe('list', () => { - it('should successfully list available environments', async () => { - console.log('Testing list environments endpoint...'); - - const response = await environments.listEnvironments( - DATALAYER_API_KEY, - BASE_URL, - ); - - console.log('Environments response:', JSON.stringify(response, null, 2)); - - // Verify the response structure - expect(response).toBeDefined(); - expect(response).toHaveProperty('success'); - expect(response.success).toBe(true); - expect(response).toHaveProperty('message'); - expect(response).toHaveProperty('environments'); - expect(Array.isArray(response.environments)).toBe(true); - - // Check that we have at least some environments - console.log( - `Found ${response.environments.length} available environments`, - ); - - // If we have environments, check the structure of the first one - if (response.environments.length > 0) { - const firstEnv = response.environments[0]; - console.log('First environment:', firstEnv.title); - - // Verify environment structure - expect(firstEnv).toHaveProperty('title'); - expect(firstEnv).toHaveProperty('description'); - expect(firstEnv).toHaveProperty('dockerImage'); - expect(firstEnv).toHaveProperty('language'); - expect(firstEnv).toHaveProperty('burning_rate'); - expect(typeof firstEnv.burning_rate).toBe('number'); - } - }); - - it('should work with default URL if not specified', async () => { - console.log('Testing list environments with default URL...'); - - // Call without specifying URL to use default - const response = await environments.listEnvironments(DATALAYER_API_KEY); - - console.log( - 'Default URL environments response:', - JSON.stringify(response, null, 2), - ); - - // Should still get valid response - expect(response).toBeDefined(); - expect(response.success).toBe(true); - expect(response).toHaveProperty('environments'); - expect(Array.isArray(response.environments)).toBe(true); - }); - - it('should include environment resource information', async () => { - console.log('Testing environment resource information...'); - - const response = await environments.listEnvironments( - DATALAYER_API_KEY, - BASE_URL, - ); - - // Check if any environment has resource information - const envWithResources = response.environments.find( - env => env.resources || env.resourcesRanges, - ); - - if (envWithResources) { - console.log( - 'Found environment with resources:', - envWithResources.title, - ); - - if (envWithResources.resources) { - console.log('Resources:', envWithResources.resources); - expect(envWithResources.resources).toHaveProperty('cpu'); - expect(envWithResources.resources).toHaveProperty('memory'); - } - - if (envWithResources.resourcesRanges) { - console.log('Resource ranges:', envWithResources.resourcesRanges); - } - } else { - console.log('No environments with explicit resource information found'); - } - }); - - it('should include environment snippets if available', async () => { - console.log('Testing environment snippets...'); - - const response = await environments.listEnvironments( - DATALAYER_API_KEY, - BASE_URL, - ); - - // Check if any environment has snippets - const envWithSnippets = response.environments.find( - env => env.snippets && env.snippets.length > 0, - ); - - if (envWithSnippets) { - console.log('Found environment with snippets:', envWithSnippets.title); - console.log('Number of snippets:', envWithSnippets.snippets?.length); - - const firstSnippet = envWithSnippets.snippets![0]; - expect(firstSnippet).toHaveProperty('title'); - // Description might be optional - if (firstSnippet.description) { - expect(firstSnippet).toHaveProperty('description'); - } - expect(firstSnippet).toHaveProperty('code'); - } else { - console.log('No environments with snippets found'); - } - }); - }); - - describe('error handling', () => { - it('should handle invalid token gracefully', async () => { - console.log('Testing with invalid token...'); - - const invalidToken = 'invalid-token-123'; - - try { - await environments.listEnvironments(invalidToken, BASE_URL); - // If we get here, the API accepted the invalid token (shouldn't happen) - console.log('WARNING: API accepted invalid token'); - } catch (error: any) { - console.log('Error with invalid token:', error.message); - // We expect an error with invalid token - expect(error).toBeDefined(); - expect(error.message).toBeDefined(); - } - }); - }); -}); diff --git a/src/api/__tests__/runtimes.healthz.integration.test.ts b/src/api/__tests__/runtimes.healthz.integration.test.ts deleted file mode 100644 index e1a92654..00000000 --- a/src/api/__tests__/runtimes.healthz.integration.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2023-2025 Datalayer, Inc. - * Distributed under the terms of the Modified BSD License. - */ - -import { describe, it, expect } from 'vitest'; -import { healthz } from '../runtimes'; -import { testConfig, skipIfNoToken } from '../../__tests__/shared/test-config'; - -const skipInCi = - process.env.CI === 'true' && - process.env.DATALAYER_TEST_RUN_EXTERNAL_INTEGRATION !== 'true'; - -/** - * Integration tests for Runtimes health check API - * These tests run against the actual Datalayer Runtimes API - */ -describe('Runtimes Healthz Integration Tests', () => { - describe.skipIf(skipIfNoToken() || skipInCi)('ping endpoint', () => { - it('should successfully ping the Runtimes service', async () => { - console.log('Testing health check ping endpoint for Runtimes...'); - - const response = await healthz.ping(testConfig.getBaseUrl('RUNTIMES')); - - // Log response for debugging - console.log('Ping response:', JSON.stringify(response, null, 2)); - - // Verify response structure - expect(response).toBeDefined(); - expect(response.success).toBe(true); - expect(response.message).toBeDefined(); - - // Log success - console.log('Runtimes health check successful'); - console.log('Success:', response.success); - console.log('Message:', response.message); - if (response.status) { - console.log('Status:', response.status); - } - if (response.version) { - console.log('Version:', response.version); - } - }); - - it('should work with default URL if not specified', async () => { - console.log('Testing health check with default URL...'); - - // Use default URL (should use production) - const response = await healthz.ping(); - - expect(response).toBeDefined(); - expect(response.success).toBe(true); - expect(response.message).toBeDefined(); - - console.log('Successfully pinged Runtimes service with default URL'); - }); - - it('should fail with invalid URL', async () => { - console.log('Testing health check with invalid URL...'); - - const invalidUrl = 'https://invalid.datalayer.run'; - - await expect(healthz.ping(invalidUrl)).rejects.toThrow( - 'Health check failed', - ); - - console.log('Correctly failed with invalid URL'); - }); - }); -}); From f46d6a913e19edd0f36ed358e74c0f5a6f6d7c82 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 2 Aug 2026 11:37:45 +0200 Subject: [PATCH 06/43] user badge --- src/utils/Jwt.ts | 4 +++- src/views/profile/UserBadge.tsx | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/utils/Jwt.ts b/src/utils/Jwt.ts index fa60176c..2ff89a0e 100644 --- a/src/utils/Jwt.ts +++ b/src/utils/Jwt.ts @@ -10,6 +10,8 @@ * Never use for security-critical checks. */ +import { asDisplayName } from './Name'; + // ── Types ───────────────────────────────────────────────────────── export interface DatalayerJwtUser { @@ -81,6 +83,6 @@ export function getDatalayerDisplayName( fallback = '', ): string { if (!user) return fallback; - const full = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); + const full = asDisplayName(user.firstName ?? '', user.lastName ?? '').trim(); return full || user.handle || fallback; } diff --git a/src/views/profile/UserBadge.tsx b/src/views/profile/UserBadge.tsx index bbad4831..6a8f6b48 100644 --- a/src/views/profile/UserBadge.tsx +++ b/src/views/profile/UserBadge.tsx @@ -82,6 +82,9 @@ export const UserBadge: React.FC = ({ const user = getDatalayerJwtUser(token); const displayName = getDatalayerDisplayName(user, user?.handle ?? ''); const claims = parseJwtPayload(token); + const popoverDisplayName = claims?.user + ? getDatalayerDisplayName(claims.user, claims.user.handle ?? '') + : displayName; // Colour the trigger label based on token expiry: // - red when already expired, @@ -162,7 +165,9 @@ export const UserBadge: React.FC = ({ gap: 2, }} > - JWT Claims + + {popoverDisplayName || 'User'} + {variant === 'small' && showExpandToggle && ( = ({ fontSize: 0, }} > + First name + {claims.user.firstName} + Last name + {claims.user.lastName} + Display name + + {getDatalayerDisplayName(claims.user, claims.user.handle)} + {claims.user.email && ( <> Email From 50cfcb8def0c9e05b843e3c572f46644e833d04d Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 2 Aug 2026 17:07:25 +0200 Subject: [PATCH 07/43] user badge --- src/views/profile/UserBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/profile/UserBadge.tsx b/src/views/profile/UserBadge.tsx index 6a8f6b48..a1afde2e 100644 --- a/src/views/profile/UserBadge.tsx +++ b/src/views/profile/UserBadge.tsx @@ -156,7 +156,7 @@ export const UserBadge: React.FC = ({ sx={{ px: 3, py: 2, - bg: 'canvas.subtle', + bg: 'canvas.inset', borderBottom: '1px solid', borderColor: 'border.default', display: 'flex', From fcabe6f059032e8bd4845f5b4ae56a749af51b75 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sun, 2 Aug 2026 20:26:38 +0200 Subject: [PATCH 08/43] signin --- src/views/iam/SignInSimple.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/views/iam/SignInSimple.tsx b/src/views/iam/SignInSimple.tsx index 825667b5..fdc8b96f 100644 --- a/src/views/iam/SignInSimple.tsx +++ b/src/views/iam/SignInSimple.tsx @@ -36,6 +36,7 @@ import { EyeIcon, EyeClosedIcon, KeyIcon, + MailIcon, TelescopeIcon, } from '@primer/octicons-react'; import { @@ -789,6 +790,7 @@ export const SignInSimple: React.FC = ({ ); } @@ -105,7 +105,7 @@ export const LoginToken = (props: ILoginTokenProps): JSX.Element => { disabled={disabled || loading || !token.trim()} sx={{ mr: 2 }} > - {loading ? 'Authenticating...' : 'Login'} + {loading ? 'Authenticating...' : 'Sign In'} {/* @@ -451,7 +454,7 @@ export const Login = (props: ILoginProps): JSX.Element => { disabled={socialButtonsDisabled} style={{ margin: '10px 0' }} > - Login with GitHub + Sign In with GitHub )} {showGoogleLogin && @@ -470,11 +473,11 @@ export const Login = (props: ILoginProps): JSX.Element => { disabled={socialButtonsDisabled} style={{ margin: '10px 0' }} > - Login with Google + Sign In with Google )} {showTokenLogin && ( - { ); }; -export default Login; +export default SignIn; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts index 3fb43a28..99492f45 100644 --- a/src/components/auth/index.ts +++ b/src/components/auth/index.ts @@ -7,7 +7,7 @@ * Authentication components for Datalayer platform */ -export * from './Login'; +export * from './SignIn'; // LoginCLI is excluded from build - it's meant for CLI apps with full routing. // export * from './LoginCLI'; -export * from './LoginToken'; +export * from './SigInAPIKey'; diff --git a/src/components/iam/ExternalTokenSilentLogin.tsx b/src/components/iam/ExternalTokenSilentLogin.tsx index 42112ea5..6ef39d43 100644 --- a/src/components/iam/ExternalTokenSilentLogin.tsx +++ b/src/components/iam/ExternalTokenSilentLogin.tsx @@ -30,8 +30,8 @@ const ExternalTokenSilentLoginRoute = ( if (externalToken) { loginAndNavigate(externalToken, logout, checkIAMToken) .catch(error => { - console.debug('Failed to login with the provided token.', error); - enqueueToast('Failed to login with the provided token.', { + console.debug('Failed to sign in with the provided token.', error); + enqueueToast('Failed to sign in with the provided token.', { variant: 'error', }); }) diff --git a/src/components/index.ts b/src/components/index.ts index 2862e677..1cadcd31 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ */ export * from './auth'; +export * from './animation'; export * from './billing'; export * from './sharing'; export * from './sparklines'; diff --git a/src/views/iam/SignInSimple.tsx b/src/views/iam/SignInSimple.tsx index d93501ba..2a10bd50 100644 --- a/src/views/iam/SignInSimple.tsx +++ b/src/views/iam/SignInSimple.tsx @@ -277,7 +277,7 @@ export const SignInSimple: React.FC = ({ loginUrl: loginUrlProp, name, title = 'Datalayer OTEL', - description = 'Sign in to access the observability dashboard.', + description = 'Sign In to access the observability dashboard.', icon, leadingIcon = , github = false, @@ -290,7 +290,7 @@ export const SignInSimple: React.FC = ({ hideHero = false, calloutTitle, calloutDescription, - passwordToggleLabel = 'Sign in with a password', + passwordToggleLabel = 'Sign In with a password', signUpTitle = "Don't have an account?", signUpDescription = 'Create a free Datalayer account with your email address.', signUpLabel = 'Sign up with email', @@ -303,10 +303,8 @@ export const SignInSimple: React.FC = ({ }) => { const compactDocMode = asDoc && !hideHero; const headingText = - name ?? - (asDoc && title === 'Datalayer OTEL' ? 'Datalayer Sign In' : title); - const headingIcon = - icon ?? (asDoc ? : leadingIcon); + name ?? (asDoc && title === 'Datalayer OTEL' ? 'Datalayer Sign In' : title); + const headingIcon = icon ?? (asDoc ? : leadingIcon); const loginUrl = useMemo(() => { if (loginUrlProp) return loginUrlProp; @@ -633,7 +631,7 @@ export const SignInSimple: React.FC = ({ mx: 'auto', }} > - Sign in with GitHub + Sign In with GitHub )} {google && ( @@ -653,7 +651,7 @@ export const SignInSimple: React.FC = ({ mx: 'auto', }} > - Sign in with Google + Sign In with Google )} {linkedin && ( @@ -673,7 +671,7 @@ export const SignInSimple: React.FC = ({ mx: 'auto', }} > - Sign in with LinkedIn + Sign In with LinkedIn )} @@ -748,7 +746,7 @@ export const SignInSimple: React.FC = ({ disabled={loading || asDoc || !handle || !password} onClick={submit} > - {loading ? 'Signing in…' : 'Sign in'} + {loading ? 'Signing in…' : 'Sign In'} {showForgotPassword && ( Date: Tue, 11 Aug 2026 19:40:16 +0200 Subject: [PATCH 17/43] animated text --- src/components/animation/AnimatedText.tsx | 8 --- src/state/substates/LayoutState.ts | 87 +++++++++++++++++++++-- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/components/animation/AnimatedText.tsx b/src/components/animation/AnimatedText.tsx index f3914037..c1c75f1c 100644 --- a/src/components/animation/AnimatedText.tsx +++ b/src/components/animation/AnimatedText.tsx @@ -56,13 +56,6 @@ export function AnimatedText({ ? normalizedWords[wordIndex % normalizedWords.length] : ''; - const minWidthCh = useMemo(() => { - if (normalizedWords.length === 0) { - return 0; - } - return Math.max(...normalizedWords.map(word => word.length)); - }, [normalizedWords]); - return ( {prefix} @@ -70,7 +63,6 @@ export function AnimatedText({ aria-live="polite" style={{ display: 'inline-block', - minWidth: `${minWidthCh}ch`, transition: `opacity ${transitionMs}ms ease, transform ${transitionMs}ms ease`, opacity: visible ? 1 : 0, transform: visible ? 'translateY(0)' : 'translateY(0.25em)', diff --git a/src/state/substates/LayoutState.ts b/src/state/substates/LayoutState.ts index 02650cbf..ce2a7272 100644 --- a/src/state/substates/LayoutState.ts +++ b/src/state/substates/LayoutState.ts @@ -99,6 +99,76 @@ export type LayoutState = ILayoutState & { updateLayoutTeam: (team?: Partial) => void; }; + +/** + * Name of the cookie holding the space last selected by the user. + * + * The selection has to survive a reload, as the whole user interface is scoped + * by it; it lives in a cookie next to the principal context written by + * `usePrincipalStore`. + */ +const SPACE_CONTEXT_COOKIE = 'datalayer-space-context'; +const SPACE_CONTEXT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +/** The identity of a space, all a reload needs to restore the selection. */ +type SpaceContextCookie = { + id?: string; + handle?: string; + name?: string; +}; + +const readSpaceContextCookie = (): SpaceContextCookie | undefined => { + if (typeof document === 'undefined') { + return undefined; + } + const escaped = SPACE_CONTEXT_COOKIE.replace( + /[-[\]{}()*+?.,\\^$|#\s]/g, + '\\$&', + ); + const match = document.cookie.match( + new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`), + ); + if (!match?.[1]) { + return undefined; + } + try { + const parsed = JSON.parse(decodeURIComponent(match[1])); + if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string') { + return undefined; + } + return { + id: parsed.id, + handle: typeof parsed.handle === 'string' ? parsed.handle : undefined, + name: typeof parsed.name === 'string' ? parsed.name : undefined, + }; + } catch { + return undefined; + } +}; + +const writeSpaceContextCookie = (space?: Partial): void => { + if (typeof document === 'undefined') { + return; + } + if (!space?.id) { + document.cookie = + `${SPACE_CONTEXT_COOKIE}=;` + ' path=/; max-age=0; SameSite=Lax'; + return; + } + const value = encodeURIComponent( + JSON.stringify({ + id: space.id, + handle: space.handle, + name: space.name, + }), + ); + document.cookie = + `${SPACE_CONTEXT_COOKIE}=${value};` + + ` path=/; max-age=${SPACE_CONTEXT_COOKIE_MAX_AGE}; SameSite=Lax`; +}; + +const initialSpaceContext = readSpaceContextCookie(); + export const layoutStore = createStore((set, get) => ({ backdrop: undefined, banner: undefined, @@ -110,7 +180,8 @@ export const layoutStore = createStore((set, get) => ({ organization: undefined, rightPortal: undefined, screenCapture: undefined, - space: undefined, + // Restored from the cookie; the full space is hydrated once loaded. + space: initialSpaceContext, team: undefined, hideBackdrop: () => set((state: LayoutState) => ({ @@ -176,13 +247,15 @@ export const layoutStore = createStore((set, get) => ({ }), updateLayoutSpace: (space?: Partial) => set((state: LayoutState) => { + const next = space + ? { + ...state.space, + ...(space as IAnySpace), + } + : undefined; + writeSpaceContextCookie(next); return { - space: space - ? { - ...state.space, - ...(space as IAnySpace), - } - : undefined, + space: next, }; }), setItem: (item?: ISpaceItem) => set((state: LayoutState) => ({ item })), From f2a26342bc23d78868f28d953bcf56bfeca94078 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 12 Aug 2026 13:32:09 +0200 Subject: [PATCH 18/43] config --- src/config/Configuration.ts | 8 ++++++++ src/state/substates/CoreState.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/config/Configuration.ts b/src/config/Configuration.ts index 7e7c560d..d4d7f80d 100644 --- a/src/config/Configuration.ts +++ b/src/config/Configuration.ts @@ -60,10 +60,18 @@ export type IDatalayerCoreConfig = { * IAM API URL. */ iamUrl: string; + /** + * Manager API URL. + */ + managerUrl: string; /** * Runtimes API URL. */ runtimesUrl: string; + /** + * Scheduler API URL. + */ + schedulerUrl: string; /** * Spacer API URL. */ diff --git a/src/state/substates/CoreState.ts b/src/state/substates/CoreState.ts index 70ab8d70..732d33de 100644 --- a/src/state/substates/CoreState.ts +++ b/src/state/substates/CoreState.ts @@ -21,7 +21,9 @@ let initialConfiguration: IDatalayerCoreConfig = { loadConfigurationFromServer: true, jupyterServerless: false, iamUrl: 'https://prod1.datalayer.run', + managerUrl: 'https://prod1.datalayer.run', runtimesUrl: 'https://r1.datalayer.run', + schedulerUrl: 'https://prod1.datalayer.run', libraryUrl: 'https://prod1.datalayer.run', spacerUrl: 'https://prod1.datalayer.run', aiAgentsUrl: 'https://prod1.datalayer.run', From f45d6407076b5a6cc507563057bfdac6b76533c3 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 12 Aug 2026 15:51:44 +0200 Subject: [PATCH 19/43] datalayer run --- datalayer_core/authn/authn.py | 9 +- datalayer_core/authn/server/__main__.py | 8 +- datalayer_core/authn/server/http_server.py | 27 +++-- datalayer_core/authn/storage.py | 2 +- datalayer_core/base/serverapplication.py | 111 +++++++++++++++++++-- datalayer_core/cli/__main__.py | 7 -- datalayer_core/cli/commands/authn.py | 22 ++-- datalayer_core/cli/commands/otel.py | 2 +- datalayer_core/cli/commands/web.py | 20 ++-- datalayer_core/displays/me.py | 2 +- datalayer_core/handlers/config/handler.py | 3 +- datalayer_core/mixins/authn.py | 8 +- datalayer_core/otel/emitter.py | 2 +- datalayer_core/otel/logfire.py | 8 +- datalayer_core/templates/index.html | 9 +- datalayer_core/tests/test_usage.py | 2 - datalayer_core/utils/urls.py | 43 +++----- datalayer_core/web/webapp.py | 2 +- examples/otel/app/generator.py | 10 +- examples/otel/app/main.py | 2 +- examples/otel/ui/vite.config.ts | 4 +- index.html | 6 +- package.json | 2 +- src/client/auth/AuthenticationManager.ts | 10 +- src/components/display/JupyterDialog.tsx | 46 +++++++-- src/config/Configuration.ts | 25 ++--- src/state/substates/CoreState.ts | 1 - vite.examples.config.ts | 8 +- 28 files changed, 253 insertions(+), 148 deletions(-) diff --git a/datalayer_core/authn/authn.py b/datalayer_core/authn/authn.py index 41760e59..51d94170 100644 --- a/datalayer_core/authn/authn.py +++ b/datalayer_core/authn/authn.py @@ -40,13 +40,14 @@ def __init__(self, iam_url: str, storage: Optional[TokenStorage] = None): """ self.iam_url = iam_url - # Extract datalayer_url from iam_url (remove /api/iam/v1 suffix if present) - datalayer_url = iam_url.replace("/api/iam/v1", "") + # The service URL the credentials are stored under: the IAM one, + # without the path of its API. + service_url = iam_url.replace("/api/iam/v1", "") - # CRITICAL: Pass datalayer_url as service_name to KeyringStorage for backwards compatibility + # CRITICAL: Pass the service URL as service_name to KeyringStorage for backwards compatibility self.storage: TokenStorage if storage is None: - keyring_storage = KeyringStorage(service_name=datalayer_url) + keyring_storage = KeyringStorage(service_name=service_url) if keyring_storage.is_available(): self.storage = keyring_storage else: diff --git a/datalayer_core/authn/server/__main__.py b/datalayer_core/authn/server/__main__.py index cea0f606..5139ce1d 100644 --- a/datalayer_core/authn/server/__main__.py +++ b/datalayer_core/authn/server/__main__.py @@ -9,23 +9,23 @@ from typing import Optional from datalayer_core.authn.server.http_server import get_token -from datalayer_core.utils.urls import DEFAULT_DATALAYER_URL +from datalayer_core.utils.urls import DEFAULT_DATALAYER_IAM_URL logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -DATALAYER_URL = DEFAULT_DATALAYER_URL +IAM_URL = DEFAULT_DATALAYER_IAM_URL if __name__ == "__main__": from sys import argv if len(argv) == 2: - ans = get_token(DATALAYER_URL, port=int(argv[1])) + ans = get_token(IAM_URL, port=int(argv[1])) else: - ans = get_token(DATALAYER_URL) + ans = get_token(IAM_URL) handle: Optional[str] = None token: Optional[str] = None diff --git a/datalayer_core/authn/server/http_server.py b/datalayer_core/authn/server/http_server.py index f6b41b85..a779e046 100644 --- a/datalayer_core/authn/server/http_server.py +++ b/datalayer_core/authn/server/http_server.py @@ -152,7 +152,7 @@ def do_GET(self) -> None: elif path in {"/", "/datalayer/login/cli"}: config_json = json.dumps( { - "datalayerUrl": self.server.datalayer_url, # type: ignore + "iamUrl": self.server.iam_url, # type: ignore "iamUrl": self.server.iam_url, # type: ignore "whiteLabel": False, } @@ -229,8 +229,8 @@ class AuthHTTPServer(HTTPServer): The server address and port. RequestHandlerClass : Callable The request handler class. - datalayer_url : str - The runtime URL. + iam_url : str + The URL of the IAM service, which the login goes through. bind_and_activate : bool, default True Whether to bind and activate the server. """ @@ -239,7 +239,7 @@ def __init__( self, server_address: tuple[Union[str, bytes, bytearray], int], RequestHandlerClass: t.Callable[[t.Any, t.Any, t.Self], BaseRequestHandler], - datalayer_url: str, + iam_url: str, bind_and_activate: bool = True, ) -> None: """ @@ -251,14 +251,13 @@ def __init__( The server address and port. RequestHandlerClass : Callable The request handler class. - datalayer_url : str - The runtime URL. + iam_url : str + The URL of the IAM service, which the login goes through. bind_and_activate : bool, default True Whether to bind and activate the server. """ # Use DatalayerURLs for proper URL configuration - self._urls = DatalayerURLs.from_environment(datalayer_url=datalayer_url) - self.datalayer_url = self._urls.datalayer_url + self._urls = DatalayerURLs.from_environment(iam_url=iam_url) self.iam_url = self._urls.iam_url self.user_handle = None self.token = None @@ -299,15 +298,15 @@ def finish_request(self, request: t.Any, client_address: str) -> None: def get_token( - datalayer_url: str, port: Optional[int] = None, logger: logging.Logger = logger + iam_url: str, port: Optional[int] = None, logger: logging.Logger = logger ) -> Optional[tuple[str, str]]: """ Get the user handle and token. Parameters ---------- - datalayer_url : str - The runtime URL. + iam_url : str + The URL of the IAM service, which the login goes through. port : int or None, default None The port to use for the authentication server. logger : logging.Logger, default logger @@ -329,8 +328,8 @@ def get_token( ) sys.argv = [ "", - "--DatalayerExtensionApp.datalayer_url", - datalayer_url, + "--DatalayerExtensionApp.iam_url", + iam_url, "--ServerApp.disable_check_xsrf", "True", ] @@ -339,7 +338,7 @@ def get_token( # return None if httpd.token is None else (httpd.user_handle, httpd.token) return None else: - httpd = AuthHTTPServer(server_address, LoginRequestHandler, datalayer_url) + httpd = AuthHTTPServer(server_address, LoginRequestHandler, iam_url) logger.info( f"Waiting for user logging, open http://localhost:{port}. Press CTRL+C to abort.\n" ) diff --git a/datalayer_core/authn/storage.py b/datalayer_core/authn/storage.py index b9fc0060..ef5e6520 100644 --- a/datalayer_core/authn/storage.py +++ b/datalayer_core/authn/storage.py @@ -60,7 +60,7 @@ def __init__(self, service_name: str = DEFAULT_DATALAYER_IAM_URL): """Initialize keyring storage. Args: - service_name: Service name for keyring entries (MUST be datalayer_url for backwards compatibility) + service_name: Service name for keyring entries (MUST be the IAM URL for backwards compatibility) """ self.service_name = service_name self._keyring: Any = None diff --git a/datalayer_core/base/serverapplication.py b/datalayer_core/base/serverapplication.py index 437d78f9..bbb0fb24 100644 --- a/datalayer_core/base/serverapplication.py +++ b/datalayer_core/base/serverapplication.py @@ -16,7 +16,7 @@ from datalayer_core.handlers.index.handler import IndexHandler from datalayer_core.handlers.login.handler import LoginHandler from datalayer_core.handlers.service_worker.handler import ServiceWorkerHandler -from datalayer_core.utils.urls import DEFAULT_DATALAYER_IAM_URL +from datalayer_core.utils.urls import DatalayerURLs _PACKAGE_ROOT = Path(__file__).resolve().parent.parent DEFAULT_STATIC_FILES_PATH = str(_PACKAGE_ROOT / "static") @@ -36,15 +36,110 @@ class DatalayerExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): template_paths = [DEFAULT_TEMPLATE_FILES_PATH] - # datalayer_url can be set set and None or ' ' (empty string). - # In that case, the consumer of those settings are free to consider datalayer_url as null. - datalayer_url = Unicode( - DEFAULT_DATALAYER_IAM_URL, + # One URL per service: there is no single base any more. Each of them can + # be set and None or ' ' (empty string); the consumer of those settings is + # then free to consider it as null. What is not configured is resolved from + # the environment — `DATALAYER_IAM_URL` and friends — and falls back to the + # default of the service, see `DatalayerURLs`. + iam_url = Unicode( config=True, allow_none=True, - help="""URL to connect to the Datalayer RUN APIs.""", + help="""URL to connect to the Datalayer IAM API.""", ) + runtimes_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer Runtimes API.""", + ) + + spacer_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer Spacer API.""", + ) + + library_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer Library API.""", + ) + + manager_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer Manager API.""", + ) + + scheduler_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer Scheduler API.""", + ) + + ai_agents_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer AI Agents API.""", + ) + + ai_inference_url = Unicode( + config=True, + allow_none=True, + help="""URL to connect to the Datalayer AI Inference API.""", + ) + + @default("iam_url") + def _default_iam_url(self) -> str: + return self._urls.iam_url + + @default("runtimes_url") + def _default_runtimes_url(self) -> str: + return self._urls.runtimes_url + + @default("spacer_url") + def _default_spacer_url(self) -> str: + return self._urls.spacer_url + + @default("library_url") + def _default_library_url(self) -> str: + return self._urls.library_url + + @default("manager_url") + def _default_manager_url(self) -> str: + return self._urls.manager_url + + @default("scheduler_url") + def _default_scheduler_url(self) -> str: + return self._urls.scheduler_url + + @default("ai_agents_url") + def _default_ai_agents_url(self) -> str: + return self._urls.ai_agents_url + + @default("ai_inference_url") + def _default_ai_inference_url(self) -> str: + return self._urls.ai_inference_url + + @property + def _urls(self) -> DatalayerURLs: + """The URLs of the services, as the environment resolves them.""" + return DatalayerURLs.from_environment() + + @property + def service_urls(self) -> dict: + """The URL of every service, as the browser and the templates read them.""" + return { + "iam_url": self.iam_url, + "runtimes_url": self.runtimes_url, + "spacer_url": self.spacer_url, + "library_url": self.library_url, + "manager_url": self.manager_url, + "scheduler_url": self.scheduler_url, + "ai_agents_url": self.ai_agents_url, + "ai_inference_url": self.ai_inference_url, + } + white_label = Bool(False, config=True, help="""Display white label content.""") benchmarks = Bool(False, config=True, help="""Show the benchmarks page.""") @@ -189,7 +284,7 @@ def initialize_settings(self) -> None: self.serverapp.port = port settings = dict( - datalayer_url=self.datalayer_url, + **self.service_urls, launcher={ "category": self.launcher.category, "name": self.launcher.name, @@ -215,7 +310,7 @@ def initialize_templates(self) -> None: self.serverapp.jinja_template_vars.update( { "datalayer_version": __version__, - "datalayer_url": self.datalayer_url, + **self.service_urls, } ) diff --git a/datalayer_core/cli/__main__.py b/datalayer_core/cli/__main__.py index e6522edf..ebdf6b3d 100644 --- a/datalayer_core/cli/__main__.py +++ b/datalayer_core/cli/__main__.py @@ -75,11 +75,6 @@ def main_callback( "omitted; otherwise built-in auth resolution is used." ), ), - datalayer_url: str | None = typer.Option( - None, - "--datalayer-url", - help="Override DATALAYER_URL for this CLI invocation.", - ), iam_url: str | None = typer.Option( None, "--iam-url", @@ -154,7 +149,6 @@ def main_callback( ) -> None: """Main callback to handle global options.""" overrides = { - "DATALAYER_URL": datalayer_url, "DATALAYER_IAM_URL": iam_url, "DATALAYER_RUNTIMES_URL": runtimes_url, "DATALAYER_SPACER_URL": spacer_url, @@ -216,7 +210,6 @@ def main_callback( _GLOBAL_OPTIONS_WITH_VALUES = { "--api-key", - "--datalayer-url", "--iam-url", "--runtimes-url", "--spacer-url", diff --git a/datalayer_core/cli/commands/authn.py b/datalayer_core/cli/commands/authn.py index b803d9da..4e4cf290 100644 --- a/datalayer_core/cli/commands/authn.py +++ b/datalayer_core/cli/commands/authn.py @@ -183,12 +183,12 @@ def login( if access_token: # Token-based authentication console.print("🔑 Authenticating with provided token...") - asyncio.run(_login_with_token(auth, access_token, urls.datalayer_url)) + asyncio.run(_login_with_token(auth, access_token, urls.iam_url)) elif handle and password: # Credentials-based authentication console.print(f"👤 Authenticating as {handle}...") - asyncio.run(_login_with_credentials(auth, handle, password, urls.datalayer_url)) + asyncio.run(_login_with_credentials(auth, handle, password, urls.iam_url)) else: # Try stored token first @@ -196,7 +196,7 @@ def login( if stored_token: console.print("🔑 Found stored token, validating...") try: - asyncio.run(_login_with_token(auth, stored_token, urls.datalayer_url)) + asyncio.run(_login_with_token(auth, stored_token, urls.iam_url)) return except Exception: console.print( @@ -213,7 +213,7 @@ def login( if credentials.get("credentials_type") == "api_key": asyncio.run( _login_with_api_key( - auth, credentials["api_key"], urls.datalayer_url + auth, credentials["api_key"], urls.iam_url ) ) else: @@ -222,7 +222,7 @@ def login( auth, credentials["handle"], credentials["password"], - urls.datalayer_url, + urls.iam_url, ) ) else: @@ -230,7 +230,7 @@ def login( console.print( "[yellow]No API key found. Starting browser-based authentication...[/yellow]" ) - _authenticate_with_browser(auth, urls.datalayer_url) + _authenticate_with_browser(auth, urls.iam_url) except typer.Exit: raise @@ -408,7 +408,7 @@ def logout( asyncio.run(auth.logout()) - console.print(f"👋 Logged out from [green]{urls.datalayer_url}[/green]") + console.print(f"👋 Logged out from [green]{urls.iam_url}[/green]") console.print("🧹 Stored API key cleared") except Exception as e: @@ -440,8 +440,7 @@ def whoami( if urls_only: url_items = [ - ("DATALAYER_URL", urls.datalayer_url), - ("DATALAYER_IAM_URL", urls.iam_url), + ("DATALAYER_IAM_URL", urls.iam_url), ("DATALAYER_RUNTIMES_URL", urls.runtimes_url), ("DATALAYER_SPACER_URL", urls.spacer_url), ("DATALAYER_LIBRARY_URL", urls.library_url), @@ -477,14 +476,13 @@ def whoami( console.print(f"👤 User: [cyan]{handle}[/cyan]") if email: console.print(f"📧 Email: {email}") - console.print(f"🌐 Datalayer URL: [green]{urls.datalayer_url}[/green]") + console.print(f"🌐 Datalayer IAM URL: [green]{urls.iam_url}[/green]") if details: console.print("\n[bold]Detailed Information:[/bold]") url_items = [ - ("DATALAYER_URL", urls.datalayer_url), - ("DATALAYER_IAM_URL", urls.iam_url), + ("DATALAYER_IAM_URL", urls.iam_url), ("DATALAYER_RUNTIMES_URL", urls.runtimes_url), ("DATALAYER_SPACER_URL", urls.spacer_url), ("DATALAYER_LIBRARY_URL", urls.library_url), diff --git a/datalayer_core/cli/commands/otel.py b/datalayer_core/cli/commands/otel.py index a8d165ef..1299b286 100644 --- a/datalayer_core/cli/commands/otel.py +++ b/datalayer_core/cli/commands/otel.py @@ -76,7 +76,7 @@ def _otel_base_url(url: str | None) -> str: return ( url or os.environ.get("DATALAYER_OTEL_RUN_URL") - or os.environ.get("DATALAYER_URL", "https://prod1.datalayer.run") + or os.environ.get("DATALAYER_OTEL_URL", "https://prod1.datalayer.run") ) diff --git a/datalayer_core/cli/commands/web.py b/datalayer_core/cli/commands/web.py index db8ea62b..13c824c2 100644 --- a/datalayer_core/cli/commands/web.py +++ b/datalayer_core/cli/commands/web.py @@ -29,10 +29,10 @@ def web_callback(ctx: typer.Context) -> None: @app.command(name="start") def web_start( - datalayer_url: Optional[str] = typer.Option( + iam_url: Optional[str] = typer.Option( None, - "--datalayer-url", - help="Datalayer URL", + "--iam-url", + help="Datalayer IAM URL", ), disable_xsrf: bool = typer.Option( True, @@ -43,18 +43,18 @@ def web_start( """Launch the Datalayer web application.""" try: # Get URLs configuration - urls = DatalayerURLs.from_environment(datalayer_url=datalayer_url) + urls = DatalayerURLs.from_environment(iam_url=iam_url) # Prepare arguments for Jupyter server sys.argv = [ "", f"--ServerApp.disable_check_xsrf={disable_xsrf}", "--DatalayerExtensionApp.webapp=True", - f"--DatalayerExtensionApp.datalayer_url={urls.datalayer_url}", + f"--DatalayerExtensionApp.iam_url={urls.iam_url}", ] console.print("[green]Starting Datalayer web application...[/green]") - console.print(f"Datalayer URL: {urls.datalayer_url}") + console.print(f"Datalayer IAM URL: {urls.iam_url}") console.print("[yellow]Press Ctrl+C to stop the server[/yellow]") # Launch the Jupyter server @@ -71,10 +71,10 @@ def web_start( @app.callback(invoke_without_command=True) def web_callback_default( ctx: typer.Context, - datalayer_url: Optional[str] = typer.Option( + iam_url: Optional[str] = typer.Option( None, - "--datalayer-url", - help="Datalayer Datalayer URL", + "--iam-url", + help="Datalayer IAM URL", ), disable_xsrf: bool = typer.Option( True, @@ -85,7 +85,7 @@ def web_callback_default( """Launch the Datalayer web application (default behavior).""" if ctx.invoked_subcommand is None: # Call web_start with the same parameters - web_start(datalayer_url=datalayer_url, disable_xsrf=disable_xsrf) + web_start(iam_url=iam_url, disable_xsrf=disable_xsrf) if __name__ == "__main__": diff --git a/datalayer_core/displays/me.py b/datalayer_core/displays/me.py index 76566fd1..27376b39 100644 --- a/datalayer_core/displays/me.py +++ b/datalayer_core/displays/me.py @@ -31,7 +31,7 @@ def display_me(me: dict[str, str], infos: dict[str, str]) -> None: me["handle_s"], me["first_name_t"], me["last_name_t"], - infos.get("datalayer_url"), + infos.get("iam_url"), ) console = Console() console.print(table) diff --git a/datalayer_core/handlers/config/handler.py b/datalayer_core/handlers/config/handler.py index 8c29ab8c..0a923a22 100644 --- a/datalayer_core/handlers/config/handler.py +++ b/datalayer_core/handlers/config/handler.py @@ -22,7 +22,8 @@ def get(self) -> None: """Return the configuration of the server extension.""" settings = self.settings["datalayer"] configuration = dict( - datalayer_url=settings.datalayer_url, + # One URL per service; there is no single base any more. + **settings.service_urls, launcher={ "category": settings.launcher.category, "name": settings.launcher.name, diff --git a/datalayer_core/mixins/authn.py b/datalayer_core/mixins/authn.py index 89f970e6..5356e80c 100644 --- a/datalayer_core/mixins/authn.py +++ b/datalayer_core/mixins/authn.py @@ -18,7 +18,7 @@ class AuthnMixin: Provide authentication methods for Datalayer client. This mixin expects the implementing class to provide: - - urls property: DatalayerURLs instance with datalayer_url and iam_url + - urls property: DatalayerURLs instance with iam_url and the other service URLs """ @property @@ -71,9 +71,9 @@ def _get_api_key(self) -> Optional[str]: try: import keyring - stored_api_key = keyring.get_password( - self.urls.datalayer_url, "access_token" - ) + # The credentials are stored under the IAM URL, which is what + # issued them. + stored_api_key = keyring.get_password(self.urls.iam_url, "access_token") if stored_api_key: self._api_key = stored_api_key return self._api_key diff --git a/datalayer_core/otel/emitter.py b/datalayer_core/otel/emitter.py index 8e5ff67a..05487a70 100644 --- a/datalayer_core/otel/emitter.py +++ b/datalayer_core/otel/emitter.py @@ -85,7 +85,7 @@ def __init__( otlp_base = ( ( os.environ.get("DATALAYER_OTEL_URL") - or os.environ.get("DATALAYER_URL") + or os.environ.get("DATALAYER_OTEL_URL") or "https://prod1.datalayer.run" ).rstrip("/") + "/api/otel/v1/otlp" diff --git a/datalayer_core/otel/logfire.py b/datalayer_core/otel/logfire.py index 93714eb9..bee4215a 100644 --- a/datalayer_core/otel/logfire.py +++ b/datalayer_core/otel/logfire.py @@ -22,7 +22,7 @@ 1. ``DATALAYER_OTLP_URL`` — explicit full base URL 2. ``DATALAYER_OTEL_RUN_URL`` — run URL, appends ``/api/otel/v1/otlp`` -3. ``DATALAYER_URL`` — fallback run URL, appends ``/api/otel/v1/otlp`` +3. ``DATALAYER_OTEL_URL`` — the OTEL service, appends ``/api/otel/v1/otlp`` 4. ``https://prod1.datalayer.run`` — production default Authentication reads ``DATALAYER_API_KEY`` as a Bearer token. The JWT payload is @@ -62,12 +62,12 @@ def otlp_endpoint() -> str: explicit = os.environ.get("DATALAYER_OTLP_URL") if explicit: return explicit.rstrip("/") - datalayer_url = ( + otel_url = ( os.environ.get("DATALAYER_OTEL_RUN_URL") - or os.environ.get("DATALAYER_URL") + or os.environ.get("DATALAYER_OTEL_URL") or "https://prod1.datalayer.run" ) - return datalayer_url.rstrip("/") + "/api/otel/v1/otlp" + return otel_url.rstrip("/") + "/api/otel/v1/otlp" def decode_user_uid(token: str) -> str | None: diff --git a/datalayer_core/templates/index.html b/datalayer_core/templates/index.html index 607c1c73..3c550079 100644 --- a/datalayer_core/templates/index.html +++ b/datalayer_core/templates/index.html @@ -11,9 +11,14 @@