feat: unify workspace settings and runtime resources
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
"workspaceRoute(workspaceId, '/settings/runtimes')",
|
||||
) &&
|
||||
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
||||
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
|
||||
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
|
||||
);
|
||||
assert(
|
||||
!workspacePage.includes("workerConsoleHref") &&
|
||||
@@ -599,21 +599,24 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
||||
|
||||
assert(
|
||||
!sidebar.includes("RuntimesNavSection") &&
|
||||
settingsModel.includes('id: "runtime-inventory"') &&
|
||||
settingsModel.includes('id: "runtimes"') &&
|
||||
settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"),
|
||||
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||
);
|
||||
assert(
|
||||
runtimesPage.includes("Runtime Inventory") &&
|
||||
runtimesPage.includes("Add remote Runtime") &&
|
||||
runtimesPage.includes("Open workdirs") &&
|
||||
runtimesPage.includes("runtimes-table") &&
|
||||
runtimesPage.includes("settings-runtime-table") &&
|
||||
runtimesPage.includes(
|
||||
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests",
|
||||
) &&
|
||||
runtimesPage.includes(
|
||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
|
||||
),
|
||||
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
|
||||
"Settings Runtimes page should expose canonical REST actions and link to each Runtime's workdirs",
|
||||
);
|
||||
assert(
|
||||
workdirsPage.includes("Runtime Inventory") &&
|
||||
workdirsPage.includes(">Runtimes</a>") &&
|
||||
workdirsPage.includes("workdirs-table") &&
|
||||
workdirsLoad.includes("/working-directories"),
|
||||
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
|
||||
@@ -834,9 +837,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
|
||||
);
|
||||
assert(
|
||||
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
|
||||
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
|
||||
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
||||
sidebarOverride.includes("controller.registerSidebar(sidebar)") &&
|
||||
rootLayout.includes("createOverrideStack<SidebarSnippet>") &&
|
||||
rootLayout.includes("registerSidebar: sidebarOverrides.register"),
|
||||
"SidebarOverride should register a nested sidebar whose cleanup restores the parent override",
|
||||
);
|
||||
assert(
|
||||
rootLayoutLoad.includes("export const load") &&
|
||||
|
||||
@@ -71,13 +71,13 @@ Deno.test("Repository access settings are editable and canonically routed", () =
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime connections are editable without advertising raw authority leaks", () => {
|
||||
Deno.test("Runtimes are one editable REST resource without authority leaks", () => {
|
||||
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
|
||||
section.id === "runtime-connections"
|
||||
section.id === "runtimes"
|
||||
);
|
||||
assert(
|
||||
runtimeSection?.status === "editable",
|
||||
"Runtime Connections should be editable",
|
||||
"Runtimes should be editable",
|
||||
);
|
||||
|
||||
const allText = [
|
||||
@@ -91,13 +91,13 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
||||
].join("\n");
|
||||
|
||||
assert(
|
||||
allText.includes("restart_required=true") ||
|
||||
allText.includes("Restart-required"),
|
||||
"restart-required pattern should be visible",
|
||||
allText.includes("canonical") && allText.includes("REST resource"),
|
||||
"Runtime settings should describe the canonical REST resource",
|
||||
);
|
||||
assert(
|
||||
allText.includes("not echoed back") || allText.includes("not echoed"),
|
||||
"endpoint submission should not imply endpoint echoing",
|
||||
!allText.includes("restart_required") &&
|
||||
!allText.includes("Restart-required"),
|
||||
"Runtime settings should not retain obsolete restart-required semantics",
|
||||
);
|
||||
|
||||
for (
|
||||
@@ -120,12 +120,12 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
||||
Deno.test("diagnostic labels preserve severity and code", () => {
|
||||
const diagnostic = {
|
||||
severity: "warning",
|
||||
code: "runtime_registry_restart_required",
|
||||
message: "Restart required.",
|
||||
code: "configured_runtime_unavailable",
|
||||
message: "Configured Runtime unavailable.",
|
||||
} as const;
|
||||
assert(
|
||||
diagnosticLabel(diagnostic) ===
|
||||
"warning: runtime_registry_restart_required",
|
||||
"warning: configured_runtime_unavailable",
|
||||
"diagnostic label should be bounded and stable",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,18 +5,16 @@ export type Diagnostic = {
|
||||
};
|
||||
|
||||
export type SettingsSectionId =
|
||||
| "runtime-connections"
|
||||
| "runtime-inventory"
|
||||
| "runtimes"
|
||||
| "configuration-sources"
|
||||
| "repository-access"
|
||||
| "profile-sources"
|
||||
| "backend-config"
|
||||
| "workspace-identity";
|
||||
|
||||
export type SettingsSection = {
|
||||
readonly id: SettingsSectionId;
|
||||
readonly label: string;
|
||||
readonly status: "editable" | "placeholder" | "read-only";
|
||||
readonly status: "editable" | "read-only";
|
||||
readonly summary: string;
|
||||
readonly bullets: readonly string[];
|
||||
};
|
||||
@@ -26,50 +24,6 @@ export type SettingsPattern = {
|
||||
readonly body: string;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionSummary = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
active: boolean;
|
||||
worker_creation_available: boolean;
|
||||
restart_required: boolean;
|
||||
status: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RemoteRuntimeConnectionSummary = RuntimeConnectionSummary & {
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionSettingsResponse = {
|
||||
workspace_id: string;
|
||||
embedded: RuntimeConnectionSummary;
|
||||
remotes: RemoteRuntimeConnectionSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeConnectionMutationResponse = {
|
||||
workspace_id: string;
|
||||
restart_required: boolean;
|
||||
remotes: RemoteRuntimeConnectionSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RemoteRuntimeTestResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
state: string;
|
||||
protocol_version?: string | null;
|
||||
compatibility_basis: string;
|
||||
capabilities: string[];
|
||||
health_result: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export const SETTINGS_ROUTE = "/settings";
|
||||
|
||||
export const SETTINGS_PERMISSION_NOTICE =
|
||||
@@ -77,27 +31,15 @@ export const SETTINGS_PERMISSION_NOTICE =
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{
|
||||
id: "runtime-connections",
|
||||
label: "Runtime Connections",
|
||||
id: "runtimes",
|
||||
label: "Runtimes",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.",
|
||||
"Register and inspect Workspace Runtimes, verify connectivity, and open their Workdir inventory from one resource list.",
|
||||
bullets: [
|
||||
"Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
|
||||
"The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
|
||||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "runtime-inventory",
|
||||
label: "Runtime Inventory",
|
||||
status: "read-only",
|
||||
summary:
|
||||
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
|
||||
bullets: [
|
||||
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
|
||||
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
|
||||
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
||||
"Embedded and remote Runtimes share one canonical Workspace resource representation.",
|
||||
"Remote Runtime creation, connection tests, and guarded deletion use the same REST collection.",
|
||||
"Runtime status, worker creation availability, diagnostics, and Workdir inventory remain visible without exposing endpoints or credentials.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -136,18 +78,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "backend-config",
|
||||
label: "Backend Config",
|
||||
status: "placeholder",
|
||||
summary:
|
||||
"General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.",
|
||||
bullets: [
|
||||
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.",
|
||||
"Missing-provider or invalid-config states should be displayed as typed diagnostics.",
|
||||
"No fake permission model is created to make unrelated config editing appear available.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workspace-identity",
|
||||
label: "Workspace Identity",
|
||||
@@ -169,22 +99,20 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
|
||||
"Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, or Runtime store paths.",
|
||||
},
|
||||
{
|
||||
title: "Restart-required changes",
|
||||
title: "Live Runtime resources",
|
||||
body:
|
||||
"Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.",
|
||||
"Remote Runtime create/delete operations update persisted configuration and the live Runtime registry through one canonical REST resource.",
|
||||
},
|
||||
{
|
||||
title: "Typed Runtime surface only",
|
||||
body:
|
||||
"Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.",
|
||||
"The Runtime REST resource exposes embedded and remote inventory, guarded create/delete/test operations, and Workdir links without broader Backend admin controls.",
|
||||
},
|
||||
];
|
||||
|
||||
export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
switch (id) {
|
||||
case "runtime-connections":
|
||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
||||
case "runtime-inventory":
|
||||
case "runtimes":
|
||||
return `${SETTINGS_ROUTE}/runtimes`;
|
||||
case "configuration-sources":
|
||||
return `${SETTINGS_ROUTE}/configuration`;
|
||||
@@ -194,8 +122,6 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
return `${SETTINGS_ROUTE}/profiles`;
|
||||
case "workspace-identity":
|
||||
return `${SETTINGS_ROUTE}/workspace`;
|
||||
case "backend-config":
|
||||
return `${SETTINGS_ROUTE}/backend`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
|
||||
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
|
||||
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Settings sections">
|
||||
<div class="nav-section">
|
||||
<div class="section-heading">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="sidebar-list">
|
||||
<a
|
||||
class:active={currentPath === settingsHref}
|
||||
class="sidebar-link"
|
||||
href={settingsHref}
|
||||
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">Overview</span>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a
|
||||
class:active={isActive(href)}
|
||||
class="sidebar-link"
|
||||
href={href}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">{section.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -8,8 +8,5 @@
|
||||
const { sidebar }: Props = $props();
|
||||
const controller = getSidebarController();
|
||||
|
||||
$effect(() => {
|
||||
controller.setSidebar(sidebar);
|
||||
return () => controller.clearSidebar(sidebar);
|
||||
});
|
||||
$effect(() => controller.registerSidebar(sidebar));
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import './sidebar.css';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||
@@ -12,9 +13,15 @@
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
currentPath?: string;
|
||||
content?: Snippet | null;
|
||||
};
|
||||
|
||||
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||
let {
|
||||
workspace,
|
||||
workspaceError = null,
|
||||
currentPath = '/',
|
||||
content = null,
|
||||
}: Props = $props();
|
||||
|
||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||
</script>
|
||||
@@ -38,11 +45,15 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{#if content}
|
||||
{@render content()}
|
||||
{:else}
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from './workspace-content-context';
|
||||
|
||||
let { content }: { content: Snippet } = $props();
|
||||
|
||||
const controller = getContext<WorkspaceSidebarContentController>(
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
);
|
||||
|
||||
$effect(() => controller.registerContent(content));
|
||||
</script>
|
||||
@@ -4,8 +4,7 @@ import type { Snippet } from "svelte";
|
||||
export type SidebarSnippet = Snippet<[]>;
|
||||
|
||||
export type SidebarController = {
|
||||
setSidebar(snippet: SidebarSnippet): void;
|
||||
clearSidebar(snippet: SidebarSnippet): void;
|
||||
registerSidebar(sidebar: SidebarSnippet): () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createOverrideStack } from "./override-stack.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: string | URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||
if (!Object.is(actual, expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${String(expected)}, received ${String(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("nested sidebar cleanup restores the parent override", () => {
|
||||
let active: string | null = null;
|
||||
const stack = createOverrideStack<string>((value) => {
|
||||
active = value;
|
||||
});
|
||||
|
||||
const clearWorkspace = stack.register("workspace");
|
||||
assertEquals(active, "workspace", "workspace sidebar should become active");
|
||||
|
||||
const clearSettings = stack.register("settings");
|
||||
assertEquals(
|
||||
active,
|
||||
"settings",
|
||||
"child settings sidebar should override its parent",
|
||||
);
|
||||
|
||||
clearSettings();
|
||||
assertEquals(
|
||||
active,
|
||||
"workspace",
|
||||
"removing the child should restore its parent",
|
||||
);
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(
|
||||
active,
|
||||
null,
|
||||
"removing the final override should restore the global sidebar",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("sidebar disposers remove only their own registration", () => {
|
||||
let active: string | null = null;
|
||||
const stack = createOverrideStack<string>((value) => {
|
||||
active = value;
|
||||
});
|
||||
|
||||
const clearWorkspace = stack.register("workspace");
|
||||
const clearSettings = stack.register("settings");
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(
|
||||
active,
|
||||
"settings",
|
||||
"removing an inactive parent should preserve the active child",
|
||||
);
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(active, "settings", "a disposer should be idempotent");
|
||||
|
||||
clearSettings();
|
||||
assertEquals(
|
||||
active,
|
||||
null,
|
||||
"removing the remaining child should empty the stack",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
|
||||
const layoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspaceLayoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspaceSidebarUrl = new URL(
|
||||
"./WorkspaceSidebar.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const settingsContentUrl = new URL(
|
||||
"./SettingsSidebarContent.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
|
||||
await Promise.all([
|
||||
Deno.readTextFile(layoutUrl),
|
||||
Deno.readTextFile(workspaceLayoutUrl),
|
||||
Deno.readTextFile(workspaceSidebarUrl),
|
||||
Deno.readTextFile(settingsContentUrl),
|
||||
]);
|
||||
|
||||
assert(
|
||||
layout.includes(
|
||||
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
|
||||
),
|
||||
"settings layout should override the WorkspaceSidebar content slot",
|
||||
);
|
||||
assert(
|
||||
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
|
||||
"settings layout should not replace the whole sidebar or retain inline navigation",
|
||||
);
|
||||
assert(
|
||||
workspaceLayout.includes(
|
||||
"registerContent: sidebarContentOverrides.register",
|
||||
) &&
|
||||
workspaceLayout.includes("content={sidebarContent}"),
|
||||
"workspace layout should provide and project the active child content",
|
||||
);
|
||||
assert(
|
||||
workspaceSidebar.includes("<WorkspaceSwitcher") &&
|
||||
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
|
||||
workspaceSidebar.indexOf("{#if content}") &&
|
||||
workspaceSidebar.includes("{@render content()}"),
|
||||
"WorkspaceSidebar should retain its header and render child content below it",
|
||||
);
|
||||
assert(
|
||||
settingsContent.includes("SETTINGS_SECTIONS") &&
|
||||
settingsContent.includes('aria-label="Settings sections"'),
|
||||
"SettingsSidebarContent should render the authoritative settings section catalog",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type OverrideDisposer = () => void;
|
||||
|
||||
export type OverrideStack<T> = {
|
||||
register(value: T): OverrideDisposer;
|
||||
};
|
||||
|
||||
export function createOverrideStack<T>(
|
||||
setActive: (value: T | null) => void,
|
||||
): OverrideStack<T> {
|
||||
let entries: Array<{ id: symbol; value: T }> = [];
|
||||
|
||||
return {
|
||||
register(value: T): OverrideDisposer {
|
||||
const id = Symbol();
|
||||
let disposed = false;
|
||||
|
||||
entries = [...entries, { id, value }];
|
||||
setActive(value);
|
||||
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
entries = entries.filter((entry) => entry.id !== id);
|
||||
setActive(entries.at(-1)?.value ?? null);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export type Runtime = {
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Diagnostic[];
|
||||
management?: {
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
removable: boolean;
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Host = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
|
||||
"workspace-sidebar-content",
|
||||
);
|
||||
|
||||
export type WorkspaceSidebarContentController = {
|
||||
registerContent(content: Snippet): () => void;
|
||||
};
|
||||
@@ -16,7 +16,6 @@
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.settings-hero,
|
||||
.settings-notice,
|
||||
.settings-section-header,
|
||||
.settings-patterns {
|
||||
@@ -183,7 +182,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-hero,
|
||||
.settings-notice,
|
||||
.settings-section-header,
|
||||
.settings-patterns {
|
||||
@@ -345,18 +343,11 @@
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.settings-nav a,
|
||||
.settings-section-card,
|
||||
.button-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-nav a,
|
||||
.settings-section-card {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
@@ -366,14 +357,10 @@
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.settings-nav a:hover,
|
||||
.settings-nav a:focus-visible,
|
||||
.settings-nav a.active,
|
||||
.settings-section-card:hover,
|
||||
.settings-section-card:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
.settings-nav span,
|
||||
.settings-section-card h3 {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
|
||||
@@ -5,22 +5,21 @@
|
||||
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
|
||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
|
||||
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import '../app.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { children }: LayoutProps = $props();
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||
sidebar = activeSidebar;
|
||||
});
|
||||
const headerController = $state<HeaderController>({ content: null });
|
||||
|
||||
provideHeaderController(headerController);
|
||||
setContext(SIDEBAR_CONTEXT, {
|
||||
setSidebar(snippet: SidebarSnippet) {
|
||||
sidebar = snippet;
|
||||
},
|
||||
clearSidebar(snippet: SidebarSnippet) {
|
||||
if (sidebar === snippet) sidebar = null;
|
||||
},
|
||||
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||
registerSidebar: sidebarOverrides.register,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from '$lib/workspace/sidebar/workspace-content-context';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||
import '$lib/workspace/styles/workspace-pages.css';
|
||||
@@ -11,6 +17,15 @@
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
let sidebarContent = $state<Snippet | null>(null);
|
||||
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
|
||||
sidebarContent = activeContent;
|
||||
});
|
||||
|
||||
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
|
||||
registerContent: sidebarContentOverrides.register,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const workspaceId = data.workspace?.workspace_id;
|
||||
if (!workspaceId) return;
|
||||
@@ -27,6 +42,7 @@
|
||||
workspace={data.workspace ?? null}
|
||||
workspaceError={data.workspaceError ?? null}
|
||||
currentPath={page.url.pathname}
|
||||
content={sidebarContent}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<small>Read typed Ticket records</small>
|
||||
</a>
|
||||
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
||||
<span>Runtime Inventory</span>
|
||||
<strong>Open admin runtime inventory</strong>
|
||||
<span>Runtimes</span>
|
||||
<strong>Open admin Runtimes</strong>
|
||||
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
||||
</a>
|
||||
<a class="workspace-action-card" href={workersHref}>
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
|
||||
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
|
||||
import '$lib/workspace/styles/settings.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const href = sectionHref(path);
|
||||
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
||||
}
|
||||
let { children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet settingsSidebarContent()}
|
||||
<SettingsSidebarContent
|
||||
workspaceId={page.params.workspaceId ?? ''}
|
||||
currentPath={page.url.pathname}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
|
||||
|
||||
<section class="settings-page">
|
||||
<div class="settings-hero">
|
||||
<p class="eyebrow">Settings / Admin</p>
|
||||
<h1>Workspace settings</h1>
|
||||
<p>
|
||||
Configure workspace metadata, runtime connections, and Decodal profile sources through the Backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<a class:active={page.url.pathname === settingsHref} href={settingsHref}>
|
||||
<span>Overview</span>
|
||||
<small>Settings map</small>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a class:active={isActive(settingsSectionHref(section.id))} href={href}>
|
||||
<span>{section.label}</span>
|
||||
<small>{section.status}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<section class="card settings-section" aria-labelledby="backend-config-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">read-only</p>
|
||||
<h2 id="backend-config-title">Backend config</h2>
|
||||
</div>
|
||||
<span class="badge warning">planned</span>
|
||||
</header>
|
||||
<p>
|
||||
Backend-owned config is exposed through typed settings APIs. Additional Backend config surfaces will be added as they become editable product settings.
|
||||
</p>
|
||||
</section>
|
||||
@@ -1,371 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||
import type {
|
||||
Diagnostic,
|
||||
RemoteRuntimeTestResponse,
|
||||
RuntimeConnectionMutationResponse,
|
||||
RuntimeConnectionSettingsResponse
|
||||
} from '$lib/workspace/settings/model';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type RemoteAddForm = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
||||
let runtimeLoading = $state(true);
|
||||
let runtimeError = $state<string | null>(null);
|
||||
let mutationMessage = $state<string | null>(null);
|
||||
let mutationDiagnostics = $state<Diagnostic[]>([]);
|
||||
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
|
||||
let deleting = $state<string | null>(null);
|
||||
let testing = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let showAddRuntimeForm = $state(false);
|
||||
let remoteForm = $state<RemoteAddForm>({
|
||||
runtime_id: '',
|
||||
display_name: '',
|
||||
endpoint: ''
|
||||
});
|
||||
|
||||
function settingsApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
runtimeLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRuntimeSettings() {
|
||||
runtimeLoading = true;
|
||||
runtimeError = null;
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/settings/runtime-connections'));
|
||||
if (!response.ok) {
|
||||
throw new Error(`runtime settings request failed (${response.status})`);
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionSettingsResponse;
|
||||
if (!cancelled) {
|
||||
runtimeSettings = responseData;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
runtimeError = err instanceof Error ? err.message : 'runtime settings request failed';
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
runtimeLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRuntimeSettings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function submitRemoteRuntime() {
|
||||
submitting = true;
|
||||
mutationMessage = null;
|
||||
mutationDiagnostics = [];
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/settings/runtime-connections/remotes'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
runtime_id: remoteForm.runtime_id,
|
||||
display_name: remoteForm.display_name || null,
|
||||
endpoint: remoteForm.endpoint
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'add remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||
applyRuntimeMutation(responseData);
|
||||
remoteForm = { runtime_id: '', display_name: '', endpoint: '' };
|
||||
showAddRuntimeForm = false;
|
||||
} catch (err) {
|
||||
mutationMessage = err instanceof Error ? err.message : 'add remote Runtime failed';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRemoteRuntime(runtimeId: string) {
|
||||
deleting = runtimeId;
|
||||
mutationMessage = null;
|
||||
mutationDiagnostics = [];
|
||||
try {
|
||||
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'delete remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||
applyRuntimeMutation(responseData);
|
||||
const nextTests = { ...tests };
|
||||
delete nextTests[runtimeId];
|
||||
tests = nextTests;
|
||||
} catch (err) {
|
||||
mutationMessage = err instanceof Error ? err.message : 'delete remote Runtime failed';
|
||||
} finally {
|
||||
deleting = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRemoteRuntime(runtimeId: string) {
|
||||
testing = runtimeId;
|
||||
try {
|
||||
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'test remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RemoteRuntimeTestResponse;
|
||||
tests = { ...tests, [runtimeId]: responseData };
|
||||
} catch (err) {
|
||||
tests = {
|
||||
...tests,
|
||||
[runtimeId]: {
|
||||
workspace_id: runtimeSettings?.workspace_id ?? 'unknown',
|
||||
runtime_id: runtimeId,
|
||||
checked_at: new Date().toISOString(),
|
||||
state: 'failed',
|
||||
protocol_version: null,
|
||||
compatibility_basis: 'browser request failed',
|
||||
capabilities: [],
|
||||
health_result: 'failed',
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'browser_runtime_test_failed',
|
||||
severity: 'error',
|
||||
message: err instanceof Error ? err.message : 'test remote Runtime failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityOperations(test: RemoteRuntimeTestResponse, state: 'available' | 'unknown' | 'incompatible'): string[] {
|
||||
const suffix = `:${state}`;
|
||||
return test.capabilities
|
||||
.filter((capability) => capability.endsWith(suffix))
|
||||
.map((capability) => capability.slice(0, -suffix.length));
|
||||
}
|
||||
|
||||
function applyRuntimeMutation(responseData: RuntimeConnectionMutationResponse) {
|
||||
runtimeSettings = runtimeSettings
|
||||
? { ...runtimeSettings, remotes: responseData.remotes, diagnostics: responseData.diagnostics }
|
||||
: {
|
||||
workspace_id: responseData.workspace_id,
|
||||
embedded: {
|
||||
runtime_id: 'embedded-worker-runtime',
|
||||
display_name: 'Embedded Runtime',
|
||||
kind: 'embedded_worker_runtime',
|
||||
built_in: true,
|
||||
config_managed: false,
|
||||
active: false,
|
||||
worker_creation_available: false,
|
||||
restart_required: false,
|
||||
status: 'unknown',
|
||||
diagnostics: []
|
||||
},
|
||||
remotes: responseData.remotes,
|
||||
diagnostics: responseData.diagnostics
|
||||
};
|
||||
mutationDiagnostics = responseData.diagnostics;
|
||||
mutationMessage = responseData.restart_required
|
||||
? 'Runtime config saved. Restart the Workspace backend to apply live registry changes.'
|
||||
: 'Runtime config saved.';
|
||||
}
|
||||
|
||||
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
|
||||
if (typeof payload.error === 'object' && payload.error?.message) {
|
||||
return `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`;
|
||||
}
|
||||
if (payload.message) {
|
||||
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
|
||||
return `${code}: ${payload.message}`;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Runtime Connections · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card settings-section" aria-labelledby="runtime-connections-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="runtime-connections-title">Runtime Connections</h2>
|
||||
</div>
|
||||
<span class="badge success">typed API</span>
|
||||
</header>
|
||||
<p>Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.</p>
|
||||
|
||||
{#if runtimeLoading}
|
||||
<p class="status-message">Loading Runtime connections…</p>
|
||||
{:else if runtimeError}
|
||||
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
|
||||
{:else if runtimeSettings}
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (showAddRuntimeForm = !showAddRuntimeForm)}>
|
||||
{showAddRuntimeForm ? 'Cancel adding Runtime' : 'Add remote Runtime'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddRuntimeForm}
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
|
||||
<h3>Add remote Runtime</h3>
|
||||
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
|
||||
<label>
|
||||
<span>Runtime id</span>
|
||||
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Endpoint</span>
|
||||
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
|
||||
</label>
|
||||
<button type="submit" disabled={submitting}>{submitting ? 'Saving…' : 'Add Runtime'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if mutationMessage}
|
||||
<p class="status-message" class:error={mutationMessage.includes('failed')}>{mutationMessage}</p>
|
||||
{/if}
|
||||
<DiagnosticsList diagnostics={mutationDiagnostics} />
|
||||
|
||||
<div class="settings-runtime-list" aria-label="Runtime connections">
|
||||
<h3>Runtimes</h3>
|
||||
<div class="settings-runtime-table-wrap">
|
||||
<table class="settings-runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Runtime</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Connection</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class:inactive={!runtimeSettings.embedded.active}>
|
||||
<td>
|
||||
<strong>{runtimeSettings.embedded.display_name}</strong>
|
||||
<code>{runtimeSettings.embedded.runtime_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>embedded</strong>
|
||||
<span>Workspace backend process</span>
|
||||
</td>
|
||||
<td>Local Backend runtime</td>
|
||||
<td>
|
||||
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
|
||||
{#if runtimeSettings.embedded.restart_required}
|
||||
<span class="badge warning">restart required</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td><span class="settings-muted-action">Managed by backend</span></td>
|
||||
</tr>
|
||||
{#if runtimeSettings.embedded.diagnostics.length > 0}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5"><DiagnosticsList diagnostics={runtimeSettings.embedded.diagnostics} /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
|
||||
<tr class:inactive={!remote.active}>
|
||||
<td>
|
||||
<strong>{remote.display_name}</strong>
|
||||
<code>{remote.runtime_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>remote</strong>
|
||||
<span>Configured Runtime endpoint</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>Endpoint: {remote.endpoint_configured ? 'configured' : 'not configured'}</span>
|
||||
{#if remote.endpoint_configured}<small>hidden</small>{/if}
|
||||
<span>Token: {remote.token_ref_configured ? 'configured' : 'not configured'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
|
||||
{#if remote.restart_required}
|
||||
<span class="badge warning">restart required</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
|
||||
{testing === remote.runtime_id ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
|
||||
{deleting === remote.runtime_id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if remote.diagnostics.length > 0}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5"><DiagnosticsList diagnostics={remote.diagnostics} /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if tests[remote.runtime_id]}
|
||||
{@const test = tests[remote.runtime_id]}
|
||||
{@const available = capabilityOperations(test, 'available')}
|
||||
{@const unchecked = capabilityOperations(test, 'unknown')}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5">
|
||||
<div class="settings-test-result">
|
||||
<strong>Test: {test.state}</strong>
|
||||
<span>{test.health_result} · {test.checked_at}</span>
|
||||
<p>{test.compatibility_basis}</p>
|
||||
{#if available.length > 0}
|
||||
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
|
||||
{/if}
|
||||
{#if unchecked.length > 0}
|
||||
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
|
||||
{/if}
|
||||
<DiagnosticsList diagnostics={test.diagnostics} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if runtimeSettings.remotes.length === 0}
|
||||
<p class="status-message">No remote Runtime connections configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,27 +1,161 @@
|
||||
<script lang="ts">
|
||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import type { Diagnostic, Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type ConnectionTest = {
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
state: string;
|
||||
protocol_version?: string | null;
|
||||
compatibility_basis: string;
|
||||
capabilities: string[];
|
||||
health_result: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let runtimeId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let showAddRuntime = $state(false);
|
||||
let busyRuntimeId = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let testResults = $state<Record<string, ConnectionTest>>({});
|
||||
|
||||
function runtimePlatform(runtime: Runtime): string {
|
||||
return `${runtime.os} / ${runtime.arch}`;
|
||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||
}
|
||||
|
||||
function managementLabel(runtime: Runtime): string {
|
||||
if (runtime.management?.built_in) return 'Built-in';
|
||||
if (runtime.management?.config_managed) return 'Managed remote';
|
||||
return 'Observed';
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<string> {
|
||||
const payload = await response.json().catch(() => null) as
|
||||
| { message?: string; error?: string }
|
||||
| null;
|
||||
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
async function addRuntime(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
requestError = null;
|
||||
busyRuntimeId = 'create';
|
||||
try {
|
||||
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName || null,
|
||||
endpoint,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
runtimeId = '';
|
||||
displayName = '';
|
||||
endpoint = '';
|
||||
showAddRuntime = false;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const nextResults = { ...testResults };
|
||||
delete nextResults[runtime.runtime_id];
|
||||
testResults = nextResults;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests`,
|
||||
),
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const result = await response.json() as ConnectionTest;
|
||||
testResults = { ...testResults, [runtime.runtime_id]: result };
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Runtime Inventory · Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Runtime inventory" />
|
||||
<title>Runtimes · Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Runtime resources" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<h1 id="runtimes-heading">Runtime Inventory</h1>
|
||||
<p>Admin view of execution backends available to this workspace.</p>
|
||||
<h1 id="runtimes-heading">Runtimes</h1>
|
||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showAddRuntime}
|
||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||
<h2>Add remote Runtime</h2>
|
||||
<div class="settings-form-grid">
|
||||
<label>
|
||||
Runtime ID
|
||||
<input bind:value={runtimeId} required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input bind:value={displayName} autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
|
||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if requestError}
|
||||
<p class="section-state error">{requestError}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.runtimesError}
|
||||
<p class="section-state error">{data.runtimesError}</p>
|
||||
{:else if !data.runtimes}
|
||||
@@ -29,20 +163,22 @@
|
||||
{:else if data.runtimes.items.length === 0}
|
||||
<p class="section-state">No Runtimes are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="runtimes-table">
|
||||
<div class="settings-runtime-table-wrap">
|
||||
<table class="settings-runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Runtime</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Platform</th>
|
||||
<th>Management</th>
|
||||
<th>Workdirs</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.runtimes.items as runtime}
|
||||
<tr>
|
||||
<tr class:inactive={runtime.status !== 'active'}>
|
||||
<td>
|
||||
<strong>{runtime.label}</strong>
|
||||
<small><code>{runtime.runtime_id}</code></small>
|
||||
@@ -50,12 +186,58 @@
|
||||
<td>{runtime.kind}</td>
|
||||
<td>{runtime.status}</td>
|
||||
<td>{runtimePlatform(runtime)}</td>
|
||||
<td>{managementLabel(runtime)}</td>
|
||||
<td>
|
||||
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||
Open workdirs
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="settings-action-row">
|
||||
{#if runtime.management?.config_managed}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => testRuntime(runtime)}
|
||||
>Test</button>
|
||||
{/if}
|
||||
{#if runtime.management?.removable}
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => deleteRuntime(runtime)}
|
||||
>Delete</button>
|
||||
{:else}
|
||||
<span class="settings-muted-action">Not removable</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="7">
|
||||
{#if runtime.diagnostics.length > 0}
|
||||
<ul class="settings-diagnostics-list">
|
||||
{#each runtime.diagnostics as diagnostic}
|
||||
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if testResults[runtime.runtime_id]}
|
||||
{@const result = testResults[runtime.runtime_id]}
|
||||
<div class="settings-test-result">
|
||||
<strong>Connection test: {result.state}</strong>
|
||||
<span>{result.health_result}</span>
|
||||
<small>{result.compatibility_basis} · {result.checked_at}</small>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
|
||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
||||
<h1 id="workdirs-heading">Workdirs</h1>
|
||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||
|
||||
Reference in New Issue
Block a user