fix(portal): merge external runners into leases table
This commit is contained in:
parent
fc73712387
commit
2a4e08af24
@ -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/<name>` packages while keeping command orchestration and rendering core-owned.
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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("")
|
||||
: `<tr><td colspan="8" class="empty">no leases visible</td></tr>`;
|
||||
const runnerRows = sortedRunners.length
|
||||
? sortedRunners.map((runner) => runnerRow(runner, { admin, owner, org })).join("")
|
||||
: `<tr><td colspan="8" class="empty">no external runners synced</td></tr>`;
|
||||
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",
|
||||
`<main class="portal-shell">
|
||||
@ -87,28 +99,7 @@ export function portalHome(
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel table-panel runner-panel">
|
||||
<div class="section-head">
|
||||
<h2>external runners</h2>
|
||||
<span>${escapeHTML(runnerSummary)}</span>
|
||||
</div>
|
||||
<table class="runner-table" data-portal-table data-page-size="8" data-search-placeholder="search runners" data-filter-buttons="active:active,stale:stale,blacksmith-testbox:blacksmith,all:all" data-filter-default="${activeRunners.length > 0 ? "active" : "all"}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>runner</th>
|
||||
<th>status</th>
|
||||
<th>provider</th>
|
||||
<th>repo</th>
|
||||
<th>workflow</th>
|
||||
<th>job/ref</th>
|
||||
<th>seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${runnerRows}</tbody>
|
||||
<tbody>${rows || `<tr><td colspan="8" class="empty">no leases or external runners visible</td></tr>`}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>`,
|
||||
@ -670,27 +661,24 @@ function leaseRow(
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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 `<tr data-filter-tags="${escapeHTML([state, ownership, runner.provider, runner.status, runner.repo, runner.workflow, runner.job, runner.ref].filter(Boolean).join(" "))}">
|
||||
return `<tr class="external-row" aria-disabled="true" data-filter-tags="${escapeHTML([state, "external", ownership, runner.provider, runner.status, runner.repo, runner.workflow, runner.job, runner.ref].filter(Boolean).join(" "))}">
|
||||
<td><span class="lease-link"><strong>${escapeHTML(runner.id)}</strong><small>${escapeHTML(subline)}</small></span></td>
|
||||
<td><span class="pill" data-tone="${runner.stale ? "warn" : runnerStatusTone(runner.status)}">${escapeHTML(runner.status || "-")}</span></td>
|
||||
<td>${providerBadge(runner.provider)}</td>
|
||||
<td>${escapeHTML(runner.repo || "-")}</td>
|
||||
<td>${escapeHTML(runner.workflow || "-")}</td>
|
||||
<td>${escapeHTML(jobRef)}</td>
|
||||
<td><span class="muted" title="Blacksmith owns runner host details">-</span></td>
|
||||
<td><span title="${escapeHTML([runner.repo, runner.workflow, jobRef].filter(Boolean).join(" · "))}">external</span></td>
|
||||
<td><span class="access-cell disabled-cell" title="external runner; no Crabbox access data">no access</span></td>
|
||||
${timeCell(runnerSortTime(runner))}
|
||||
<td></td>
|
||||
</tr>`;
|
||||
@ -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; }
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user