feat(portal): add table search controls

This commit is contained in:
Vincent Koc 2026-05-05 19:33:55 -07:00
parent be8d830933
commit 8ca88aebfe
No known key found for this signature in database
5 changed files with 155 additions and 16 deletions

View File

@ -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, event timelines, and retained log previews.
- Added portal run detail pages with command metadata, result summaries, searchable/paginated portal tables, 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.

View File

@ -56,13 +56,14 @@ GET /portal/runs/{run-id}/events
`/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, recent run links, and a stop action for the owner-scoped lease.
commands, searchable/paginated recent run links, 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, the latest events, and a retained log tail; `/logs` and `/events`
remain raw/plain resources for copying and automation.
present, a searchable/paginated event table, 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.

View File

@ -32,10 +32,10 @@ crabbox logs run_...
```
In the authenticated browser portal, `/portal/runs/<run-id>` renders the same
run as a human page with command metadata, result summary, recent events, and a
retained log tail. `/portal/runs/<run-id>/logs` stays a plain-text log endpoint,
and `/portal/runs/<run-id>/events` stays JSON for copying or browser-side
inspection.
run as a human page with command metadata, result summary, searchable/paginated
recent events, and a copyable retained log tail. `/portal/runs/<run-id>/logs`
stays a plain-text log endpoint, and `/portal/runs/<run-id>/events` stays JSON
for copying or browser-side inspection.
History records and run events live in the Fleet Durable Object. Log text is
stored separately from run metadata and intentionally capped so noisy commands

View File

