From b16372cb784fee4d3a9b887066003e57de249db0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 20:08:17 -0700 Subject: [PATCH] feat(portal): polish lease tables --- CHANGELOG.md | 2 +- docs/features/coordinator.md | 18 ++++--- worker/src/portal.ts | 98 +++++++++++++++++++++++++++--------- worker/test/fleet.test.ts | 19 ++++++- 4 files changed, 103 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 133373e..46623dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - Added `crabbox media preview` for creating motion-trimmed GIF previews and optional trimmed MP4 clips from desktop recordings. - Added `crabbox code` and per-lease `/code/` portal URLs for authenticated code-server access on `--code` Linux leases. - Added per-lease portal detail pages with bridge status, pasteable commands, recent run links, and a stop action. -- Added portal run detail pages with command metadata, result summaries, searchable/paginated portal tables, active/ended lease filters, and copyable retained log previews. +- Added portal run detail pages with command metadata, result summaries, searchable/paginated portal tables, provider/OS badges, active/ended/provider/target filters, sticky portal chrome, and copyable retained log previews. - Added `.crabboxignore` for repo-local sync-only exclude patterns shared by `run` and `sync-plan`. - Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases. diff --git a/docs/features/coordinator.md b/docs/features/coordinator.md index afe2479..c6f9ef4 100644 --- a/docs/features/coordinator.md +++ b/docs/features/coordinator.md @@ -54,20 +54,22 @@ GET /portal/runs/{run-id}/logs GET /portal/runs/{run-id}/events ``` -`/portal` renders a searchable/paginated owner-scoped lease table with -active/ended/all filters. It defaults to active leases when any are active, and -falls back to all visible leases when the active list is empty. +`/portal` renders a searchable/paginated owner-scoped lease table with compact +provider/target badges and active, ended, provider, target, and all filters. It +defaults to active leases when any are active, and falls back to all visible +leases when the active list is empty. `/portal/leases/{id-or-slug}` is the authenticated lease detail page. It shows -the lease state, bridge status, pasteable `ssh`, `run`, WebVNC, and code -commands, searchable/paginated recent run links, and a stop action for the -owner-scoped lease. +the lease state, bridge status, compact provider/target badges, pasteable +`ssh`, `run`, WebVNC, and code commands, searchable/paginated recent run links +with state filters, and a stop action for the owner-scoped lease. Portal run links mirror the `/v1/runs/...` resources but use the browser session cookie, so users can inspect logs and events without copying a bearer token into the browser. The run detail page at `/portal/runs/{run-id}` renders the command, owner, lease, provider metadata, exit status, JUnit summary when -present, a searchable/paginated event table, and a copyable retained log tail; -`/logs` and `/events` remain raw/plain resources for copying and automation. +present, a searchable/paginated event table with event-type filters, and a +copyable retained log tail; `/logs` and `/events` remain raw/plain resources for +copying and automation. GitHub browser-login tokens are owner/org scoped for lease, run, log, and usage routes. Shared-token admin auth is required for `GET /v1/pool`, admin lease routes, and fleet-wide usage/listing. diff --git a/worker/src/portal.ts b/worker/src/portal.ts index e610b34..9916099 100644 --- a/worker/src/portal.ts +++ b/worker/src/portal.ts @@ -32,7 +32,7 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {

leases

${active.length} active / ${ended} ended - +
@@ -100,8 +100,8 @@ export function portalLeaseDetail( ${escapeHTML(lease.state)}
- ${metaRow("provider", lease.provider)} - ${metaRow("target", lease.windowsMode ? `${lease.target} / ${lease.windowsMode}` : lease.target)} + ${metaHTMLRow("provider", providerBadge(lease.provider))} + ${metaHTMLRow("target", targetBadge(lease.target, lease.windowsMode))} ${metaRow("class", lease.class)} ${metaRow("host", lease.host || "pending")} ${metaRow("ssh", lease.sshPort ? `${lease.sshUser || "crabbox"}@${lease.host || "host"}:${lease.sshPort}` : "pending")} @@ -139,7 +139,7 @@ export function portalLeaseDetail(

recent runs

${runs.length} -
lease
+
@@ -204,8 +204,8 @@ export function portalRunDetail(
${metaRow("lease", run.slug ? `${run.slug} / ${run.leaseID}` : run.leaseID)} - ${metaRow("provider", run.provider)} - ${metaRow("target", run.windowsMode ? `${run.target || "linux"} / ${run.windowsMode}` : run.target || "linux")} + ${metaHTMLRow("provider", providerBadge(run.provider))} + ${metaHTMLRow("target", targetBadge(run.target || "linux", run.windowsMode))} ${metaRow("class", run.class)} ${metaRow("server type", run.serverType)} ${metaRow("phase", run.phase || run.state)} @@ -264,7 +264,7 @@ export function portalRunDetail(

events

${events.length} -
run
+
@@ -296,7 +296,7 @@ export function portalVNC(lease: LeaseRecord): Response {

${escapeHTML(slug)}

-

${escapeHTML(lease.provider)}${escapeHTML(lease.target)}${escapeHTML(lease.id)}

+

${providerBadge(lease.provider)}${targetBadge(lease.target, lease.windowsMode)}${escapeHTML(lease.id)}

waiting for bridge @@ -525,6 +525,7 @@ function leaseRow(lease: LeaseRecord): string { const detailPath = `/portal/leases/${encodeURIComponent(lease.id)}`; const active = lease.state === "active"; const filterValue = active ? "active" : "ended"; + const target = lease.target || "linux"; const vnc = active && lease.desktop ? `open` @@ -534,11 +535,11 @@ function leaseRow(lease: LeaseRecord): string { ? `code` : `no code`; const timeLabel = active ? lease.expiresAt : lease.endedAt || lease.releasedAt || lease.updatedAt; - return `
+ return ` - - + + @@ -549,7 +550,7 @@ function leaseRow(lease: LeaseRecord): string { function runRow(run: RunRecord): string { const stateTone = run.state === "succeeded" ? "ok" : run.state === "failed" ? "bad" : "warn"; const logLabel = run.logBytes > 0 ? formatBytes(run.logBytes) : "empty"; - return ` + return ` @@ -561,7 +562,8 @@ function runRow(run: RunRecord): string { } function eventRow(event: RunEventRecord): string { - return ` + const eventGroup = event.type.split(".")[0] || event.type; + return ` @@ -574,6 +576,41 @@ function metaRow(label: string, value: string | undefined): string { return `
${escapeHTML(label)}
${escapeHTML(value || "-")}
`; } +function metaHTMLRow(label: string, value: string): string { + return `
${escapeHTML(label)}
${value}
`; +} + +function providerBadge(provider: string | undefined): string { + const value = provider || "-"; + return `${providerIcon(value)}${escapeHTML(value)}`; +} + +function targetBadge(target: string | undefined, windowsMode?: string): string { + const value = target || "linux"; + const label = windowsMode && value === "windows" ? `${value} / ${windowsMode}` : value; + return `${targetIcon(value)}${escapeHTML(label)}`; +} + +function providerIcon(provider: string): string { + if (provider === "aws") { + return ``; + } + if (provider === "hetzner") { + return ``; + } + return ``; +} + +function targetIcon(target: string): string { + if (target === "windows") { + return ``; + } + if (target === "macos") { + return ``; + } + return ``; +} + function bridgeRow( label: string, enabled: boolean, @@ -631,10 +668,10 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { * { box-sizing: border-box; } html { background:var(--bg); } body { margin:0; min-height:100vh; background:var(--bg); color:var(--fg); font:14px/1.45 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } - main { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:24px 0; } + main { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:10px 0 22px; } h1,h2,p { margin:0; } - h1 { font-size:22px; font-weight:700; } - h2 { font-size:14px; text-transform:uppercase; color:var(--muted); } + h1 { font-size:20px; font-weight:700; } + h2 { font-size:12px; text-transform:uppercase; color:var(--muted); letter-spacing:0.04em; } a { color:inherit; } form { margin:0; } button { font:inherit; } @@ -643,7 +680,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { th,td { padding:12px; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; } th { color:var(--muted); font-weight:600; } td small { display:block; color:var(--muted); margin-top:2px; } - .top { display:flex; justify-content:space-between; gap:16px; align-items:center; margin-bottom:20px; } + .top { position:sticky; top:0; z-index:20; display:flex; justify-content:space-between; gap:14px; align-items:center; margin:0 0 10px; padding:8px 0; background:linear-gradient(180deg, var(--bg) 72%, color-mix(in srgb, var(--bg) 0%, transparent)); backdrop-filter:blur(10px); } + .top p { font-size:13px; } .top p,.muted,.empty { color:var(--muted); } .panel { border:1px solid var(--line); border-radius:8px; background:var(--panel); overflow:hidden; } .section-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); } @@ -679,6 +717,14 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .pill[data-tone="ok"],.pill[data-state="active"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); } .pill[data-tone="warn"] { color:var(--warn); border-color:color-mix(in srgb, var(--warn) 35%, var(--line)); } .pill[data-tone="bad"],.pill[data-state="released"],.pill[data-state="expired"] { color:var(--bad); border-color:color-mix(in srgb, var(--bad) 45%, var(--line)); } + .icon-label { display:inline-flex; align-items:center; gap:7px; min-width:0; } + .icon-label svg { width:16px; height:16px; flex:0 0 16px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; color:#cbd5e1; } + .icon-label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .icon-label[data-provider="aws"] svg { color:#fbbf24; } + .icon-label[data-provider="hetzner"] svg { color:#ef4444; } + .icon-label[data-target="linux"] svg { color:#34d399; } + .icon-label[data-target="windows"] svg { color:#38bdf8; } + .icon-label[data-target="macos"] svg { color:#d8b4fe; } .actions-cell { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } .table-tools { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border-bottom:1px solid var(--line-soft); background:var(--panel-2); } .table-search { flex:1; min-width:180px; max-width:360px; height:32px; padding:0 10px; border:1px solid var(--line); border-radius:8px; background:#0c0e10; color:var(--fg); font:inherit; } @@ -691,14 +737,14 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .table-footer { display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:10px 12px; background:var(--panel-2); } .table-page { min-width:64px; color:var(--muted); font-size:12px; text-align:center; } tr[hidden] { display:none; } - .vnc-page { width:100vw; height:100vh; padding:10px 12px 10px; display:grid; grid-template-rows:auto 1fr auto; gap:10px; } - .vnc-bar { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:44px; padding:0 4px; } + .vnc-page { width:100vw; height:100vh; padding:0 12px 10px; display:grid; grid-template-rows:auto minmax(0,1fr) auto; gap:10px; overflow:auto; } + .vnc-bar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:14px; min-height:42px; margin:0 -12px; padding:6px 16px; border-bottom:1px solid var(--line); background:color-mix(in srgb, var(--panel) 92%, transparent); box-shadow:0 8px 24px rgba(0,0,0,0.25); } .vnc-meta { display:flex; align-items:baseline; gap:12px; min-width:0; } .vnc-meta h1 { font-size:18px; font-weight:700; letter-spacing:-0.01em; white-space:nowrap; } .vnc-meta p { display:inline-flex; align-items:center; gap:8px; color:var(--muted); font-size:12px; min-width:0; overflow:hidden; } .vnc-meta .vnc-id { font-family:var(--mono); font-size:11px; opacity:0.85; } .vnc-meta .vnc-dot { width:3px; height:3px; border-radius:50%; background:#3a4046; flex-shrink:0; } - .vnc-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; } + .vnc-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; } .status-pill { display:inline-flex; align-items:center; gap:8px; height:32px; padding:0 12px 0 11px; border-radius:8px; background:var(--panel-2); border:1px solid var(--line); font-size:12px; color:var(--muted); white-space:nowrap; transition:color 0.2s, border-color 0.2s; } .status-pill::before { content:""; width:8px; height:8px; border-radius:50%; background:currentColor; box-shadow:0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); flex-shrink:0; } .status-pill[data-tone="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); } @@ -731,7 +777,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .table-filter { flex:1; } .table-footer { justify-content:space-between; } .top{align-items:flex-start;} - .vnc-bar { flex-wrap:wrap; gap:8px; min-height:0; padding:4px 0; } + .vnc-bar { flex-wrap:wrap; gap:8px; min-height:0; padding:6px 10px; margin:0 -12px; } .vnc-meta { flex-wrap:wrap; gap:4px 10px; } .vnc-meta p .vnc-id { display:none; } .vnc-actions { gap:6px; } @@ -865,9 +911,13 @@ function portalEnhancementsScript(): string { table.dataset.enhancedIndex = String(index); function apply() { const filtered = dataRows.filter( - (row) => - (selectedFilter === "all" || row.dataset.filterValue === selectedFilter) && - row.textContent.toLowerCase().includes(query), + (row) => { + const tags = (row.dataset.filterTags || row.dataset.filterValue || "").split(/\\s+/); + return ( + (selectedFilter === "all" || tags.includes(selectedFilter)) && + row.textContent.toLowerCase().includes(query) + ); + }, ); const pages = Math.max(1, Math.ceil(filtered.length / pageSize)); page = Math.min(page, pages); diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index 783ce67..8867c89 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -514,8 +514,13 @@ describe("fleet lease identity and idle", () => { ); expect(response.status).toBe(200); const body = await response.text(); - expect(body).toContain('data-filter-buttons="active:active,ended:ended,all:all"'); + expect(body).toContain( + 'data-filter-buttons="active:active,ended:ended,aws:aws,hetzner:hetzner,linux:linux,macos:macos,windows:windows,all:all"', + ); expect(body).toContain('data-filter-default="active"'); + expect(body).toContain('data-provider="hetzner"'); + expect(body).toContain('data-target="linux"'); + expect(body).toContain('data-filter-tags="active hetzner linux"'); expect(body).toContain("blue-lobster"); expect(body).toContain("old-clam"); expect(body).toContain("released"); @@ -636,6 +641,11 @@ describe("fleet lease identity and idle", () => { ); expect(body).toContain("crabbox code --id blue-lobster --open"); expect(body).toContain('data-search-placeholder="search runs"'); + expect(body).toContain( + 'data-filter-buttons="succeeded:succeeded,failed:failed,running:running,all:all"', + ); + expect(body).toContain('data-provider="hetzner"'); + expect(body).toContain('data-target="linux"'); expect(body).toContain("table-search"); expect(body).toContain("/portal/runs/run_000000000001"); expect(body).toContain("/portal/runs/run_000000000001/logs"); @@ -659,6 +669,10 @@ describe("fleet lease identity and idle", () => { expect(runBody).toContain("portal log"); expect(runBody).toContain('data-copy-target="#run-log-tail"'); expect(runBody).toContain('data-search-placeholder="search events"'); + expect(runBody).toContain( + 'data-filter-buttons="run:run,command:command,sync:sync,stdout:stdout,stderr:stderr,all:all"', + ); + expect(runBody).toContain('data-filter-tags="command failed"'); expect(runBody).toContain("table-search"); expect(runBody).toContain("renders detail"); expect(runBody).toContain("/portal/leases/cbx_000000000001"); @@ -828,6 +842,9 @@ describe("fleet lease identity and idle", () => { expect(pageBody).toContain("function scheduleRetry"); expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status"); expect(pageBody).toContain("vnc-copy"); + expect(pageBody).toContain("position:sticky"); + expect(pageBody).toContain('data-provider="hetzner"'); + expect(pageBody).toContain('data-target="linux"'); expect(pageBody).toContain("no bridge connected; run the bridge command below"); expect(pageBody).toContain('fragment.get("username")'); expect(pageBody).toContain('types.includes("username")');
seq
${escapeHTML(label)}${escapeHTML(lease.id)} ${escapeHTML(lease.state)}${escapeHTML(lease.provider)}${escapeHTML(lease.target)}${providerBadge(lease.provider)}${targetBadge(target, lease.windowsMode)} ${escapeHTML(lease.class)}
${vnc}${code}
${escapeHTML(shortTime(timeLabel))}
${escapeHTML(run.id)}${escapeHTML(run.command.join(" "))} ${escapeHTML(run.state)} ${escapeHTML(run.phase || "-")}
${event.seq} ${escapeHTML(event.type)}${escapeHTML(event.stream || "")} ${escapeHTML(event.phase || "-")}