feat(portal): add lease detail pages
This commit is contained in:
parent
0bb34bdcad
commit
3eae4a816d
@ -8,6 +8,7 @@
|
||||
- Added `crabbox webvnc --daemon`/`--background` plus `--status`/`--stop` for background WebVNC bridges without tmux.
|
||||
- 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 `.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.
|
||||
|
||||
|
||||
@ -4,7 +4,14 @@ import { leaseConfig, validCIDRs } from "./config";
|
||||
import { HetznerClient } from "./hetzner";
|
||||
import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
|
||||
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
|
||||
import { portalCode, portalError, portalHome, portalVNC, webVNCBridgeCommand } from "./portal";
|
||||
import {
|
||||
portalCode,
|
||||
portalError,
|
||||
portalHome,
|
||||
portalLeaseDetail,
|
||||
portalVNC,
|
||||
webVNCBridgeCommand,
|
||||
} from "./portal";
|
||||
import { leaseSlugFromID, normalizeLeaseSlug, slugWithCollisionSuffix } from "./slug";
|
||||
import {
|
||||
createTailscaleAuthKey,
|
||||
@ -616,16 +623,7 @@ export class FleetDurableObject implements DurableObject {
|
||||
}
|
||||
const body = await optionalJson<{ delete?: boolean }>(request);
|
||||
const shouldDelete = body.delete ?? !lease.keep;
|
||||
if (shouldDelete && lease.state === "active") {
|
||||
await this.deleteLeaseServer(lease);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
lease.state = "released";
|
||||
lease.updatedAt = now;
|
||||
lease.releasedAt = now;
|
||||
lease.endedAt = now;
|
||||
await this.putLease(lease);
|
||||
return json({ lease });
|
||||
return json({ lease: await this.releaseResolvedLease(lease, { deleteServer: shouldDelete }) });
|
||||
}
|
||||
|
||||
private whoami(request: Request): Response {
|
||||
@ -641,6 +639,21 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (method === "GET" && parts.length === 1) {
|
||||
return portalHome(this.filterLeasesForRequest(await this.leaseRecords(), request), request);
|
||||
}
|
||||
if (method === "GET" && parts[1] === "runs" && parts[2]) {
|
||||
return await this.portalRunRoute(request, parts[2], parts[3]);
|
||||
}
|
||||
if (method === "GET" && parts[1] === "leases" && parts[2] && parts[3] === undefined) {
|
||||
return await this.portalLeasePage(request, parts[2]);
|
||||
}
|
||||
if (
|
||||
method === "POST" &&
|
||||
parts[1] === "leases" &&
|
||||
parts[2] &&
|
||||
parts[3] === "release" &&
|
||||
parts[4] === undefined
|
||||
) {
|
||||
return await this.portalReleaseLease(request, parts[2]);
|
||||
}
|
||||
if (
|
||||
method === "GET" &&
|
||||
parts[1] === "leases" &&
|
||||
@ -686,6 +699,69 @@ export class FleetDurableObject implements DurableObject {
|
||||
return json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
private async portalLeasePage(request: Request, identifier: string): Promise<Response> {
|
||||
const lease = await this.resolveLease(identifier, request, false);
|
||||
if (!lease) {
|
||||
return portalError(
|
||||
"Lease not found",
|
||||
"That lease is not active or is not visible to you.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
const runs = (await this.runRecords())
|
||||
.filter((run) => run.leaseID === lease.id && this.runVisibleToRequest(run, request))
|
||||
.toSorted((a, b) => b.startedAt.localeCompare(a.startedAt))
|
||||
.slice(0, 12);
|
||||
return portalLeaseDetail(lease, runs, {
|
||||
webVNCBridgeConnected: this.webVNCAgents.get(lease.id)?.readyState === WebSocket.OPEN,
|
||||
webVNCViewerConnected: this.webVNCViewers.get(lease.id)?.readyState === WebSocket.OPEN,
|
||||
codeBridgeConnected: this.codeAgents.get(lease.id)?.readyState === WebSocket.OPEN,
|
||||
});
|
||||
}
|
||||
|
||||
private async portalReleaseLease(request: Request, identifier: string): Promise<Response> {
|
||||
const lease = await this.resolveLease(identifier, request, false);
|
||||
if (!lease) {
|
||||
return portalError(
|
||||
"Lease not found",
|
||||
"That lease is not active or is not visible to you.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
await this.releaseResolvedLease(lease, { deleteServer: true, keep: false });
|
||||
return new Response(null, { status: 303, headers: { location: "/portal" } });
|
||||
}
|
||||
|
||||
private async portalRunRoute(
|
||||
request: Request,
|
||||
runID: string,
|
||||
action?: string,
|
||||
): Promise<Response> {
|
||||
const run = await this.getRun(runID);
|
||||
if (!run || !this.runVisibleToRequest(run, request)) {
|
||||
return notFound();
|
||||
}
|
||||
if (request.method.toUpperCase() !== "GET") {
|
||||
return json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
if (action === "logs") {
|
||||
const log = await this.readRunLog(runID);
|
||||
return new Response(log, {
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
if (action === "events") {
|
||||
const url = new URL(request.url);
|
||||
const after = finiteQueryNumber(url.searchParams.get("after")) ?? 0;
|
||||
const limit = clampLimit(url.searchParams.get("limit"), 500);
|
||||
return json({ events: await this.runEvents(runID, after, limit) });
|
||||
}
|
||||
if (action === undefined) {
|
||||
return json({ run });
|
||||
}
|
||||
return json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
private async webVNCAgent(request: Request, identifier: string): Promise<Response> {
|
||||
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
||||
return json(
|
||||
@ -1306,17 +1382,9 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (!lease) {
|
||||
return notFound();
|
||||
}
|
||||
if (lease.state === "active") {
|
||||
await this.deleteLeaseServer(lease);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
lease.state = "released";
|
||||
lease.updatedAt = now;
|
||||
lease.releasedAt = now;
|
||||
lease.endedAt = now;
|
||||
lease.keep = false;
|
||||
await this.putLease(lease);
|
||||
return json({ lease });
|
||||
return json({
|
||||
lease: await this.releaseResolvedLease(lease, { deleteServer: true, keep: false }),
|
||||
});
|
||||
}
|
||||
|
||||
private filterLeases(leases: LeaseRecord[], request: Request): LeaseRecord[] {
|
||||
@ -1748,6 +1816,25 @@ export class FleetDurableObject implements DurableObject {
|
||||
await this.provider("hetzner").deleteSSHKey(lease.providerKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseResolvedLease(
|
||||
lease: LeaseRecord,
|
||||
options: { deleteServer: boolean; keep?: boolean },
|
||||
): Promise<LeaseRecord> {
|
||||
if (options.deleteServer && lease.state === "active") {
|
||||
await this.deleteLeaseServer(lease);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
lease.state = "released";
|
||||
lease.updatedAt = now;
|
||||
lease.releasedAt = now;
|
||||
lease.endedAt = now;
|
||||
if (options.keep !== undefined) {
|
||||
lease.keep = options.keep;
|
||||
}
|
||||
await this.putLease(lease);
|
||||
return lease;
|
||||
}
|
||||
}
|
||||
|
||||
function leaseKey(leaseID: string): string {
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import type { LeaseRecord } from "./types";
|
||||
import type { LeaseRecord, RunRecord } from "./types";
|
||||
|
||||
const novncModuleURL = "/portal/assets/novnc/rfb.js";
|
||||
|
||||
export interface PortalLeaseBridgeStatus {
|
||||
webVNCBridgeConnected: boolean;
|
||||
webVNCViewerConnected: boolean;
|
||||
codeBridgeConnected: boolean;
|
||||
}
|
||||
|
||||
export function portalHome(leases: LeaseRecord[], request: Request): Response {
|
||||
const active = leases.filter((lease) => lease.state === "active");
|
||||
const rows = active.length
|
||||
@ -41,6 +47,103 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
|
||||
);
|
||||
}
|
||||
|
||||
export function portalLeaseDetail(
|
||||
lease: LeaseRecord,
|
||||
runs: RunRecord[],
|
||||
bridgeStatus: PortalLeaseBridgeStatus,
|
||||
): Response {
|
||||
const slug = lease.slug || lease.id;
|
||||
const runRows = runs.length
|
||||
? runs.map((run) => runRow(run)).join("")
|
||||
: `<tr><td colspan="7" class="empty">no recorded runs for this lease</td></tr>`;
|
||||
const vncAction = lease.desktop
|
||||
? `<a class="button" href="/portal/leases/${encodeURIComponent(lease.id)}/vnc">open VNC</a>`
|
||||
: `<span class="muted">no desktop</span>`;
|
||||
const codeAction = lease.code
|
||||
? `<a class="button" href="/portal/leases/${encodeURIComponent(lease.id)}/code/">open code</a>`
|
||||
: `<span class="muted">no code</span>`;
|
||||
const commands = [
|
||||
commandBlock("shell", `crabbox ssh --id ${shellArg(slug)}`),
|
||||
commandBlock("run", `crabbox run --id ${shellArg(slug)} -- <command>`),
|
||||
lease.desktop ? commandBlock("WebVNC bridge", webVNCBridgeCommand(lease)) : "",
|
||||
lease.code ? commandBlock("code bridge", codeBridgeCommand(lease)) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return html(
|
||||
`${slug} lease`,
|
||||
`<main>
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1>${escapeHTML(slug)}</h1>
|
||||
<p>${escapeHTML(lease.provider)} ${escapeHTML(lease.target)} lease <span class="mono">${escapeHTML(lease.id)}</span></p>
|
||||
</div>
|
||||
<div class="vnc-actions">
|
||||
<a class="button secondary" href="/portal">leases</a>
|
||||
<a class="button secondary" href="/portal/logout">log out</a>
|
||||
</div>
|
||||
</header>
|
||||
<section class="detail-grid">
|
||||
<div class="panel detail-card">
|
||||
<div class="section-head">
|
||||
<h2>status</h2>
|
||||
<span class="pill" data-state="${escapeHTML(lease.state)}">${escapeHTML(lease.state)}</span>
|
||||
</div>
|
||||
<dl class="meta-grid">
|
||||
${metaRow("provider", lease.provider)}
|
||||
${metaRow("target", lease.windowsMode ? `${lease.target} / ${lease.windowsMode}` : lease.target)}
|
||||
${metaRow("class", lease.class)}
|
||||
${metaRow("host", lease.host || "pending")}
|
||||
${metaRow("ssh", lease.sshPort ? `${lease.sshUser || "crabbox"}@${lease.host || "host"}:${lease.sshPort}` : "pending")}
|
||||
${metaRow("work root", lease.workRoot || "pending")}
|
||||
${metaRow("expires", shortTime(lease.expiresAt))}
|
||||
</dl>
|
||||
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/release" class="stop-form">
|
||||
<button class="button danger" type="submit">stop lease</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="panel detail-card">
|
||||
<div class="section-head">
|
||||
<h2>access</h2>
|
||||
<span>${lease.desktop || lease.code ? "bridges" : "ssh only"}</span>
|
||||
</div>
|
||||
<div class="bridge-grid">
|
||||
${bridgeRow("WebVNC", lease.desktop === true, bridgeStatus.webVNCBridgeConnected, bridgeStatus.webVNCViewerConnected, vncAction)}
|
||||
${bridgeRow("code", lease.code === true, bridgeStatus.codeBridgeConnected, false, codeAction)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>commands</h2>
|
||||
<span>copy locally</span>
|
||||
</div>
|
||||
<div class="commands">${commands}</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>recent runs</h2>
|
||||
<span>${runs.length}</span>
|
||||
</div>
|
||||
<table class="run-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>run</th>
|
||||
<th>state</th>
|
||||
<th>phase</th>
|
||||
<th>started</th>
|
||||
<th>duration</th>
|
||||
<th>log</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${runRows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function portalVNC(lease: LeaseRecord): Response {
|
||||
const nonce = scriptNonce();
|
||||
const slug = lease.slug || lease.id;
|
||||
@ -228,7 +331,7 @@ export function portalError(title: string, message: string, status = 400): Respo
|
||||
|
||||
export function portalCode(lease: LeaseRecord): Response {
|
||||
const slug = lease.slug || lease.id;
|
||||
const bridgeCmd = `crabbox code --id ${slug} --open`;
|
||||
const bridgeCmd = codeBridgeCommand(lease);
|
||||
return html(
|
||||
`Code ${slug}`,
|
||||
`<main>
|
||||
@ -251,6 +354,10 @@ export function portalCode(lease: LeaseRecord): Response {
|
||||
);
|
||||
}
|
||||
|
||||
export function codeBridgeCommand(lease: LeaseRecord): string {
|
||||
return ["crabbox", "code", "--id", lease.slug || lease.id, "--open"].map(shellArg).join(" ");
|
||||
}
|
||||
|
||||
export function webVNCBridgeCommand(lease: LeaseRecord): string {
|
||||
const target = lease.target || "linux";
|
||||
const args = [
|
||||
@ -279,6 +386,7 @@ function shellArg(value: string): string {
|
||||
|
||||
function leaseRow(lease: LeaseRecord): string {
|
||||
const label = lease.slug || lease.id;
|
||||
const detailPath = `/portal/leases/${encodeURIComponent(lease.id)}`;
|
||||
const vnc = lease.desktop
|
||||
? `<a class="button" href="/portal/leases/${encodeURIComponent(lease.id)}/vnc">open</a>`
|
||||
: `<span class="muted">no desktop</span>`;
|
||||
@ -286,7 +394,7 @@ function leaseRow(lease: LeaseRecord): string {
|
||||
? `<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}/code/">code</a>`
|
||||
: `<span class="muted">no code</span>`;
|
||||
return `<tr>
|
||||
<td><strong>${escapeHTML(label)}</strong><small>${escapeHTML(lease.id)}</small></td>
|
||||
<td><a class="lease-link" href="${detailPath}"><strong>${escapeHTML(label)}</strong><small>${escapeHTML(lease.id)}</small></a></td>
|
||||
<td>${escapeHTML(lease.provider)}</td>
|
||||
<td>${escapeHTML(lease.target)}</td>
|
||||
<td>${escapeHTML(lease.class)}</td>
|
||||
@ -296,6 +404,50 @@ function leaseRow(lease: LeaseRecord): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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 `<tr>
|
||||
<td><strong>${escapeHTML(run.id)}</strong><small>${escapeHTML(run.command.join(" "))}</small></td>
|
||||
<td><span class="pill" data-tone="${stateTone}">${escapeHTML(run.state)}</span></td>
|
||||
<td>${escapeHTML(run.phase || "-")}</td>
|
||||
<td>${escapeHTML(shortTime(run.startedAt))}</td>
|
||||
<td>${escapeHTML(formatDuration(run.durationMs))}</td>
|
||||
<td>${escapeHTML(logLabel)}</td>
|
||||
<td><div class="actions-cell"><a class="button secondary" href="/portal/runs/${encodeURIComponent(run.id)}/logs">logs</a><a class="button secondary" href="/portal/runs/${encodeURIComponent(run.id)}/events">events</a></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function metaRow(label: string, value: string | undefined): string {
|
||||
return `<div><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value || "-")}</dd></div>`;
|
||||
}
|
||||
|
||||
function bridgeRow(
|
||||
label: string,
|
||||
enabled: boolean,
|
||||
bridgeConnected: boolean,
|
||||
viewerConnected: boolean,
|
||||
action: string,
|
||||
): string {
|
||||
const status = enabled
|
||||
? bridgeConnected
|
||||
? viewerConnected
|
||||
? "viewer active"
|
||||
: "bridge ready"
|
||||
: "waiting for bridge"
|
||||
: "unavailable";
|
||||
const tone = enabled ? (bridgeConnected ? "ok" : "warn") : "";
|
||||
return `<div class="bridge-row">
|
||||
<div><strong>${escapeHTML(label)}</strong><small>${escapeHTML(status)}</small></div>
|
||||
<span class="pill" data-tone="${tone}">${escapeHTML(enabled ? (bridgeConnected ? "connected" : "waiting") : "off")}</span>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function commandBlock(label: string, command: string): string {
|
||||
return `<div class="command-row"><div><small>${escapeHTML(label)}</small><code>${escapeHTML(command)}</code></div></div>`;
|
||||
}
|
||||
|
||||
function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
const scriptSource = nonce ? `'self' 'nonce-${nonce}'` : "'self'";
|
||||
return new Response(
|
||||
@ -317,6 +469,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
h1 { font-size:22px; font-weight:700; }
|
||||
h2 { font-size:14px; text-transform:uppercase; color:var(--muted); }
|
||||
a { color:inherit; }
|
||||
form { margin:0; }
|
||||
button { font:inherit; }
|
||||
code { display:block; overflow:auto; padding:12px; border:1px solid var(--line); border-radius:6px; background:#0c0e10; color:#d1fae5; font-family:var(--mono); }
|
||||
table { width:100%; border-collapse:collapse; table-layout:fixed; }
|
||||
th,td { padding:12px; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
|
||||
@ -329,6 +483,23 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.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.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); }
|
||||
.detail-grid { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(280px,0.9fr); gap:12px; margin-bottom:12px; }
|
||||
.detail-card { min-width:0; }
|
||||
.meta-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0; margin:0; }
|
||||
.meta-grid div { padding:12px 14px; border-bottom:1px solid var(--line-soft); }
|
||||
.meta-grid dt { color:var(--muted); font-size:11px; text-transform:uppercase; margin-bottom:3px; }
|
||||
.meta-grid dd { margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.stop-form { padding:14px; }
|
||||
.bridge-grid { display:grid; gap:0; }
|
||||
.bridge-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center; padding:14px; border-bottom:1px solid var(--line-soft); }
|
||||
.bridge-row small { display:block; color:var(--muted); margin-top:2px; }
|
||||
.pill { display:inline-flex; align-items:center; justify-content:center; min-height:24px; padding:0 8px; border-radius:999px; border:1px solid var(--line); color:var(--muted); background:var(--panel-2); font-size:12px; white-space:nowrap; }
|
||||
.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)); }
|
||||
.actions-cell { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.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; }
|
||||
@ -353,12 +524,16 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.vnc-bridge-label { font-size:10px; text-transform:uppercase; letter-spacing:0.08em; color:var(--muted); flex-shrink:0; padding-left:4px; }
|
||||
.vnc-bridge-cmd { display:block; flex:1; min-width:0; padding:6px 10px; border:none; border-radius:5px; background:transparent; color:#d1fae5; font-family:var(--mono); font-size:13px; overflow-x:auto; white-space:nowrap; }
|
||||
.commands { padding:12px; display:grid; gap:8px; }
|
||||
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:stretch; }
|
||||
.command-row { display:grid; grid-template-columns:minmax(0,1fr); gap:8px; align-items:stretch; }
|
||||
.command-row small { display:block; color:var(--muted); margin-bottom:4px; text-transform:uppercase; font-size:11px; }
|
||||
.command-row code { min-width:0; }
|
||||
.error { margin-top:20vh; padding:24px; display:grid; gap:12px; }
|
||||
@media (max-width: 760px) {
|
||||
main { width:min(100vw - 20px, 1180px); padding:10px 0; }
|
||||
th:nth-child(4),td:nth-child(4),th:nth-child(6),td:nth-child(6){ display:none; }
|
||||
.detail-grid { grid-template-columns:1fr; }
|
||||
.meta-grid { grid-template-columns:1fr; }
|
||||
.bridge-row { grid-template-columns:1fr; align-items:start; }
|
||||
.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; }
|
||||
@ -401,6 +576,29 @@ function shortTime(value: string): string {
|
||||
return date.toISOString().replace(".000Z", "Z");
|
||||
}
|
||||
|
||||
function formatDuration(value: number | undefined): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "-";
|
||||
}
|
||||
const seconds = Math.max(0, Math.round((value ?? 0) / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest}s`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function escapeHTML(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
|
||||
@ -501,11 +501,104 @@ describe("fleet lease identity and idle", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.text();
|
||||
expect(body).toContain("blue-lobster");
|
||||
expect(body).toContain("/portal/leases/cbx_000000000001");
|
||||
expect(body).toContain("/portal/leases/cbx_000000000001/vnc");
|
||||
expect(body).toContain("/portal/leases/cbx_000000000001/code/");
|
||||
expect(body).not.toContain("amber-krill");
|
||||
});
|
||||
|
||||
it("renders lease detail pages with run logs and stop controls", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage, { hetzner: fakeProvider() });
|
||||
const headers = {
|
||||
"x-crabbox-owner": "peter@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
};
|
||||
storage.seed(
|
||||
"lease:cbx_000000000001",
|
||||
testLease({
|
||||
id: "cbx_000000000001",
|
||||
slug: "blue-lobster",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
desktop: true,
|
||||
code: true,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
);
|
||||
storage.seed(
|
||||
"run:run_000000000001",
|
||||
testRun({
|
||||
id: "run_000000000001",
|
||||
leaseID: "cbx_000000000001",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
command: ["go", "test", "./..."],
|
||||
state: "failed",
|
||||
phase: "failed",
|
||||
exitCode: 1,
|
||||
durationMs: 1234,
|
||||
logBytes: 11,
|
||||
}),
|
||||
);
|
||||
storage.seed(
|
||||
"run:run_000000000002",
|
||||
testRun({
|
||||
id: "run_000000000002",
|
||||
leaseID: "cbx_000000000001",
|
||||
owner: "friend@example.com",
|
||||
org: "openclaw",
|
||||
}),
|
||||
);
|
||||
storage.seed("runlog:run_000000000001", "portal log\n");
|
||||
storage.seed("runevent:run_000000000001:000000000001", {
|
||||
runID: "run_000000000001",
|
||||
seq: 1,
|
||||
type: "command.finished",
|
||||
phase: "failed",
|
||||
createdAt: "2026-05-01T00:00:01.000Z",
|
||||
});
|
||||
|
||||
const page = await fleet.fetch(request("GET", "/portal/leases/blue-lobster", { headers }));
|
||||
expect(page.status).toBe(200);
|
||||
const body = await page.text();
|
||||
expect(body).toContain("crabbox ssh --id blue-lobster");
|
||||
expect(body).toContain("crabbox run --id blue-lobster -- <command>");
|
||||
expect(body).toContain(
|
||||
"crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
||||
);
|
||||
expect(body).toContain("crabbox code --id blue-lobster --open");
|
||||
expect(body).toContain("/portal/runs/run_000000000001/logs");
|
||||
expect(body).toContain("/portal/runs/run_000000000001/events");
|
||||
expect(body).toContain("/portal/leases/cbx_000000000001/release");
|
||||
expect(body).not.toContain("run_000000000002");
|
||||
|
||||
const logs = await fleet.fetch(
|
||||
request("GET", "/portal/runs/run_000000000001/logs", { headers }),
|
||||
);
|
||||
expect(logs.status).toBe(200);
|
||||
expect(logs.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
||||
expect(await logs.text()).toBe("portal log\n");
|
||||
|
||||
const events = await fleet.fetch(
|
||||
request("GET", "/portal/runs/run_000000000001/events", { headers }),
|
||||
);
|
||||
expect(events.status).toBe(200);
|
||||
await expect(events.json()).resolves.toMatchObject({
|
||||
events: [{ runID: "run_000000000001", type: "command.finished" }],
|
||||
});
|
||||
|
||||
const stop = await fleet.fetch(
|
||||
request("POST", "/portal/leases/blue-lobster/release", { headers }),
|
||||
);
|
||||
expect(stop.status).toBe(303);
|
||||
expect(stop.headers.get("location")).toBe("/portal");
|
||||
expect(storage.value<LeaseRecord>("lease:cbx_000000000001")).toMatchObject({
|
||||
state: "released",
|
||||
keep: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves code pages only for code leases and requires a bridge ticket", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user