@ -1,6 +1,7 @@
import type { LeaseRecord, RunEventRecord, RunRecord } from "./types";
const novncModuleURL = "/portal/assets/novnc/rfb.js";
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
export interface PortalLeaseBridgeStatus {
webVNCBridgeConnected: boolean;
@ -28,7 +29,7 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
<h2>leases</h2>
<span>${active.length} active</span>
</div>
<table>
<table data-portal-table data-page-size="12" data-search-placeholder="search leases">
<thead>
<tr>
<th>lease</th>
@ -125,7 +126,7 @@ export function portalLeaseDetail(
<h2>recent runs</h2>
<span>${runs.length}</span>
</div>
<table class="run-table">
<table class="run-table" data-portal-table data-page-size="8" data-search-placeholder="search runs">
<thead>
<tr>
<th>run</th>
@ -166,7 +167,7 @@ export function portalRunDetail(
.join("")
: "";
const logBlock = logTail
? `<pre class="log-preview">${escapeHTML(logTail)}</pre>`
? `<pre id="run-log-tail" class="log-preview">${escapeHTML(logTail)}</pre>`
: `<p class="empty">no retained log output</p>`;
return html(
`${run.id} run`,
@ -234,7 +235,14 @@ export function portalRunDetail(
<section class="panel">
<div class="section-head">
<h2>log tail</h2>
<span>${run.logTruncated ? "truncated" : "retained"}</span>
<div class="section-actions">
<span>${run.logTruncated ? "truncated" : "retained"}</span>
${
logTail
? `<button class="icon-btn" type="button" title="copy log tail" aria-label="copy log tail" data-copy-target="#run-log-tail">${copyIcon}</button>`
: ""
}
</div>
</div>
${logBlock}
</section>
@ -243,7 +251,7 @@ export function portalRunDetail(
<h2>events</h2>
<span>${events.length}</span>
</div>
<table class="event-table">
<table class="event-table" data-portal-table data-page-size="12" data-search-placeholder="search events">
<thead>
<tr>
<th>seq</th>
@ -268,7 +276,6 @@ export function portalVNC(lease: LeaseRecord): Response {
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
const bridgeCmd = webVNCBridgeCommand(lease);
const fullscreenIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>`;
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
const reconnectIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>`;
return html(
title,
@ -589,7 +596,8 @@ function resultsSummary(run: RunRecord): string {
}
function html(title: string, body: string, status = 200, nonce = ""): Response {
const scriptSource = nonce ? `'self' 'nonce-${nonce}'` : "'self'";
const pageNonce = nonce || scriptNonce();
const scriptSource = `'self' 'nonce-${pageNonce}'`;
return new Response(
`<!doctype html>
<html lang="en">
@ -620,9 +628,11 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.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); }
.section-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; color:var(--muted); }
.button { display:inline-flex; align-items:center; justify-content:center; min-height:32px; padding:0 12px; border-radius:8px; background:var(--accent); color:#001018; text-decoration:none; font-weight:700; }
.button.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); font-weight:500; }
.button.secondary:hover { background:#1b1f24; border-color:#3a4046; }
.button:disabled { opacity:0.45; cursor:not-allowed; }
.button.danger { border:1px solid color-mix(in srgb, var(--bad) 42%, var(--line)); background:color-mix(in srgb, var(--bad) 18%, transparent); color:#fecaca; cursor:pointer; }
.lease-link { display:block; text-decoration:none; }
.mono { font-family:var(--mono); }
@ -651,6 +661,14 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.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)); }
.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; }
.table-search::placeholder { color:#6b7280; }
.table-search:focus { outline:2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
.table-count { color:var(--muted); font-size:12px; white-space:nowrap; }
.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-meta { display:flex; align-items:baseline; gap:12px; min-width:0; }
@ -685,6 +703,9 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.meta-grid { grid-template-columns:1fr; }
.result-grid { grid-template-columns:1fr; }
.bridge-row { grid-template-columns:1fr; align-items:start; }
.table-tools { align-items:stretch; flex-direction:column; }
.table-search { max-width:none; width:100%; }
.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-meta { flex-wrap:wrap; gap:4px 10px; }
@ -695,7 +716,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
}
</style>
</head>
<body>${body}</body>
<body>${body}<script nonce="${pageNonce}">${portalEnhancementsScript()}</script></body>
</html>`,
{
status,
@ -715,6 +736,118 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
);
}
function portalEnhancementsScript(): string {
return `
(() => {
function copyText(text, source) {
const finish = () => {
source.dataset.state = "ok";
window.setTimeout(() => { delete source.dataset.state; }, 1200);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(finish).catch(() => fallbackCopy(text, source, finish));
return;
}
fallbackCopy(text, source, finish);
}
function fallbackCopy(text, source, finish) {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
try { document.execCommand("copy"); } catch (_) {}
area.remove();
finish();
}
document.querySelectorAll("[data-copy-target]").forEach((button) => {
button.addEventListener("click", () => {
const selector = button.getAttribute("data-copy-target");
if (!selector) return;
const target = document.querySelector(selector);
copyText(target?.textContent || "", button);
});
});
document.querySelectorAll("table[data-portal-table]").forEach((table, index) => {
const body = table.tBodies[0];
if (!body) return;
const rows = Array.from(body.rows);
const dataRows = rows.filter((row) => !row.querySelector(".empty"));
const originalEmpty = rows.find((row) => row.querySelector(".empty"));
const generatedEmpty = document.createElement("tr");
const emptyCell = document.createElement("td");
emptyCell.className = "empty";
emptyCell.colSpan = table.tHead?.rows[0]?.cells.length || table.rows[0]?.cells.length || 1;
emptyCell.textContent = "no matches";
generatedEmpty.append(emptyCell);
generatedEmpty.hidden = true;
body.append(generatedEmpty);
const pageSize = Math.max(1, Number.parseInt(table.dataset.pageSize || "10", 10) || 10);
let query = "";
let page = 1;
const tools = document.createElement("div");
tools.className = "table-tools";
const input = document.createElement("input");
input.className = "table-search";
input.type = "search";
input.placeholder = table.dataset.searchPlaceholder || "search table";
input.setAttribute("aria-label", input.placeholder);
input.disabled = dataRows.length === 0;
const count = document.createElement("span");
count.className = "table-count";
tools.append(input, count);
const footer = document.createElement("div");
footer.className = "table-footer";
const prev = document.createElement("button");
prev.className = "button secondary";
prev.type = "button";
prev.textContent = "prev";
const pageLabel = document.createElement("span");
pageLabel.className = "table-page";
const next = document.createElement("button");
next.className = "button secondary";
next.type = "button";
next.textContent = "next";
footer.append(prev, pageLabel, next);
table.before(tools);
table.after(footer);
table.dataset.enhancedIndex = String(index);
function apply() {
const filtered = dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, pages);
const start = (page - 1) * pageSize;
const visible = new Set(filtered.slice(start, start + pageSize));
dataRows.forEach((row) => { row.hidden = !visible.has(row); });
generatedEmpty.hidden = dataRows.length === 0 || filtered.length > 0;
if (originalEmpty) originalEmpty.hidden = dataRows.length > 0;
count.textContent = dataRows.length ? filtered.length + " of " + dataRows.length : "0";
pageLabel.textContent = page + " / " + pages;
prev.disabled = page <= 1;
next.disabled = page >= pages;
footer.hidden = dataRows.length <= pageSize && query === "";
}
input.addEventListener("input", () => {
query = input.value.trim().toLowerCase();
page = 1;
apply();
});
prev.addEventListener("click", () => {
page = Math.max(1, page - 1);
apply();
});
next.addEventListener("click", () => {
page += 1;
apply();
});
apply();
});
})();
`;
}
function scriptNonce(): string {
return crypto.randomUUID().replaceAll("-", "");
}

View File

@ -586,6 +586,8 @@ describe("fleet lease identity and idle", () => {
"crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
);
expect(body).toContain("crabbox code --id blue-lobster --open");
expect(body).toContain('data-search-placeholder="search runs"');
expect(body).toContain("table-search");
expect(body).toContain("/portal/runs/run_000000000001");
expect(body).toContain("/portal/runs/run_000000000001/logs");
expect(body).toContain("/portal/runs/run_000000000001/events");
@ -606,6 +608,9 @@ describe("fleet lease identity and idle", () => {
expect(runBody).toContain("run_000000000001");
expect(runBody).toContain("go test ./...");
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("table-search");
expect(runBody).toContain("renders detail");
expect(runBody).toContain("/portal/leases/cbx_000000000001");
expect(runBody).toContain("/portal/runs/run_000000000001/logs");