ui: move workers list to page

This commit is contained in:
2026-07-10 12:06:10 +09:00
parent a3cb59af43
commit 47e03bd54b
10 changed files with 322 additions and 877 deletions
+49
View File
@@ -349,6 +349,17 @@
margin: 0;
}
.section-heading-link {
color: inherit;
text-decoration: none;
}
.section-heading-link:hover,
.section-heading-link:focus-visible,
.section-heading-link.active {
color: var(--accent);
}
.eyebrow {
color: var(--accent-muted);
margin: 0;
@@ -635,6 +646,44 @@
text-transform: uppercase;
}
.workers-page {
display: grid;
gap: var(--space-4);
}
.workers-page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
}
.workers-page-header h1,
.workers-page-header p {
margin: 0;
}
.workers-page-header h1 {
color: var(--text-strong);
font-size: clamp(1.6rem, 4vw, 2.4rem);
line-height: 1.05;
}
.workers-page-header p {
margin-top: var(--space-2);
color: var(--text-muted);
}
.workers-table-wrap {
border-top: 1px solid var(--line);
}
.workers-table td small {
display: block;
margin-top: 0.2rem;
color: var(--text-muted);
}
.diagnostics {
margin-top: var(--space-4);
}
@@ -9,10 +9,13 @@ 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 lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
);
const workersPage = await Deno.readTextFile(
new URL("./../../routes/w/[workspaceId]/workers/+page.svelte", import.meta.url),
);
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
);
@@ -21,14 +24,21 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
);
assert(
workspacePage.includes("workerConsoleHref(worker, workspaceId)") &&
workspacePage.includes("Open Console"),
"top Worker list should expose an attach action per Worker",
!workspacePage.includes("workerConsoleHref") &&
!workspacePage.includes("Open Console"),
"top workspace page should not own the Worker list",
);
assert(
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
workersPage.includes("<table class=\"workers-table\">") &&
workersPage.includes("Open Console"),
"dedicated Workers page should expose a table and attach action per Worker",
);
assert(
workersNav.includes('href={`/w/${workspaceId}/workers`}') &&
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
workersNav.includes("aria-current"),
"Workers sidebar rows should link to the Worker target Console route",
"Workers sidebar should link to the Worker list page and target Console routes",
);
assert(
!sidebar.includes("CompanionNavSection") &&
@@ -68,7 +68,14 @@
<section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">workers</h2>
<h2 id="workers-heading">
<a
class="section-heading-link"
class:active={currentPath === `/w/${workspaceId}/workers`}
href={`/w/${workspaceId}/workers`}
aria-current={currentPath === `/w/${workspaceId}/workers` ? 'page' : undefined}
>workers</a>
</h2>
<a
class="section-action"
class:active={currentPath === `/w/${workspaceId}/workers/new`}
@@ -1,9 +1,7 @@
<script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
</script>
<svelte:head>
@@ -39,95 +37,48 @@
{/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>
<section 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>
<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>
<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}
</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, workspaceId)}>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>
{:else if data.hostsError}
<p class="error">{data.hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code></p>
{/if}
</section>
@@ -1,19 +1,14 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
import type { Host, ListResponse } from "$lib/workspace-sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const [hosts, workers] = await Promise.all([
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
]);
const hosts = await loadJson<ListResponse<Host>>(fetch, apiPath("/hosts"));
return {
workspaceId: params.workspaceId,
hosts: hosts.data,
hostsError: hosts.error,
workers: workers.data,
workersError: workers.error,
};
};
@@ -0,0 +1,78 @@
<script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { Worker } from '$lib/workspace-sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function workerStatus(worker: Worker): string {
return `${worker.state} · ${worker.status}`;
}
function workerProfile(worker: Worker): string {
return worker.profile ?? worker.role ?? 'unknown';
}
function workerDirectory(worker: Worker): string {
const directory = worker.working_directory;
if (!directory) return '—';
const selector = directory.requested_selector ?? 'HEAD';
const commit = directory.resolved_commit ? directory.resolved_commit.slice(0, 12) : null;
return commit ? `${directory.repository_id} · ${selector} · ${commit}` : `${directory.repository_id} · ${selector}`;
}
</script>
<svelte:head>
<title>Workers · Yoi Workspace</title>
<meta name="description" content="Workspace Workers" />
</svelte:head>
<section class="workers-page" aria-labelledby="workers-heading">
<header class="workers-page-header">
<div>
<h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace.</p>
</div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if data.workers.items.length === 0}
<p class="section-state">No Workers are visible.</p>
{:else}
<div class="table-wrap workers-table-wrap">
<table class="workers-table">
<thead>
<tr>
<th>Worker</th>
<th>Runtime</th>
<th>Profile</th>
<th>Status</th>
<th>Working directory</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each data.workers.items as worker}
<tr>
<td>
<strong>{worker.label}</strong>
<small><code>{worker.worker_id}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
<td>{workerStatus(worker)}</td>
<td>{workerDirectory(worker)}</td>
<td>
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
@@ -0,0 +1,16 @@
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
import type { ListResponse, Worker } from "$lib/workspace-sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const workers = await loadJson<ListResponse<Worker>>(
fetch,
workspaceApiPath(params.workspaceId, "/workers"),
);
return {
workspaceId: params.workspaceId,
workers: workers.data,
workersError: workers.error,
};
};