ui: split workspace settings routes
This commit is contained in:
parent
981f0501a3
commit
292ad2c202
|
|
@ -1 +1,3 @@
|
||||||
default = "builtin:companion"
|
default = "builtin:companion"
|
||||||
|
|
||||||
|
[profile]
|
||||||
|
|
|
||||||
|
|
@ -1446,3 +1446,212 @@
|
||||||
display: block;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import {
|
import {
|
||||||
|
diagnosticLabel,
|
||||||
SETTINGS_PATTERNS,
|
SETTINGS_PATTERNS,
|
||||||
SETTINGS_PERMISSION_NOTICE,
|
SETTINGS_PERMISSION_NOTICE,
|
||||||
SETTINGS_ROUTE,
|
SETTINGS_ROUTE,
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
diagnosticLabel,
|
|
||||||
settingsSectionHref,
|
settingsSectionHref,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
|
|
||||||
|
|
@ -23,12 +23,12 @@ Deno.test("settings section navigation stays under the settings route", () => {
|
||||||
for (const section of SETTINGS_SECTIONS) {
|
for (const section of SETTINGS_SECTIONS) {
|
||||||
const href = settingsSectionHref(section.id);
|
const href = settingsSectionHref(section.id);
|
||||||
assert(
|
assert(
|
||||||
href.startsWith("/settings#"),
|
href.startsWith("/settings"),
|
||||||
`${section.id} should link under settings`,
|
`${section.id} should link under settings`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
href.endsWith(section.id),
|
!href.includes("#"),
|
||||||
`${section.id} href should preserve section id`,
|
`${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) =>
|
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
|
||||||
section.id === "runtime-connections"
|
section.id === "runtime-connections"
|
||||||
);
|
);
|
||||||
assert(runtimeSection?.status === "editable", "Runtime Connections should be editable");
|
assert(
|
||||||
|
runtimeSection?.status === "editable",
|
||||||
|
"Runtime Connections should be editable",
|
||||||
|
);
|
||||||
|
|
||||||
const allText = [
|
const allText = [
|
||||||
SETTINGS_PERMISSION_NOTICE,
|
SETTINGS_PERMISSION_NOTICE,
|
||||||
|
|
@ -61,7 +64,8 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
assert(
|
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",
|
"restart-required pattern should be visible",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -93,7 +97,8 @@ Deno.test("diagnostic labels preserve severity and code", () => {
|
||||||
message: "Restart required.",
|
message: "Restart required.",
|
||||||
} as const;
|
} as const;
|
||||||
assert(
|
assert(
|
||||||
diagnosticLabel(diagnostic) === "warning: runtime_registry_restart_required",
|
diagnosticLabel(diagnostic) ===
|
||||||
|
"warning: runtime_registry_restart_required",
|
||||||
"diagnostic label should be bounded and stable",
|
"diagnostic label should be bounded and stable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,16 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function settingsSectionHref(id: SettingsSectionId): string {
|
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 {
|
export function diagnosticLabel(diagnostic: Diagnostic): string {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http";
|
import {
|
||||||
|
workspaceApiJson,
|
||||||
|
workspaceApiJsonWithBody,
|
||||||
|
} from "../workspace-api/http";
|
||||||
import type {
|
import type {
|
||||||
ProfileSettingsMutationResponse,
|
ProfileSettingsMutationResponse,
|
||||||
ProfileSettingsResponse,
|
ProfileSettingsResponse,
|
||||||
|
|
@ -7,32 +10,51 @@ import type {
|
||||||
WorkspaceProfileSourceDetailResponse,
|
WorkspaceProfileSourceDetailResponse,
|
||||||
} from "./profile-types";
|
} from "./profile-types";
|
||||||
|
|
||||||
export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse> {
|
export function fetchWorkspaceMetadataSettings(
|
||||||
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`);
|
workspaceId: string,
|
||||||
|
): Promise<WorkspaceMetadataSettingsResponse> {
|
||||||
|
return workspaceApiJson(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateWorkspaceMetadataSettings(
|
export function updateWorkspaceMetadataSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
request: { display_name: string; revision: string },
|
request: { display_name: string; revision: string },
|
||||||
): Promise<WorkspaceMetadataMutationResponse> {
|
): Promise<WorkspaceMetadataMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, {
|
return workspaceApiJsonWithBody(
|
||||||
method: "PUT",
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||||
body: JSON.stringify(request),
|
{
|
||||||
});
|
method: "PUT",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> {
|
export function fetchProfileSettings(
|
||||||
return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`);
|
workspaceId: string,
|
||||||
|
): Promise<ProfileSettingsResponse> {
|
||||||
|
return workspaceApiJson(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProfileSource(
|
export function createProfileSource(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
request: { name: string; description?: string; content: string; registry_revision: string },
|
request: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
registry_revision: string;
|
||||||
|
},
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
): Promise<ProfileSettingsMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, {
|
return workspaceApiJsonWithBody(
|
||||||
method: "POST",
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
||||||
body: JSON.stringify(request),
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProfileRegistry(
|
export function updateProfileRegistry(
|
||||||
|
|
@ -40,13 +62,22 @@ export function updateProfileRegistry(
|
||||||
request: {
|
request: {
|
||||||
registry_revision: string;
|
registry_revision: string;
|
||||||
default_profile?: string | null;
|
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> {
|
): Promise<ProfileSettingsMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, {
|
return workspaceApiJsonWithBody(
|
||||||
method: "PUT",
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`,
|
||||||
body: JSON.stringify(request),
|
{
|
||||||
});
|
method: "PUT",
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchProfileSource(
|
export function fetchProfileSource(
|
||||||
|
|
@ -54,7 +85,9 @@ export function fetchProfileSource(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): Promise<WorkspaceProfileSourceDetailResponse> {
|
): Promise<WorkspaceProfileSourceDetailResponse> {
|
||||||
return workspaceApiJson(
|
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 },
|
request: { content: string; revision: string },
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
): Promise<ProfileSettingsMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(
|
return workspaceApiJsonWithBody(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
|
||||||
|
encodeURIComponent(sourceId)
|
||||||
|
}`,
|
||||||
{ method: "PUT", body: JSON.stringify(request) },
|
{ method: "PUT", body: JSON.stringify(request) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +110,9 @@ export function deleteProfileSource(
|
||||||
request: { registry_revision: string; source_revision: string },
|
request: { registry_revision: string; source_revision: string },
|
||||||
): Promise<ProfileSettingsMutationResponse> {
|
): Promise<ProfileSettingsMutationResponse> {
|
||||||
return workspaceApiJsonWithBody(
|
return workspaceApiJsonWithBody(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${
|
||||||
|
encodeURIComponent(sourceId)
|
||||||
|
}`,
|
||||||
{ method: "DELETE", body: JSON.stringify(request) },
|
{ method: "DELETE", body: JSON.stringify(request) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,789 +1,59 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from "./$types";
|
import { workspaceRoute } from '$lib/workspace-api/http';
|
||||||
import { workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
|
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace-settings/model';
|
||||||
import {
|
import type { PageProps } from './$types';
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspace = $derived(data.workspace);
|
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||||
let workspaceError = $derived(data.workspaceError);
|
|
||||||
|
|
||||||
function settingsApiPath(path: string): string {
|
function href(path: string): string {
|
||||||
return workspaceApiPath(workspaceId, path);
|
return workspaceId ? workspaceRoute(workspaceId, path) : 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})`;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<section class="settings-card">
|
||||||
<title>Settings · Yoi Workspace</title>
|
<div class="settings-card-header">
|
||||||
</svelte:head>
|
<div>
|
||||||
|
<p class="eyebrow">Overview</p>
|
||||||
<div class="settings-shell" aria-labelledby="settings-title">
|
<h2>Settings areas</h2>
|
||||||
<section class="hero settings-hero">
|
|
||||||
<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>
|
|
||||||
</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">
|
|
||||||
{#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>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card settings-section" id="runtime-connections" 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>{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>
|
|
||||||
<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.
|
|
||||||
</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>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-grid">
|
||||||
|
{#each SETTINGS_SECTIONS as section}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
|
<section class="settings-card">
|
||||||
<div>
|
<div class="settings-card-header">
|
||||||
<p class="eyebrow">Implementation patterns</p>
|
<div>
|
||||||
<h2 id="settings-patterns-title">How settings should appear</h2>
|
<p class="eyebrow">Backend pattern</p>
|
||||||
</div>
|
<h2>Authority boundaries</h2>
|
||||||
<div class="grid settings-pattern-grid">
|
</div>
|
||||||
{#each SETTINGS_PATTERNS as pattern}
|
</div>
|
||||||
<article class="settings-pattern">
|
<div class="settings-pattern-grid">
|
||||||
<h3>{pattern.title}</h3>
|
<article>
|
||||||
<p>{pattern.body}</p>
|
<h3>Backend-owned state</h3>
|
||||||
</article>
|
<p>
|
||||||
{/each}
|
Workspace metadata and Decodal profile sources are edited through Backend APIs, not direct frontend filesystem access.
|
||||||
</div>
|
</p>
|
||||||
</section>
|
</article>
|
||||||
|
<article>
|
||||||
{#if !workspace && !workspaceError}
|
<h3>Runtime inputs</h3>
|
||||||
<p class="status-message">Loading workspace summary…</p>
|
<p>
|
||||||
{:else if workspaceError}
|
Runtime execution receives explicit resources and WorkingDirectory handles; raw workspace paths stay out of Browser-facing payloads.
|
||||||
<p class="status-message error">Workspace summary unavailable: {workspaceError}</p>
|
</p>
|
||||||
{/if}
|
</article>
|
||||||
</div>
|
<article>
|
||||||
|
<h3>Diagnostics first</h3>
|
||||||
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
|
<p>
|
||||||
{#if diagnostics.length > 0}
|
Settings mutations return typed diagnostics so validation issues are visible without exposing internal paths or credentials.
|
||||||
<ul class="settings-diagnostics-list">
|
</p>
|
||||||
{#each diagnostics as diagnostic}
|
</article>
|
||||||
<li class={diagnostic.severity}>
|
</div>
|
||||||
<strong>{diagnosticLabel(diagnostic)}</strong>
|
</section>
|
||||||
<span>{diagnostic.message}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue
Block a user