diff --git a/CHANGELOG.md b/CHANGELOG.md index 400b270..ca58d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Changed +- Changed the portal lease table to merge external Blacksmith Testbox runners into the main grid as muted, disabled rows instead of rendering a separate external-runners table. - Refactored built-in provider backend implementations into `internal/providers/` packages while keeping command orchestration and rendering core-owned. ### Fixed diff --git a/README.md b/README.md index 3ed8996..f8baa0b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th - **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type. - **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML. - **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets, and QA systems such as Mantis own scenario logic, screenshots, and PR evidence. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host. -- **Authenticated web portal.** Browser login opens owner-scoped lease, run, and external-runner views with searchable, paginated tables, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters. +- **Authenticated web portal.** Browser login opens owner-scoped lease and run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters. - **Hardened coordinator auth.** GitHub browser login, owner-scoped leases, admin-only routes, optional GitHub team allowlists, Cloudflare Access JWT verification, and service-token support keep normal use and operator automation separate. - **OpenClaw plugin.** The repo root is a native OpenClaw plugin for box lifecycle operations: `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, and `crabbox_stop`. Run inspection stays in the CLI and Crabbox skill. - **Operator surface.** `doctor`, `init`, `status`, `inspect`, `list`, `usage`, `history`, `logs`, `results`, `cache`, `admin`, `cleanup`, plus `--json` output where it matters. @@ -157,9 +157,9 @@ blacksmith: idleTimeout: 90m ``` -`crabbox list --provider blacksmith-testbox` also refreshes the portal's -external runner table from the current all-status Testbox list when coordinator -auth is configured. Those rows are +`crabbox list --provider blacksmith-testbox` also refreshes muted external +runner rows in the portal lease table from the current all-status Testbox list +when coordinator auth is configured. Those rows are visibility-only records for Blacksmith-owned Testboxes, not Crabbox leases. Optional Daytona sandbox: diff --git a/docs/commands/list.md b/docs/commands/list.md index ee94ea4..1662931 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -20,10 +20,10 @@ In `blacksmith-testbox` mode this reads `blacksmith testbox list` and renders th same Crabbox list shape as other providers. `--json` keeps the compatibility shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref, and created time when the upstream table exposes those columns. -When coordinator auth is configured, the same list command also refreshes the -portal's owner-scoped external runner table from the current all-status -Blacksmith list. Missing runners from later syncs are marked stale rather than -treated as Crabbox leases. +When coordinator auth is configured, the same list command also refreshes +owner-scoped external runner rows in the portal lease table from the current +all-status Blacksmith list. Missing runners from later syncs are marked stale +rather than treated as Crabbox leases. In `daytona` and `islo` modes, rendering is core-owned: human output and `--json` use the normalized Crabbox lease view. diff --git a/docs/features/blacksmith-testbox.md b/docs/features/blacksmith-testbox.md index cc557c8..bcbfcc8 100644 --- a/docs/features/blacksmith-testbox.md +++ b/docs/features/blacksmith-testbox.md @@ -97,10 +97,10 @@ native JSON output, Crabbox should switch to that and drop table parsing. When coordinator auth is configured, `crabbox list --provider blacksmith-testbox` also performs a best-effort sync of the current all-status Blacksmith list into -the portal's external runner table. Those rows are owner-scoped visibility -records for Blacksmith-owned Testboxes. They are not Crabbox leases, do not -heartbeat, do not participate in Crabbox expiry or cost control, and become -stale when a later sync does not see the runner. +the portal lease table. Those muted rows are owner-scoped visibility records for +Blacksmith-owned Testboxes. They are not Crabbox leases, do not expose access +actions, do not heartbeat, do not participate in Crabbox expiry or cost control, +and become stale when a later sync does not see the runner. ## Auth diff --git a/docs/features/coordinator.md b/docs/features/coordinator.md index 1c6b880..3f96df0 100644 --- a/docs/features/coordinator.md +++ b/docs/features/coordinator.md @@ -64,10 +64,10 @@ can also see non-owned runner leases, with `mine` and `system` filters so Blacksmith/Testbox-style coordinator leases are visible without leaking them to normal users. It defaults to active leases when any are active, and falls back to all visible leases when the active list is empty. External runner rows, currently -Blacksmith Testboxes synced from the CLI's current all-status list, render in a -second owner-scoped table -with search, pagination, status/provider filters, and stale markers when the -next sync no longer sees a previously visible runner. +Blacksmith Testboxes synced from the CLI's current all-status list, render in the +same grid as muted, disabled rows with search, pagination, status/provider +filters, and stale markers when the next sync no longer sees a previously +visible runner. `/portal/leases/{id-or-slug}` is the authenticated lease detail page. It shows the lease state, bridge status, compact provider/target badges, latest Linux diff --git a/docs/orchestrator.md b/docs/orchestrator.md index 81a2f6c..b835afd 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -49,9 +49,9 @@ Direct-provider mode does not have a central heartbeat or alarm. It labels machi Delegated external runners, such as Blacksmith Testboxes, are visibility-only records in the coordinator. `crabbox list --provider blacksmith-testbox` syncs -the current all-status Blacksmith table into `/portal`, and a later sync marks -missing runners stale. They do not heartbeat and do not participate in Crabbox -lease expiry, cleanup, or cost accounting. +the current all-status Blacksmith table into muted `/portal` lease-grid rows, +and a later sync marks missing runners stale. They do not heartbeat and do not +participate in Crabbox lease expiry, cleanup, or cost accounting. ## Cleanup diff --git a/worker/src/portal.ts b/worker/src/portal.ts index 2c8ddff..6d59e2d 100644 --- a/worker/src/portal.ts +++ b/worker/src/portal.ts @@ -34,34 +34,46 @@ export function portalHome( const admin = request.headers.get("x-crabbox-admin") === "true"; const owner = request.headers.get("x-crabbox-owner") || ""; const org = request.headers.get("x-crabbox-org") || ""; - const system = admin + const systemLeases = admin ? sortedLeases.filter((lease) => leaseOwnership(lease, owner, org) === "system").length : 0; - const defaultFilter = active.length > 0 ? "active" : "all"; + const systemRunners = admin + ? sortedRunners.filter((runner) => runnerOwnership(runner, owner, org) === "system").length + : 0; + const system = systemLeases + systemRunners; + const defaultFilter = active.length + activeRunners.length > 0 ? "active" : "all"; const filterButtons = [ "active:active", "ended:ended", + "external:external", + "stale:stale", ...(admin ? ["mine:mine", "system:system"] : []), "aws:aws", "hetzner:hetzner", + "blacksmith-testbox:blacksmith", "linux:linux", "macos:macos", "windows:windows", "all:all", ].join(","); - const rows = sortedLeases.length - ? sortedLeases.map((lease) => leaseRow(lease, { admin, owner, org })).join("") - : `no leases visible`; - const runnerRows = sortedRunners.length - ? sortedRunners.map((runner) => runnerRow(runner, { admin, owner, org })).join("") - : `no external runners synced`; + const rows = [ + ...sortedLeases.map((lease) => ({ kind: "lease" as const, sort: leaseSortTime(lease), lease })), + ...sortedRunners.map((runner) => ({ + kind: "runner" as const, + sort: runnerSortTime(runner), + runner, + })), + ] + .toSorted((a, b) => b.sort.localeCompare(a.sort)) + .map((row) => + row.kind === "lease" + ? leaseRow(row.lease, { admin, owner, org }) + : externalRunnerLeaseRow(row.runner, { admin, owner, org }), + ) + .join(""); const summary = admin - ? `${active.length} active / ${ended} ended / ${system} system` - : `${active.length} active / ${ended} ended`; - const runnerSummary = - sortedRunners.length > 0 - ? `${activeRunners.length} active / ${sortedRunners.length - activeRunners.length} stale` - : "sync with crabbox list --provider blacksmith-testbox"; + ? `${active.length + activeRunners.length} active / ${ended} ended / ${sortedRunners.length} external / ${system} system` + : `${active.length + activeRunners.length} active / ${ended} ended / ${sortedRunners.length} external`; return html( "Crabbox Portal", `
@@ -87,28 +99,7 @@ export function portalHome( - ${rows} - - -
-
-

external runners

- ${escapeHTML(runnerSummary)} -
- - - - - - - - - - - - - - ${runnerRows} + ${rows || ``}
runnerstatusproviderrepoworkflowjob/refseen
no leases or external runners visible
`, @@ -670,27 +661,24 @@ function leaseRow( `; } -function runnerRow( +function externalRunnerLeaseRow( runner: ExternalRunnerRecord, context: { admin: boolean; owner: string; org: string }, ): string { - const ownership = - context.admin && (runner.owner !== context.owner || runner.org !== context.org) - ? "system" - : "mine"; + const ownership = context.admin ? runnerOwnership(runner, context.owner, context.org) : "mine"; const state = runner.stale ? "stale" : "active"; const subline = context.admin && ownership === "system" - ? `${runner.owner || "unknown"} · ${runner.org || "unknown"}` - : runner.id; + ? `${runner.id} · ${runner.owner || "unknown"}` + : [runner.repo, runner.workflow].filter(Boolean).join(" · ") || runner.id; const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-"; - return ` + return ` ${escapeHTML(runner.id)}${escapeHTML(subline)} ${escapeHTML(runner.status || "-")} ${providerBadge(runner.provider)} - ${escapeHTML(runner.repo || "-")} - ${escapeHTML(runner.workflow || "-")} - ${escapeHTML(jobRef)} + - + external + no access ${timeCell(runnerSortTime(runner))} `; @@ -716,6 +704,14 @@ function leaseOwnership(lease: LeaseRecord, owner: string, org: string): "mine" return lease.owner === owner && lease.org === org ? "mine" : "system"; } +function runnerOwnership( + runner: ExternalRunnerRecord, + owner: string, + org: string, +): "mine" | "system" { + return runner.owner === owner && runner.org === org ? "mine" : "system"; +} + function runnerSortTime(runner: ExternalRunnerRecord): string { return runner.lastSeenAt || runner.updatedAt || runner.createdAt || runner.firstSeenAt; } @@ -1177,7 +1173,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { html { min-height:100%; background:var(--bg); } body { margin:0; min-height:100vh; overflow-x:hidden; 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)); max-width:100%; margin:0 auto; padding:10px 0 22px; } - .portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1.1fr) minmax(220px,0.9fr); gap:8px; padding:6px 0 8px; overflow:hidden; } + .portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1fr); gap:8px; padding:6px 0 8px; overflow:hidden; } .lease-shell { grid-template-rows:auto auto minmax(0,1fr); } .run-shell { height:auto; min-height:100dvh; overflow:visible; grid-template-rows:auto; } h1,h2,p { margin:0; } @@ -1276,6 +1272,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .icon-label[data-target="macos"] svg { color:#d8b4fe; } .actions-cell { display:flex; align-items:center; gap:5px; flex-wrap:nowrap; } .access-cell { display:flex; align-items:center; gap:5px; min-width:0; } + .disabled-cell { color:#6b7280; font-size:12px; } .access-icon { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:6px; border:1px solid var(--line); color:#cbd5e1; background:#0c0e10; text-decoration:none; } .access-icon svg { width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } .access-icon[data-access="vscode"] { color:#d8b4fe; } @@ -1298,6 +1295,12 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .table-footer { display:flex; justify-content:flex-end; align-items:center; gap:6px; min-height:36px; padding:5px 8px; background:var(--panel-2); } .table-page { min-width:64px; color:var(--muted); font-size:12px; text-align:center; } tr[hidden] { display:none; } + .external-row { color:var(--muted); background:color-mix(in srgb, var(--panel-2) 58%, transparent); } + .external-row td { border-bottom-color:var(--line-soft); } + .external-row .lease-link { color:#b6beca; pointer-events:none; } + .external-row .lease-link strong::after { content:"external"; display:inline-flex; margin-left:8px; min-height:18px; align-items:center; padding:0 6px; border:1px solid var(--line); border-radius:999px; color:#8b949e; font-size:10px; font-weight:700; text-transform:uppercase; vertical-align:middle; } + .external-row .icon-label svg { color:#7c8490; } + .external-row .pill { opacity:0.82; } .lease-table th:nth-child(1) { width:25%; } .lease-table th:nth-child(2) { width:86px; } .lease-table th:nth-child(3),.lease-table th:nth-child(4) { width:104px; } @@ -1305,14 +1308,6 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .lease-table th:nth-child(6) { width:118px; } .lease-table th:nth-child(7) { width:148px; } .lease-table th:nth-child(8) { width:24px; } - .runner-table th:nth-child(1) { width:22%; } - .runner-table th:nth-child(2) { width:88px; } - .runner-table th:nth-child(3) { width:148px; } - .runner-table th:nth-child(4) { width:118px; } - .runner-table th:nth-child(5) { width:28%; } - .runner-table th:nth-child(6) { width:120px; } - .runner-table th:nth-child(7) { width:138px; } - .runner-table th:nth-child(8) { width:24px; } .run-table th:nth-child(2) { width:104px; } .run-table th:nth-child(3) { width:112px; } .run-table th:nth-child(4) { width:92px; } diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index dd9e0c4..054197b 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -712,10 +712,14 @@ describe("fleet lease identity and idle", () => { expect(body).toContain("table-scroll"); expect(body).toContain(".lease-table th:nth-child(1)"); expect(body).toContain( - 'data-filter-buttons="active:active,ended:ended,aws:aws,hetzner:hetzner,linux:linux,macos:macos,windows:windows,all:all"', + 'data-filter-buttons="active:active,ended:ended,external:external,stale:stale,aws:aws,hetzner:hetzner,blacksmith-testbox:blacksmith,linux:linux,macos:macos,windows:windows,all:all"', ); expect(body).toContain('data-filter-default="active"'); - expect(body).toContain("external runners"); + expect(body).not.toContain("external runners"); + expect(body).toContain("1 external"); + expect(body).toContain('class="external-row"'); + expect(body).toContain('aria-disabled="true"'); + expect(body).toContain("no access"); expect(body).toContain("tbx_01testbox"); expect(body).toContain("blacksmith-testbox"); expect(body).toContain("ci-check-testbox.yml");