ui: split workspace settings routes

This commit is contained in:
Keisuke Hirata 2026-07-09 01:23:34 +09:00
parent 981f0501a3
commit 292ad2c202
No known key found for this signature in database
12 changed files with 1003 additions and 809 deletions

View File

@ -1 +1,3 @@
default = "builtin:companion"
[profile]

View File

@ -1446,3 +1446,212 @@
display: block;
}
}
@layer components {
.settings-page {
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);
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
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;
}
.settings-section-grid,
.settings-profile-grid,
.settings-pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: var(--space-4);
}
.settings-card {
display: grid;
gap: var(--space-4);
}
.settings-card-header,
.settings-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.settings-form,
.settings-registry-editor {
display: grid;
gap: var(--space-3);
}
.settings-form label,
.settings-registry-editor label {
display: grid;
gap: var(--space-1);
color: var(--text-muted);
font-size: 0.82rem;
}
.settings-form input,
.settings-form select,
.settings-form textarea,
.settings-registry-editor input,
.settings-registry-editor select,
.settings-runtime-form input {
width: 100%;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--bg-raised);
color: var(--text-strong);
padding: 0.45rem 0.55rem;
font: inherit;
}
.settings-form textarea {
min-height: 18rem;
resize: vertical;
font-family: var(--font-mono);
}
.settings-form button,
.button-link {
width: fit-content;
border: 0;
border-radius: 0.6rem;
background: var(--accent);
color: var(--bg);
font-weight: 700;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
.settings-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-form button.danger {
background: var(--danger);
}
.settings-note {
margin: 0;
color: var(--text-muted);
}
.settings-profile-list {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.settings-profile-list li,
.settings-registry-editor fieldset,
.settings-pattern-grid article {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-profile-list strong,
.settings-profile-list span,
.settings-profile-list small {
display: block;
}
.settings-profile-list strong {
color: var(--text-strong);
}
.settings-profile-list span,
.settings-profile-list small,
.settings-profile-list p {
margin: 0;
color: var(--text-muted);
}
.settings-registry-editor fieldset {
margin: 0;
}
.settings-registry-editor legend {
color: var(--text-strong);
font-weight: 750;
}
.settings-identity-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--space-3);
margin: 0;
}
.settings-identity-list div {
display: grid;
gap: var(--space-1);
}
.settings-identity-list dt {
color: var(--text-muted);
font-size: 0.78rem;
}
.settings-identity-list dd {
margin: 0;
color: var(--text-strong);
}
.section-status-pill {
width: fit-content;
border-radius: 999px;
padding: 0.25rem 0.55rem;
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.status-message.error {
color: var(--danger);
}
}

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { diagnosticLabel, type Diagnostic } from './model';
type Props = {
diagnostics: Diagnostic[];
};
let { diagnostics }: Props = $props();
</script>
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnosticLabel(diagnostic)}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}

View File

