refactor: move workspace pages into routes
This commit is contained in:
parent
4e72bc6be3
commit
5ef8e4e8ba
34
web/workspace/src/lib/workspace-api/http.ts
Normal file
34
web/workspace/src/lib/workspace-api/http.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export type ApiResult<T> = {
|
||||||
|
data: T | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadJson<T>(
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
path: string,
|
||||||
|
): Promise<ApiResult<T>> {
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(path);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: text || `${path} request failed (${response.status})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: (await response.json()) as T, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: error instanceof Error ? error.message : `${path} request failed`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
|
||||||
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
||||||
const workspacePage = await Deno.readTextFile(
|
const workspacePage = await Deno.readTextFile(
|
||||||
new URL("../workspace-pages/WorkspacePage.svelte", import.meta.url),
|
new URL("./../../routes/+page.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const workersNav = await Deno.readTextFile(
|
const workersNav = await Deno.readTextFile(
|
||||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
|
|
|
||||||
|
|
@ -1,512 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
|
||||||
import type {
|
|
||||||
Host,
|
|
||||||
ListResponse,
|
|
||||||
ObjectiveDetail,
|
|
||||||
ObjectiveListResponse,
|
|
||||||
RepositoryDetailResponse,
|
|
||||||
RepositoryListResponse,
|
|
||||||
RepositorySummary,
|
|
||||||
RepositoryTicketsResponse,
|
|
||||||
Worker,
|
|
||||||
WorkspaceResponse
|
|
||||||
} from '$lib/workspace-sidebar/types';
|
|
||||||
|
|
||||||
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
|
|
||||||
|
|
||||||
type RouteState =
|
|
||||||
| { page: 'overview'; objectiveId?: undefined; repositoryId?: undefined }
|
|
||||||
| { page: 'repository'; repositoryId: string; objectiveId?: undefined }
|
|
||||||
| { page: 'objectives'; objectiveId?: undefined; repositoryId?: undefined }
|
|
||||||
| { page: 'objective'; objectiveId: string; repositoryId?: undefined };
|
|
||||||
|
|
||||||
let {
|
|
||||||
view = 'overview',
|
|
||||||
objectiveId = null,
|
|
||||||
repositoryId = null
|
|
||||||
}: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
|
|
||||||
|
|
||||||
let workspace = $state<WorkspaceResponse | null>(null);
|
|
||||||
let hosts = $state<ListResponse<Host> | null>(null);
|
|
||||||
let workers = $state<ListResponse<Worker> | null>(null);
|
|
||||||
let repositories = $state<RepositoryListResponse | null>(null);
|
|
||||||
let repository = $state<RepositorySummary | null>(null);
|
|
||||||
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
|
||||||
let objectives = $state<ObjectiveListResponse | null>(null);
|
|
||||||
let objectiveDetail = $state<ObjectiveDetail | null>(null);
|
|
||||||
|
|
||||||
let workspaceError = $state<string | null>(null);
|
|
||||||
let hostsError = $state<string | null>(null);
|
|
||||||
let workersError = $state<string | null>(null);
|
|
||||||
let repositoriesError = $state<string | null>(null);
|
|
||||||
let repositoryError = $state<string | null>(null);
|
|
||||||
let repositoryTicketsError = $state<string | null>(null);
|
|
||||||
let objectivesError = $state<string | null>(null);
|
|
||||||
let objectiveDetailError = $state<string | null>(null);
|
|
||||||
let objectiveDetailLoading = $state(false);
|
|
||||||
let objectiveDetailRequest = 0;
|
|
||||||
let route = $derived(routeFromView(view, objectiveId, repositoryId));
|
|
||||||
let currentPath = $derived(pathFromRoute(route));
|
|
||||||
|
|
||||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
||||||
const response = await fetch(path, { signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkspace(signal?: AbortSignal) {
|
|
||||||
workspaceError = null;
|
|
||||||
try {
|
|
||||||
workspace = await getJson<WorkspaceResponse>('/api/workspace', signal);
|
|
||||||
} catch (error) {
|
|
||||||
workspaceError = error instanceof Error ? error.message : String(error);
|
|
||||||
workspace = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadHosts(signal?: AbortSignal) {
|
|
||||||
hostsError = null;
|
|
||||||
try {
|
|
||||||
hosts = await getJson<ListResponse<Host>>('/api/hosts', signal);
|
|
||||||
} catch (error) {
|
|
||||||
hostsError = error instanceof Error ? error.message : String(error);
|
|
||||||
hosts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkers(signal?: AbortSignal) {
|
|
||||||
workersError = null;
|
|
||||||
try {
|
|
||||||
workers = await getJson<ListResponse<Worker>>('/api/workers', signal);
|
|
||||||
} catch (error) {
|
|
||||||
workersError = error instanceof Error ? error.message : String(error);
|
|
||||||
workers = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepositories(signal?: AbortSignal) {
|
|
||||||
repositoriesError = null;
|
|
||||||
try {
|
|
||||||
repositories = await getJson<RepositoryListResponse>('/api/repositories', signal);
|
|
||||||
} catch (error) {
|
|
||||||
repositoriesError = error instanceof Error ? error.message : String(error);
|
|
||||||
repositories = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepository(id: string) {
|
|
||||||
repositoryError = null;
|
|
||||||
try {
|
|
||||||
const detail = await getJson<RepositoryDetailResponse>(
|
|
||||||
`/api/repositories/${encodeURIComponent(id)}`
|
|
||||||
);
|
|
||||||
repository = detail.item;
|
|
||||||
} catch (error) {
|
|
||||||
repositoryError = error instanceof Error ? error.message : String(error);
|
|
||||||
repository = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepositoryTickets(id: string) {
|
|
||||||
repositoryTicketsError = null;
|
|
||||||
try {
|
|
||||||
repositoryTickets = await getJson<RepositoryTicketsResponse>(
|
|
||||||
`/api/repositories/${encodeURIComponent(id)}/tickets`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
|
||||||
repositoryTickets = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadObjectives(signal?: AbortSignal) {
|
|
||||||
objectivesError = null;
|
|
||||||
try {
|
|
||||||
objectives = await getJson<ObjectiveListResponse>('/api/objectives', signal);
|
|
||||||
} catch (error) {
|
|
||||||
objectivesError = error instanceof Error ? error.message : String(error);
|
|
||||||
objectives = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadObjectiveDetail(id: string) {
|
|
||||||
const request = ++objectiveDetailRequest;
|
|
||||||
objectiveDetailLoading = true;
|
|
||||||
objectiveDetailError = null;
|
|
||||||
objectiveDetail = null;
|
|
||||||
try {
|
|
||||||
const detail = await getJson<ObjectiveDetail>(`/api/objectives/${encodeURIComponent(id)}`);
|
|
||||||
if (request === objectiveDetailRequest) {
|
|
||||||
objectiveDetail = detail;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (request === objectiveDetailRequest) {
|
|
||||||
objectiveDetailError = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (request === objectiveDetailRequest) {
|
|
||||||
objectiveDetailLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeFromView(
|
|
||||||
view: WorkspaceView,
|
|
||||||
objectiveId: string | null,
|
|
||||||
repositoryId: string | null
|
|
||||||
): RouteState {
|
|
||||||
if (view === 'repository' && repositoryId) {
|
|
||||||
return { page: 'repository', repositoryId };
|
|
||||||
}
|
|
||||||
if (view === 'objective' && objectiveId) {
|
|
||||||
return { page: 'objective', objectiveId };
|
|
||||||
}
|
|
||||||
if (view === 'objectives') {
|
|
||||||
return { page: 'objectives' };
|
|
||||||
}
|
|
||||||
return { page: 'overview' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathFromRoute(route: RouteState): string {
|
|
||||||
if (route.page === 'repository') {
|
|
||||||
return `/repositories/${route.repositoryId}`;
|
|
||||||
}
|
|
||||||
if (route.page === 'objective') {
|
|
||||||
return `/objectives/${route.objectiveId}`;
|
|
||||||
}
|
|
||||||
if (route.page === 'objectives') {
|
|
||||||
return '/objectives';
|
|
||||||
}
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
return value ?? 'not recorded';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
void loadWorkspace(controller.signal);
|
|
||||||
void loadHosts(controller.signal);
|
|
||||||
void loadWorkers(controller.signal);
|
|
||||||
void loadRepositories(controller.signal);
|
|
||||||
void loadObjectives(controller.signal);
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (route.page === 'repository') {
|
|
||||||
void loadRepository(route.repositoryId);
|
|
||||||
void loadRepositoryTickets(route.repositoryId);
|
|
||||||
} else {
|
|
||||||
repository = null;
|
|
||||||
repositoryTickets = null;
|
|
||||||
repositoryError = null;
|
|
||||||
repositoryTicketsError = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
|
|
||||||
if (selectedObjectiveId) {
|
|
||||||
void loadObjectiveDetail(selectedObjectiveId);
|
|
||||||
} else {
|
|
||||||
objectiveDetailRequest += 1;
|
|
||||||
objectiveDetail = null;
|
|
||||||
objectiveDetailError = null;
|
|
||||||
objectiveDetailLoading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Yoi Workspace Control Plane</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Local single-workspace Yoi control plane bootstrap"
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="workspace-layout">
|
|
||||||
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
|
|
||||||
|
|
||||||
<main class="shell">
|
|
||||||
{#if route.page === 'repository'}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Repository summary</h2>
|
|
||||||
{#if repository}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{repository.id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Kind</dt>
|
|
||||||
<dd>{repository.kind}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Provider</dt>
|
|
||||||
<dd>{repository.provider}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Default selector</dt>
|
|
||||||
<dd>{repository.default_selector ?? 'none configured'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>{repository.record_authority}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Git</dt>
|
|
||||||
<dd>{repository.git?.status ?? 'not available'}</dd>
|
|
||||||
</div>
|
|
||||||
{#if repository.diagnostics && repository.diagnostics.length > 0}
|
|
||||||
<div>
|
|
||||||
<dt>Diagnostics</dt>
|
|
||||||
<dd>
|
|
||||||
<ul>
|
|
||||||
{#each repository.diagnostics as diagnostic}
|
|
||||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</dl>
|
|
||||||
{:else if repositoryError}
|
|
||||||
<p class="error">{repositoryError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Repository Ticket Kanban</h2>
|
|
||||||
<p class="section-note">
|
|
||||||
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
|
||||||
</p>
|
|
||||||
{#if repositoryTickets}
|
|
||||||
<RepositoryTicketKanban tickets={repositoryTickets} />
|
|
||||||
{:else if repositoryTicketsError}
|
|
||||||
<p class="error">{repositoryTicketsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{:else if route.page === 'objectives' || route.page === 'objective'}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Objectives</h2>
|
|
||||||
<p class="section-note">
|
|
||||||
Objectives are read from canonical filesystem records through <code>/api/objectives</code>.
|
|
||||||
</p>
|
|
||||||
{#if objectives}
|
|
||||||
{#if objectives.items.length === 0}
|
|
||||||
<p>No Objective records are present.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="objective-list">
|
|
||||||
{#each objectives.items as objective (objective.id)}
|
|
||||||
<a class="objective-row" class:selected={route.page === 'objective' && route.objectiveId === objective.id} href={`/objectives/${objective.id}`}>
|
|
||||||
<div class="objective-main">
|
|
||||||
<div class="objective-title-row">
|
|
||||||
<strong class="objective-title">{objective.title}</strong>
|
|
||||||
<span class="state-pill">{objective.state}</span>
|
|
||||||
</div>
|
|
||||||
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
|
||||||
</div>
|
|
||||||
<div class="objective-meta" aria-label="Objective metadata">
|
|
||||||
<span>Updated {formatDate(objective.updated_at)}</span>
|
|
||||||
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
|
||||||
<code>{objective.id}</code>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if objectives.invalid_records.length > 0}
|
|
||||||
<p class="error">{objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if objectivesError}
|
|
||||||
<p class="error">{objectivesError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/objectives</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if route.page === 'objective'}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Objective detail</h2>
|
|
||||||
{#if objectiveDetail}
|
|
||||||
<div class="detail-heading">
|
|
||||||
<h3>{objectiveDetail.title}</h3>
|
|
||||||
<span>{objectiveDetail.state}</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{objectiveDetail.id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>{formatDate(objectiveDetail.updated_at)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>{formatDate(objectiveDetail.created_at)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Linked tickets</dt>
|
|
||||||
<dd>{objectiveDetail.linked_tickets.length ? objectiveDetail.linked_tickets.join(', ') : 'none'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Source</dt>
|
|
||||||
<dd>{objectiveDetail.record_source}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{#if objectiveDetail.body_truncated}
|
|
||||||
<p class="section-note">Objective body was truncated by the backend response limit.</p>
|
|
||||||
{/if}
|
|
||||||
<pre class="record-body">{objectiveDetail.body || 'No Objective body text is available.'}</pre>
|
|
||||||
{:else if objectiveDetailError}
|
|
||||||
<p class="error">{objectiveDetailError}</p>
|
|
||||||
{:else if objectiveDetailLoading}
|
|
||||||
<p>Loading Objective <code>{route.objectiveId}</code>…</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for Objective detail…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Workspace</h2>
|
|
||||||
{#if workspace}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd>{workspace.workspace_id}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Name</dt>
|
|
||||||
<dd>{workspace.display_name}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>{workspace.record_authority}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Host / Worker bridge</dt>
|
|
||||||
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{:else if workspaceError}
|
|
||||||
<p class="error">{workspaceError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
<section class="grid runtime">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Hosts</h2>
|
|
||||||
{#if hosts}
|
|
||||||
{#if hosts.items.length === 0}
|
|
||||||
<p>No local Hosts are visible.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="stack">
|
|
||||||
{#each hosts.items as host}
|
|
||||||
<article class="runtime-card">
|
|
||||||
<div class="runtime-heading">
|
|
||||||
<strong>{host.label}</strong>
|
|
||||||
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
|
||||||
</div>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{host.host_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Kind</dt>
|
|
||||||
<dd>{host.kind}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Runtime</dt>
|
|
||||||
<dd><code>{host.runtime_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Scope</dt>
|
|
||||||
<dd>{host.capabilities.workspace_scope}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Platform</dt>
|
|
||||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if hostsError}
|
|
||||||
<p class="error">{hostsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Workers</h2>
|
|
||||||
{#if workers}
|
|
||||||
{#if workers.items.length === 0}
|
|
||||||
<p>No local Workers are visible.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Worker</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th>Workspace</th>
|
|
||||||
<th>Implementation</th>
|
|
||||||
<th>Attach</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each workers.items as worker}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>{worker.label}</strong>
|
|
||||||
{#if worker.role || worker.profile}
|
|
||||||
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td><code>{worker.host_id}</code></td>
|
|
||||||
<td>{worker.state} · {worker.status}</td>
|
|
||||||
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
|
||||||
<td>{worker.implementation.kind}</td>
|
|
||||||
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if workersError}
|
|
||||||
<p class="error">{workersError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/workers</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,505 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import WorkspaceSidebar from "$lib/workspace-sidebar/WorkspaceSidebar.svelte";
|
|
||||||
import type { WorkspaceResponse } from "$lib/workspace-sidebar/types";
|
|
||||||
import {
|
|
||||||
SETTINGS_PATTERNS,
|
|
||||||
SETTINGS_PERMISSION_NOTICE,
|
|
||||||
SETTINGS_SECTIONS,
|
|
||||||
diagnosticLabel,
|
|
||||||
settingsSectionHref,
|
|
||||||
type Diagnostic,
|
|
||||||
type RemoteRuntimeConnectionSummary,
|
|
||||||
type RemoteRuntimeTestResponse,
|
|
||||||
type RuntimeConnectionMutationResponse,
|
|
||||||
type RuntimeConnectionSettingsResponse,
|
|
||||||
} from "./model";
|
|
||||||
|
|
||||||
type RemoteAddForm = {
|
|
||||||
runtime_id: string;
|
|
||||||
display_name: string;
|
|
||||||
endpoint: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let workspace = $state<WorkspaceResponse | null>(null);
|
|
||||||
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let runtimeLoading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
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: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function loadWorkspace() {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/workspace");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`workspace request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as WorkspaceResponse;
|
|
||||||
if (!cancelled) {
|
|
||||||
workspace = data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
error = err instanceof Error ? err.message : "workspace request failed";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRuntimeSettings() {
|
|
||||||
runtimeLoading = true;
|
|
||||||
runtimeError = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadWorkspace();
|
|
||||||
loadRuntimeSettings();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submitRemoteRuntime() {
|
|
||||||
submitting = true;
|
|
||||||
mutationMessage = null;
|
|
||||||
mutationDiagnostics = [];
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/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(`/api/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(`/api/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>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Settings · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="workspace-layout">
|
|
||||||
<WorkspaceSidebar workspace={workspace} currentPath="/settings" />
|
|
||||||
|
|
||||||
<main class="shell settings-shell" aria-labelledby="settings-title">
|
|
||||||
<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={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>
|
|
||||||
|
|
||||||
<div class="grid settings-grid">
|
|
||||||
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") 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 loading}
|
|
||||||
<p class="status-message">Loading workspace summary…</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="status-message error">Workspace summary unavailable: {error}</p>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</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}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
|
|
@ -16,43 +15,10 @@
|
||||||
let {
|
let {
|
||||||
workspace,
|
workspace,
|
||||||
workspaceError = null,
|
workspaceError = null,
|
||||||
repositories,
|
repositories = null,
|
||||||
repositoriesError,
|
repositoriesError = null,
|
||||||
currentPath = '/'
|
currentPath = '/'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let fallbackRepositories = $state<RepositoryListResponse | null>(null);
|
|
||||||
let fallbackRepositoriesError = $state<string | null>(null);
|
|
||||||
let displayedRepositories = $derived(repositories === undefined ? fallbackRepositories : repositories);
|
|
||||||
let displayedRepositoriesError = $derived(
|
|
||||||
repositoriesError === undefined ? fallbackRepositoriesError : repositoriesError
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (repositories !== undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
|
||||||
void loadFallbackRepositories(controller.signal);
|
|
||||||
return () => controller.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadFallbackRepositories(signal?: AbortSignal) {
|
|
||||||
fallbackRepositoriesError = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/repositories', { signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`repositories request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
fallbackRepositories = (await response.json()) as RepositoryListResponse;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fallbackRepositoriesError = error instanceof Error ? error.message : 'repositories request failed';
|
|
||||||
fallbackRepositories = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="workspace-sidebar" aria-label="Workspace navigation">
|
<aside class="workspace-sidebar" aria-label="Workspace navigation">
|
||||||
|
|
@ -82,11 +48,7 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection
|
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
|
||||||
repositories={displayedRepositories}
|
|
||||||
repositoriesError={displayedRepositoriesError}
|
|
||||||
{currentPath}
|
|
||||||
/>
|
|
||||||
<ObjectivesNavSection {currentPath} />
|
<ObjectivesNavSection {currentPath} />
|
||||||
<WorkersNavSection {currentPath} />
|
<WorkersNavSection {currentPath} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { children } = $props();
|
let { data, children }: LayoutProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
<div class="workspace-layout">
|
||||||
|
<WorkspaceSidebar
|
||||||
|
workspace={data.workspace}
|
||||||
|
workspaceError={data.workspaceError}
|
||||||
|
repositories={data.repositories}
|
||||||
|
repositoriesError={data.repositoriesError}
|
||||||
|
currentPath={page.url.pathname}
|
||||||
|
/>
|
||||||
|
<main class="shell">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,23 @@
|
||||||
|
import { loadJson } from "$lib/workspace-api/http";
|
||||||
|
import type {
|
||||||
|
RepositoryListResponse,
|
||||||
|
WorkspaceResponse,
|
||||||
|
} from "$lib/workspace-sidebar/types";
|
||||||
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ fetch }) => {
|
||||||
|
const [workspace, repositories] = await Promise.all([
|
||||||
|
loadJson<WorkspaceResponse>(fetch, "/api/workspace"),
|
||||||
|
loadJson<RepositoryListResponse>(fetch, "/api/repositories"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: workspace.data,
|
||||||
|
workspaceError: workspace.error,
|
||||||
|
repositories: repositories.data,
|
||||||
|
repositoriesError: repositories.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,132 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspacePage view="overview" />
|
<svelte:head>
|
||||||
|
<title>Yoi Workspace Control Plane</title>
|
||||||
|
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Workspace</h2>
|
||||||
|
{#if data.workspace}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd>{data.workspace.workspace_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{data.workspace.display_name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{data.workspace.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Host / Worker bridge</dt>
|
||||||
|
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{:else if data.workspaceError}
|
||||||
|
<p class="error">{data.workspaceError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid runtime">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hosts</h2>
|
||||||
|
{#if data.hosts}
|
||||||
|
{#if data.hosts.items.length === 0}
|
||||||
|
<p>No local Hosts are visible.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stack">
|
||||||
|
{#each data.hosts.items as host}
|
||||||
|
<article class="runtime-card">
|
||||||
|
<div class="runtime-heading">
|
||||||
|
<strong>{host.label}</strong>
|
||||||
|
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd><code>{host.host_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Kind</dt>
|
||||||
|
<dd>{host.kind}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Runtime</dt>
|
||||||
|
<dd><code>{host.runtime_id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Scope</dt>
|
||||||
|
<dd>{host.capabilities.workspace_scope}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Platform</dt>
|
||||||
|
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if data.hostsError}
|
||||||
|
<p class="error">{data.hostsError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Workers</h2>
|
||||||
|
{#if data.workers}
|
||||||
|
{#if data.workers.items.length === 0}
|
||||||
|
<p>No local Workers are visible.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Worker</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Workspace</th>
|
||||||
|
<th>Implementation</th>
|
||||||
|
<th>Attach</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.workers.items as worker}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{worker.label}</strong>
|
||||||
|
{#if worker.role || worker.profile}
|
||||||
|
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td><code>{worker.host_id}</code></td>
|
||||||
|
<td>{worker.state} · {worker.status}</td>
|
||||||
|
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
|
||||||
|
<td>{worker.implementation.kind}</td>
|
||||||
|
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if data.workersError}
|
||||||
|
<p class="error">{data.workersError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/workers</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
|
||||||
17
web/workspace/src/routes/+page.ts
Normal file
17
web/workspace/src/routes/+page.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { loadJson } from "$lib/workspace-api/http";
|
||||||
|
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
|
const [hosts, workers] = await Promise.all([
|
||||||
|
loadJson<ListResponse<Host>>(fetch, "/api/hosts"),
|
||||||
|
loadJson<ListResponse<Worker>>(fetch, "/api/workers"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts: hosts.data,
|
||||||
|
hostsError: hosts.error,
|
||||||
|
workers: workers.data,
|
||||||
|
workersError: workers.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,46 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
import { formatDate } from '$lib/workspace-api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspacePage view="objectives" />
|
<svelte:head>
|
||||||
|
<title>Objectives · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objectives</h2>
|
||||||
|
<p class="section-note">Objectives are read from canonical filesystem records through <code>/api/objectives</code>.</p>
|
||||||
|
{#if data.objectives}
|
||||||
|
{#if data.objectives.items.length === 0}
|
||||||
|
<p>No Objective records are present.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="objective-list">
|
||||||
|
{#each data.objectives.items as objective (objective.id)}
|
||||||
|
<a class="objective-row" href={`/objectives/${objective.id}`}>
|
||||||
|
<div class="objective-main">
|
||||||
|
<div class="objective-title-row">
|
||||||
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
|
<span class="state-pill">{objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
|
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||||
|
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
||||||
|
<code>{objective.id}</code>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.objectives.invalid_records.length > 0}
|
||||||
|
<p class="error">{data.objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if data.objectivesError}
|
||||||
|
<p class="error">{data.objectivesError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/objectives</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
|
||||||
15
web/workspace/src/routes/objectives/+page.ts
Normal file
15
web/workspace/src/routes/objectives/+page.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { loadJson } from "$lib/workspace-api/http";
|
||||||
|
import type { ObjectiveListResponse } from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
|
const objectives = await loadJson<ObjectiveListResponse>(
|
||||||
|
fetch,
|
||||||
|
"/api/objectives",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objectives: objectives.data,
|
||||||
|
objectivesError: objectives.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,76 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
import { formatDate } from '$lib/workspace-api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: { data: { objectiveId: string } } = $props();
|
let { data }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspacePage view="objective" objectiveId={data.objectiveId} />
|
<svelte:head>
|
||||||
|
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objectives</h2>
|
||||||
|
{#if data.objectives}
|
||||||
|
<div class="objective-list compact">
|
||||||
|
{#each data.objectives.items as objective (objective.id)}
|
||||||
|
<a class="objective-row" class:active={objective.id === data.objectiveId} href={`/objectives/${objective.id}`}>
|
||||||
|
<div class="objective-main">
|
||||||
|
<div class="objective-title-row">
|
||||||
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
|
<span class="state-pill">{objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
|
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||||
|
<code>{objective.id}</code>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if data.objectivesError}
|
||||||
|
<p class="error">{data.objectivesError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading objectives…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card objective-detail-card">
|
||||||
|
<h2>Objective detail</h2>
|
||||||
|
{#if data.objective}
|
||||||
|
<div class="objective-title-row detail">
|
||||||
|
<div>
|
||||||
|
<h3>{data.objective.title}</h3>
|
||||||
|
<p><code>{data.objective.id}</code></p>
|
||||||
|
</div>
|
||||||
|
<span class="state-pill">{data.objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{data.objective.created_at ? formatDate(data.objective.created_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>{data.objective.updated_at ? formatDate(data.objective.updated_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record source</dt>
|
||||||
|
<dd>{data.objective.record_source}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Linked tickets</dt>
|
||||||
|
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<pre class="objective-body">{data.objective.body}</pre>
|
||||||
|
{#if data.objective.body_truncated}
|
||||||
|
<p class="error">Objective body was truncated by the Backend response limit.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if data.objectiveError}
|
||||||
|
<p class="error">{data.objectiveError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading objective detail…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,25 @@
|
||||||
import type { PageLoad } from './$types';
|
import { loadJson } from "$lib/workspace-api/http";
|
||||||
|
import type {
|
||||||
|
ObjectiveDetail,
|
||||||
|
ObjectiveListResponse,
|
||||||
|
} from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => ({
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
objectiveId: params.objectiveId
|
const objectiveId = params.objectiveId;
|
||||||
});
|
const [objectives, objective] = await Promise.all([
|
||||||
|
loadJson<ObjectiveListResponse>(fetch, "/api/objectives"),
|
||||||
|
loadJson<ObjectiveDetail>(
|
||||||
|
fetch,
|
||||||
|
`/api/objectives/${encodeURIComponent(objectiveId)}`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objectiveId,
|
||||||
|
objectives: objectives.data,
|
||||||
|
objectivesError: objectives.error,
|
||||||
|
objective: objective.data,
|
||||||
|
objectiveError: objective.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,105 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { formatDate } from '$lib/workspace-api/http';
|
||||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspacePage view="repository" repositoryId={page.params.repositoryId} />
|
<svelte:head>
|
||||||
|
<title>{data.repository?.item.display_name ?? data.repositoryId} · Repository</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card repository-detail-card">
|
||||||
|
<h2>Repository</h2>
|
||||||
|
{#if data.repository}
|
||||||
|
<div class="repository-detail-heading">
|
||||||
|
<div>
|
||||||
|
<h3>{data.repository.item.display_name}</h3>
|
||||||
|
<p><code>{data.repository.item.id}</code></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Kind</dt>
|
||||||
|
<dd>{data.repository.item.kind}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Provider</dt>
|
||||||
|
<dd>{data.repository.item.provider}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{data.repository.item.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Default selector</dt>
|
||||||
|
<dd>{data.repository.item.default_selector ?? 'none configured'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Branch</dt>
|
||||||
|
<dd>{data.repository.item.git?.branch ?? 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>HEAD</dt>
|
||||||
|
<dd><code>{data.repository.item.git?.head ?? 'unknown'}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Dirty</dt>
|
||||||
|
<dd>{data.repository.item.git?.dirty ? 'yes' : 'no'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if data.repository.item.diagnostics && data.repository.item.diagnostics.length > 0}
|
||||||
|
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||||
|
{#each data.repository.item.diagnostics as diagnostic}
|
||||||
|
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if data.repositoryError}
|
||||||
|
<p class="error">{data.repositoryError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card repository-log-card">
|
||||||
|
<h2>Recent commits</h2>
|
||||||
|
{#if data.repositoryLog}
|
||||||
|
{#if data.repositoryLog.items.length === 0}
|
||||||
|
<p>No recent commits are available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="commit-list">
|
||||||
|
{#each data.repositoryLog.items as commit}
|
||||||
|
<article class="commit-card">
|
||||||
|
<strong>{commit.summary}</strong>
|
||||||
|
<span><code>{commit.short_hash}</code> · {commit.author_name} · {formatDate(commit.author_date)}</span>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.repositoryLog.diagnostics.length > 0}
|
||||||
|
<ul class="diagnostics" aria-label="Repository log diagnostics">
|
||||||
|
{#each data.repositoryLog.diagnostics as diagnostic}
|
||||||
|
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if data.repositoryLogError}
|
||||||
|
<p class="error">{data.repositoryLogError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository commits…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card repository-tickets-card">
|
||||||
|
<h2>Repository Tickets</h2>
|
||||||
|
{#if data.repositoryTickets}
|
||||||
|
<RepositoryTicketKanban tickets={data.repositoryTickets} />
|
||||||
|
{:else if data.repositoryTicketsError}
|
||||||
|
<p class="error">{data.repositoryTicketsError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Loading repository tickets…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { loadJson } from "$lib/workspace-api/http";
|
||||||
|
import type {
|
||||||
|
RepositoryDetailResponse,
|
||||||
|
RepositoryLogResponse,
|
||||||
|
RepositoryTicketsResponse,
|
||||||
|
} from "$lib/workspace-sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const repositoryId = params.repositoryId;
|
||||||
|
const [repository, log, tickets] = await Promise.all([
|
||||||
|
loadJson<RepositoryDetailResponse>(
|
||||||
|
fetch,
|
||||||
|
`/api/repositories/${encodeURIComponent(repositoryId)}`,
|
||||||
|
),
|
||||||
|
loadJson<RepositoryLogResponse>(
|
||||||
|
fetch,
|
||||||
|
`/api/repositories/${encodeURIComponent(repositoryId)}/log`,
|
||||||
|
),
|
||||||
|
loadJson<RepositoryTicketsResponse>(
|
||||||
|
fetch,
|
||||||
|
`/api/repositories/${encodeURIComponent(repositoryId)}/tickets`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositoryId,
|
||||||
|
repository: repository.data,
|
||||||
|
repositoryError: repository.error,
|
||||||
|
repositoryLog: log.data,
|
||||||
|
repositoryLogError: log.error,
|
||||||
|
repositoryTickets: tickets.data,
|
||||||
|
repositoryTicketsError: tickets.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
|
||||||
import {
|
import {
|
||||||
projectConsole,
|
projectConsole,
|
||||||
workerConsolePath,
|
|
||||||
type ConsoleLine
|
type ConsoleLine
|
||||||
} from '$lib/workspace-console/model';
|
} from '$lib/workspace-console/model';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -10,8 +8,7 @@
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
Worker,
|
Worker,
|
||||||
WorkerInputResult,
|
WorkerInputResult,
|
||||||
WorkerTranscriptProjection,
|
WorkerTranscriptProjection
|
||||||
WorkspaceResponse
|
|
||||||
} from '$lib/workspace-sidebar/types';
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -25,10 +22,7 @@
|
||||||
|
|
||||||
const runtimeId = $derived(data.runtimeId);
|
const runtimeId = $derived(data.runtimeId);
|
||||||
const workerId = $derived(data.workerId);
|
const workerId = $derived(data.workerId);
|
||||||
const currentPath = $derived(workerConsolePath(runtimeId, workerId));
|
|
||||||
|
|
||||||
let workspace = $state<WorkspaceResponse | null>(null);
|
|
||||||
let workspaceError = $state<string | null>(null);
|
|
||||||
let worker = $state<Worker | null>(null);
|
let worker = $state<Worker | null>(null);
|
||||||
let workerError = $state<string | null>(null);
|
let workerError = $state<string | null>(null);
|
||||||
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
||||||
|
|
@ -95,16 +89,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWorkspace() {
|
|
||||||
workspaceError = null;
|
|
||||||
try {
|
|
||||||
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
|
||||||
} catch (error) {
|
|
||||||
workspaceError = error instanceof Error ? error.message : String(error);
|
|
||||||
workspace = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorker(target: ConsoleTarget) {
|
async function loadWorker(target: ConsoleTarget) {
|
||||||
workerError = null;
|
workerError = null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -253,10 +237,6 @@
|
||||||
return line.error ? 'error' : line.kind;
|
return line.error ? 'error' : line.kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void loadWorkspace();
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const target = consoleTarget;
|
const target = consoleTarget;
|
||||||
observedEvents = [];
|
observedEvents = [];
|
||||||
|
|
@ -273,10 +253,7 @@
|
||||||
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="workspace-layout">
|
<div class="console-shell worker-console-shell">
|
||||||
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
|
||||||
|
|
||||||
<main class="shell console-shell worker-console-shell">
|
|
||||||
<section class="console-header card">
|
<section class="console-header card">
|
||||||
<div>
|
<div>
|
||||||
<h2>{worker?.label ?? workerId}</h2>
|
<h2>{worker?.label ?? workerId}</h2>
|
||||||
|
|
@ -413,5 +390,4 @@
|
||||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,476 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SettingsPage from "$lib/workspace-settings/SettingsPage.svelte";
|
import type { PageProps } from "./$types";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type RemoteAddForm = {
|
||||||
|
runtime_id: string;
|
||||||
|
display_name: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspace = $derived(data.workspace);
|
||||||
|
let workspaceError = $derived(data.workspaceError);
|
||||||
|
|
||||||
|
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: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadRuntimeSettings() {
|
||||||
|
runtimeLoading = true;
|
||||||
|
runtimeError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/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;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitRemoteRuntime() {
|
||||||
|
submitting = true;
|
||||||
|
mutationMessage = null;
|
||||||
|
mutationDiagnostics = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/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(`/api/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(`/api/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>
|
||||||
|
|
||||||
<SettingsPage />
|
<svelte:head>
|
||||||
|
<title>Settings · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="settings-shell" aria-labelledby="settings-title">
|
||||||
|
<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={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>
|
||||||
|
|
||||||
|
<div class="grid settings-grid">
|
||||||
|
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") 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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user