@ -1,9 +1,9 @@
import {
diagnosticLabel,
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
SETTINGS_ROUTE,
SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref,
} from "./model.ts";
@ -23,12 +23,12 @@ Deno.test("settings section navigation stays under the settings route", () => {
for (const section of SETTINGS_SECTIONS) {
const href = settingsSectionHref(section.id);
assert(
href.startsWith("/settings#"),
href.startsWith("/settings"),
`${section.id} should link under settings`,
);
assert(
href.endsWith(section.id),
`${section.id} href should preserve section id`,
!href.includes("#"),
`${section.id} href should use a dedicated route instead of a page anchor`,
);
}
});
@ -48,7 +48,10 @@ Deno.test("runtime connections are editable without advertising raw authority le
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
section.id === "runtime-connections"
);
assert(runtimeSection?.status === "editable", "Runtime Connections should be editable");
assert(
runtimeSection?.status === "editable",
"Runtime Connections should be editable",
);
const allText = [
SETTINGS_PERMISSION_NOTICE,
@ -61,7 +64,8 @@ Deno.test("runtime connections are editable without advertising raw authority le
].join("\n");
assert(
allText.includes("restart_required=true") || allText.includes("Restart-required"),
allText.includes("restart_required=true") ||
allText.includes("Restart-required"),
"restart-required pattern should be visible",
);
assert(
@ -93,7 +97,8 @@ Deno.test("diagnostic labels preserve severity and code", () => {
message: "Restart required.",
} as const;
assert(
diagnosticLabel(diagnostic) === "warning: runtime_registry_restart_required",
diagnosticLabel(diagnostic) ===
"warning: runtime_registry_restart_required",
"diagnostic label should be bounded and stable",
);
});

View File

@ -142,7 +142,16 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
];
export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}#${id}`;
switch (id) {
case "runtime-connections":
return `${SETTINGS_ROUTE}/runtime-connections`;
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":
return `${SETTINGS_ROUTE}/workspace`;
case "backend-config":
return `${SETTINGS_ROUTE}/backend`;
}
}
export function diagnosticLabel(diagnostic: Diagnostic): string {

View File

@ -1,4 +1,7 @@
import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
} from "../workspace-api/http";
import type {
ProfileSettingsMutationResponse,
ProfileSettingsResponse,
@ -7,32 +10,51 @@ import type {
WorkspaceProfileSourceDetailResponse,
} from "./profile-types";
export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse> {
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`);
export function fetchWorkspaceMetadataSettings(
workspaceId: string,
): Promise<WorkspaceMetadataSettingsResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
);
}
export function updateWorkspaceMetadataSettings(
workspaceId: string,
request: { display_name: string; revision: string },
): Promise<WorkspaceMetadataMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
{
method: "PUT",
body: JSON.stringify(request),
});
},
);
}
export function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`);
export function fetchProfileSettings(
workspaceId: string,
): Promise<ProfileSettingsResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
);
}
export function createProfileSource(
workspaceId: string,
request: { name: string; description?: string; content: string; registry_revision: string },
request: {
name: string;
description?: string;
content: string;
registry_revision: string;
},
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
{
method: "POST",
body: JSON.stringify(request),
});
},
);
}
export function updateProfileRegistry(
@ -40,13 +62,22 @@ export function updateProfileRegistry(
request: {
registry_revision: string;
default_profile?: string | null;
profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>;
profiles: Array<
{
name: string;
description?: string | null;
profile_source_id?: string | null;
}
>;
},
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`,
{
method: "PUT",
body: JSON.stringify(request),
});
},
);
}
export function fetchProfileSource(
@ -54,7 +85,9 @@ export function fetchProfileSource(
sourceId: string,
): Promise<WorkspaceProfileSourceDetailResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
encodeURIComponent(sourceId)
}`,
);
}
@ -64,7 +97,9 @@ export function updateProfileSource(
request: { content: string; revision: string },
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
encodeURIComponent(sourceId)
}`,
{ method: "PUT", body: JSON.stringify(request) },
);
}
@ -75,7 +110,9 @@ export function deleteProfileSource(
request: { registry_revision: string; source_revision: string },
): Promise<ProfileSettingsMutationResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
encodeURIComponent(sourceId)
}`,
{ method: "DELETE", body: JSON.stringify(request) },
);
}

View File

@ -0,0 +1,46 @@
<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 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}/`);
}
</script>
<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>

View File

@ -1,789 +1,59 @@
<script lang="ts">
import type { PageProps } from "./$types";
import { workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
import {
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref,
type Diagnostic,
type RemoteRuntimeConnectionSummary,
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
} from "$lib/workspace-settings/model";
import type {
ProfileSettingsResponse,
WorkspaceMetadataSettingsResponse,
WorkspaceProfileSourceDetailResponse,
} from "$lib/workspace-settings/profile-types";
import {
createProfileSource,
deleteProfileSource,
fetchProfileSettings,
fetchProfileSource,
fetchWorkspaceMetadataSettings,
updateProfileRegistry,
updateProfileSource,
updateWorkspaceMetadataSettings,
} from "$lib/workspace-settings/profile-api";
type RemoteAddForm = {
runtime_id: string;
display_name: string;
endpoint: string;
};
import { workspaceRoute } from '$lib/workspace-api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace-settings/model';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspace = $derived(data.workspace);
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceError = $derived(data.workspaceError);
function settingsApiPath(path: string): string {
return workspaceApiPath(workspaceId, path);
}
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: "",
});
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let profileSettings = $state<ProfileSettingsResponse | null>(null);
let selectedProfileSource = $state<WorkspaceProfileSourceDetailResponse | null>(null);
let profileSourceContent = $state("");
let newProfileName = $state("");
let newProfileDescription = $state("");
let newProfileContent = $state("{\n slug = \"workspace-profile\";\n description = \"Workspace profile\";\n scope = \"workspace_read\";\n}\n");
let profileLoading = $state(true);
let profileMessage = $state<string | null>(null);
let profileDiagnostics = $state<Diagnostic[]>([]);
let profileSubmitting = $state(false);
let workspaceNameDraft = $state("");
let defaultProfileDraft = $state("");
$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 data = (await response.json()) as RuntimeConnectionSettingsResponse;
if (!cancelled) {
runtimeSettings = data;
}
} catch (err) {
if (!cancelled) {
runtimeError = err instanceof Error ? err.message : "runtime settings request failed";
}
} finally {
if (!cancelled) {
runtimeLoading = false;
}
}
}
loadRuntimeSettings();
return () => {
cancelled = true;
};
});
$effect(() => {
if (!workspaceId) {
profileLoading = false;
return;
}
let cancelled = false;
async function loadProfileAndWorkspaceSettings() {
profileLoading = true;
profileMessage = null;
profileDiagnostics = [];
try {
const [workspace, profiles] = await Promise.all([
fetchWorkspaceMetadataSettings(workspaceId),
fetchProfileSettings(workspaceId),
]);
if (!cancelled) {
workspaceMetadata = workspace;
workspaceNameDraft = workspace.display_name;
profileSettings = profiles;
defaultProfileDraft = profiles.default_profile ?? "";
profileDiagnostics = [...workspace.diagnostics, ...profiles.diagnostics];
}
} catch (err) {
if (!cancelled) {
profileMessage = err instanceof Error ? err.message : "profile settings request failed";
}
} finally {
if (!cancelled) {
profileLoading = false;
}
}
}
loadProfileAndWorkspaceSettings();
return () => {
cancelled = true;
};
});
async function refreshProfileSettings() {
profileSettings = await fetchProfileSettings(workspaceId);
profileDiagnostics = profileSettings.diagnostics;
}
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
profileSubmitting = true;
profileMessage = null;
try {
const response = await updateWorkspaceMetadataSettings(workspaceId, {
display_name: workspaceNameDraft,
revision: workspaceMetadata.revision,
});
workspaceMetadata = response.workspace;
workspaceNameDraft = response.workspace.display_name;
profileDiagnostics = response.diagnostics.concat(response.workspace.diagnostics);
profileMessage = "Workspace display name updated.";
} catch (err) {
profileMessage = err instanceof Error ? err.message : "workspace update failed";
} finally {
profileSubmitting = false;
}
}
async function submitProfileRegistry() {
if (!profileSettings) return;
profileSubmitting = true;
profileMessage = null;
try {
const response = await updateProfileRegistry(workspaceId, {
registry_revision: profileSettings.registry_revision,
default_profile: defaultProfileDraft || null,
profiles: profileSettings.profiles
.filter((profile) => profile.source_kind === "project")
.map((profile) => ({
name: profile.selector.replace(/^project:/, ""),
description: profile.description ?? null,
profile_source_id: profile.profile_source_id ?? null,
})),
});
profileSettings = response.settings;
defaultProfileDraft = response.settings.default_profile ?? "";
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
profileMessage = "Profile registry updated.";
} catch (err) {
profileMessage = err instanceof Error ? err.message : "profile registry update failed";
} finally {
profileSubmitting = false;
}
}
async function submitNewProfileSource() {
if (!profileSettings) return;
profileSubmitting = true;
profileMessage = null;
try {
const response = await createProfileSource(workspaceId, {
name: newProfileName,
description: newProfileDescription || undefined,
content: newProfileContent,
registry_revision: profileSettings.registry_revision,
});
profileSettings = response.settings;
defaultProfileDraft = response.settings.default_profile ?? "";
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
newProfileName = "";
newProfileDescription = "";
profileMessage = "Profile source created.";
} catch (err) {
profileMessage = err instanceof Error ? err.message : "profile source create failed";
} finally {
profileSubmitting = false;
}
}
async function selectProfileSource(sourceId: string) {
profileSubmitting = true;
profileMessage = null;
try {
selectedProfileSource = await fetchProfileSource(workspaceId, sourceId);
profileSourceContent = selectedProfileSource.content;
profileDiagnostics = selectedProfileSource.diagnostics.concat(
selectedProfileSource.source.diagnostics,
selectedProfileSource.profile.diagnostics,
);
} catch (err) {
profileMessage = err instanceof Error ? err.message : "profile source load failed";
} finally {
profileSubmitting = false;
}
}
async function submitProfileSourceUpdate() {
if (!selectedProfileSource) return;
profileSubmitting = true;
profileMessage = null;
try {
const response = await updateProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, {
content: profileSourceContent,
revision: selectedProfileSource.source.revision,
});
profileSettings = response.settings;
defaultProfileDraft = response.settings.default_profile ?? "";
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
await selectProfileSource(selectedProfileSource.source.profile_source_id);
profileMessage = "Profile source updated.";
} catch (err) {
profileMessage = err instanceof Error ? err.message : "profile source update failed";
} finally {
profileSubmitting = false;
}
}
async function submitProfileSourceDelete() {
if (!profileSettings || !selectedProfileSource) return;
profileSubmitting = true;
profileMessage = null;
try {
const response = await deleteProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, {
registry_revision: profileSettings.registry_revision,
source_revision: selectedProfileSource.source.revision,
});
profileSettings = response.settings;
selectedProfileSource = null;
profileSourceContent = "";
profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics);
profileMessage = "Profile source deleted.";
} catch (err) {
profileMessage = err instanceof Error ? err.message : "profile source delete failed";
} finally {
profileSubmitting = false;
}
}
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 data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
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 data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
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 data = (await response.json()) as RemoteRuntimeTestResponse;
tests = { ...tests, [runtimeId]: data };
} 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(data: RuntimeConnectionMutationResponse) {
runtimeSettings = runtimeSettings
? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics }
: {
workspace_id: data.workspace_id,
embedded: {
runtime_id: "embedded-worker-runtime",
display_name: "Embedded Runtime",
kind: "embedded_worker_runtime",
built_in: true,
config_managed: false,
active: false,
can_spawn_worker: false,
restart_required: false,
status: "unknown",
diagnostics: [],
},
remotes: data.remotes,
diagnostics: data.diagnostics,
};
mutationDiagnostics = data.diagnostics;
mutationMessage = data.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})`;
function href(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
</script>
<svelte:head>
<title>Settings · Yoi Workspace</title>
</svelte:head>
<div class="settings-shell" aria-labelledby="settings-title">
<section class="hero settings-hero">
<section class="settings-card">
<div class="settings-card-header">
<div>
<p class="eyebrow">Workspace Browser</p>
<h1 id="settings-title">Settings / Admin</h1>
<p class="hero-copy">
Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
</p>
<p class="eyebrow">Overview</p>
<h2>Settings areas</h2>
</div>
<span class="badge warning">local only</span>
</section>
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
<div>
<p class="eyebrow">Authority boundary</p>
<h2 id="settings-boundary-title">No browser admin permission model</h2>
<p>{SETTINGS_PERMISSION_NOTICE}</p>
</div>
<div class="settings-diagnostic" role="note">
<strong>Restart-required</strong>
<span>Runtime config changes are persisted, then applied after backend restart.</span>
</div>
</section>
<section class="settings-nav-card" aria-label="Settings sections">
<div class="settings-section-grid">
{#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={workspaceRoute(workspaceId, settingsSectionHref(section.id))}>
<span>{section.label}</span>
<small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
<a class="settings-section-card" href={href(settingsSectionHref(section.id))}>
<span class="section-status-pill">{section.status}</span>
<h3>{section.label}</h3>
<p>{section.summary}</p>
</a>
{/each}
</section>
</div>
</section>
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
<header class="settings-section-header">
<section class="settings-card">
<div class="settings-card-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="runtime-connections-title">Runtime Connections</h2>
<p class="eyebrow">Backend pattern</p>
<h2>Authority boundaries</h2>
</div>
<span class="badge success">typed API</span>
</header>
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</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}
{@render 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">{@render 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">{@render 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}
{@render 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>
<section class="card profile-settings" id="profile-sources" aria-labelledby="profile-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="profile-settings-title">Profile Sources</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
<div class="settings-pattern-grid">
<article>
<h3>Backend-owned state</h3>
<p>
Workspace profile settings are surfaced as source-qualified selectors and Decodal source summaries. The browser never receives raw host paths, runtime endpoints, tokens, resource handles, archive content, or archive digests.
Workspace metadata and Decodal profile sources are edited through Backend APIs, not direct frontend filesystem access.
</p>
</article>
<article>
<h3>Runtime inputs</h3>
<p>
Runtime execution receives explicit resources and WorkingDirectory handles; raw workspace paths stay out of Browser-facing payloads.
</p>
</article>
<article>
<h3>Diagnostics first</h3>
<p>
Settings mutations return typed diagnostics so validation issues are visible without exposing internal paths or credentials.
</p>
{#if profileLoading}
<p class="status-message">Loading profile settings…</p>
{:else}
<div class="grid settings-grid">
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitWorkspaceName(); }}>
<h3>Workspace display name</h3>
<label>
<span>Display name</span>
<input bind:value={workspaceNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={profileSubmitting || !workspaceMetadata}>Save workspace name</button>
</form>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitProfileRegistry(); }}>
<h3>Default launch profile</h3>
<label>
<span>Default selector</span>
<select bind:value={defaultProfileDraft} disabled={!profileSettings}>
<option value="">Runtime default</option>
{#each profileSettings?.profiles ?? [] as profile}
<option value={profile.selector}>{profile.label} ({profile.selector})</option>
{/each}
</select>
</label>
<button type="submit" disabled={profileSubmitting || !profileSettings}>Save profile registry</button>
</form>
</div>
<div class="settings-table-wrapper">
<h3>Discovered profiles</h3>
<table class="settings-table">
<thead>
<tr><th>Selector</th><th>Label</th><th>Source</th><th>Status</th><th>Action</th></tr>
</thead>
<tbody>
{#each profileSettings?.profiles ?? [] as profile}
<tr>
<td><code>{profile.selector}</code></td>
<td>{profile.label}</td>
<td>{profile.source_kind}</td>
<td>{profile.diagnostics.length === 0 ? "ok" : `${profile.diagnostics.length} diagnostics`}</td>
<td>
{#if profile.profile_source_id}
<button type="button" onclick={() => selectProfileSource(profile.profile_source_id!)} disabled={profileSubmitting}>Open source</button>
{:else}
<span class="settings-note">builtin</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="grid settings-grid">
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitNewProfileSource(); }}>
<h3>Create project profile source</h3>
<label><span>Name</span><input bind:value={newProfileName} placeholder="team-coder" autocomplete="off" /></label>
<label><span>Description</span><input bind:value={newProfileDescription} placeholder="Optional summary" autocomplete="off" /></label>
<label><span>Decodal source</span><textarea bind:value={newProfileContent} rows="8"></textarea></label>
<button type="submit" disabled={profileSubmitting || !profileSettings}>Create source</button>
</form>
{#if selectedProfileSource}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); submitProfileSourceUpdate(); }}>
<h3>Edit {selectedProfileSource.profile.label}</h3>
<p class="settings-note">Source id: <code>{selectedProfileSource.source.profile_source_id}</code>; display path: {selectedProfileSource.source.display_path}</p>
<label><span>Decodal source</span><textarea bind:value={profileSourceContent} rows="14"></textarea></label>
<div class="settings-actions">
<button type="submit" disabled={profileSubmitting}>Validate & save source</button>
<button type="button" class="danger" onclick={submitProfileSourceDelete} disabled={profileSubmitting}>Delete source</button>
</div>
</form>
{:else}
<div class="settings-empty-state">
<h3>No profile source selected</h3>
<p>Open a project source from the discovered profile table to edit it.</p>
</div>
{/if}
</div>
{#if profileMessage}
<p class="status-message">{profileMessage}</p>
{/if}
{@render DiagnosticsList({ diagnostics: profileDiagnostics })}
{/if}
</section>
<div class="grid settings-grid">
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections" && section.id !== "profile-sources") as section}
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
<header class="settings-section-header">
<div>
<p class="eyebrow">{section.status}</p>
<h2 id={`${section.id}-title`}>{section.label}</h2>
</div>
{#if section.status === "placeholder"}
<span class="badge neutral">not implemented</span>
{:else}
<span class="badge success">read-only</span>
{/if}
</header>
<p>{section.summary}</p>
<ul>
{#each section.bullets as bullet}
<li>{bullet}</li>
{/each}
</ul>
{#if section.id === "workspace-identity"}
<dl class="settings-identity-list">
<div>
<dt>Workspace id</dt>
<dd><code>{workspace?.workspace_id ?? "loading"}</code></dd>
</div>
<div>
<dt>Display name</dt>
<dd>{workspace?.display_name ?? "loading"}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>.yoi tickets/objectives through the Backend projection</dd>
</div>
</dl>
{/if}
</section>
{/each}
</div>
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
<div>
<p class="eyebrow">Implementation patterns</p>
<h2 id="settings-patterns-title">How settings should appear</h2>
</div>
<div class="grid settings-pattern-grid">
{#each SETTINGS_PATTERNS as pattern}
<article class="settings-pattern">
<h3>{pattern.title}</h3>
<p>{pattern.body}</p>
</article>
{/each}
</div>
</section>
{#if !workspace && !workspaceError}
<p class="status-message">Loading workspace summary…</p>
{:else if workspaceError}
<p class="status-message error">Workspace summary unavailable: {workspaceError}</p>
{/if}
</div>
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnosticLabel(diagnostic)}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{/snippet}
</section>

View File

@ -0,0 +1,12 @@
<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>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import { fetchProfileSettings } from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import type { ProfileSettingsResponse } from '$lib/workspace-settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let profileSettings = $state<ProfileSettingsResponse | null>(null);
let loading = $state(true);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
if (!cancelled) {
profileSettings = response;
diagnostics = response.diagnostics;
}
} catch (err) {
if (!cancelled) message = err instanceof Error ? err.message : 'profile settings request failed';
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
</script>
<svelte:head>
<title>Profiles · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="profile-sources-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">read-only</p>
<h2 id="profile-sources-title">Profiles</h2>
</div>
<span class="badge warning">editing pending</span>
</header>
<p>
Review the effective launch profiles and their source files. Editing is intentionally deferred until the Decodal profile source model is settled.
</p>
{#if loading}
<p class="status-message">Loading profiles…</p>
{:else if profileSettings}
<div class="settings-profile-grid">
<article>
<h3>Available profiles</h3>
<p class="settings-note">Profiles that can be selected when launching a Worker.</p>
<ul class="settings-profile-list">
{#each profileSettings.profiles as profile (profile.profile_id)}
<li>
<strong>{profile.selector}</strong>
<span>{profile.label}</span>
<small>{profile.source_kind}{profile.is_default ? ' · default' : ''}</small>
{#if profile.description}<p>{profile.description}</p>{/if}
<DiagnosticsList diagnostics={profile.diagnostics} />
</li>
{/each}
</ul>
</article>
<article>
<h3>Profile source files</h3>
<p class="settings-note">Decodal source files that define or contribute to launch profiles.</p>
<ul class="settings-profile-list">
{#each profileSettings.sources as source (source.profile_source_id)}
<li>
<strong>{source.display_path}</strong>
<span>{source.kind} · {source.size_bytes} bytes</span>
<small>{source.editable ? 'editable later' : 'read-only'} · rev {source.revision}</small>
<DiagnosticsList diagnostics={source.diagnostics} />
</li>
{/each}
</ul>
</article>
</div>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>

View File

@ -0,0 +1,371 @@
<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,
can_spawn_worker: 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>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadataSettings,
updateWorkspaceMetadataSettings
} from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import type { WorkspaceMetadataSettingsResponse } from '$lib/workspace-settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadataSettings(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadataSettings(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="workspace-settings-title">Workspace Identity</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>