From 17686bb6f51ae932702201ff88dd2bc96a911121 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 6 May 2026 13:18:00 -0700 Subject: [PATCH] feat(portal): surface runner action state --- worker/src/portal.ts | 154 ++++++++++++++++++++++++++++++++++++-- worker/test/fleet.test.ts | 9 ++- 2 files changed, 157 insertions(+), 6 deletions(-) diff --git a/worker/src/portal.ts b/worker/src/portal.ts index d15fe6c..0d4adc6 100644 --- a/worker/src/portal.ts +++ b/worker/src/portal.ts @@ -47,6 +47,7 @@ export function portalHome( "ended:ended", "external:external", "stale:stale", + "stuck:stuck", ...(admin ? ["mine:mine", "system:system"] : []), "aws:aws", "hetzner:hetzner", @@ -672,21 +673,133 @@ function externalRunnerLeaseRow( ? `${runner.id} · ${runner.owner || "unknown"}` : [runner.repo, runner.workflow].filter(Boolean).join(" · ") || runner.id; const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-"; + const actionState = externalRunnerActionState(runner); const actionsLinks = externalRunnerActionsLinks(runner); - return ` + const filterTags = [ + state, + actionState?.stuck ? "stuck" : undefined, + actionState ? "actions" : undefined, + ownership, + "external", + runner.provider, + runner.status, + runner.actionsRunStatus, + runner.actionsRunConclusion, + runner.repo, + runner.workflow, + runner.job, + runner.ref, + ]; + return ` ${escapeHTML(runner.id)}${escapeHTML(subline)} - ${escapeHTML(runner.status || "-")} +
${escapeHTML(runner.status || "-")}${externalRunnerActionBadge(actionState)}
${providerBadge(runner.provider)} - - ${actionsLinks || "external"} - ${actionsLinks ? "no box access" : "no access"} + ${externalRunnerActionsCell(runner, actionsLinks)} + ${externalRunnerAccessCell(runner, actionsLinks.length > 0)} ${timeCell(runnerSortTime(runner))} `; } +type ExternalRunnerActionState = { + label: string; + title: string; + tone: string; + stuck: boolean; +}; + +function externalRunnerActionState( + runner: ExternalRunnerRecord, +): ExternalRunnerActionState | undefined { + const status = runner.actionsRunStatus; + const conclusion = runner.actionsRunConclusion; + if (!status && !conclusion) { + return undefined; + } + const ageMs = runner.createdAt ? Date.now() - Date.parse(runner.createdAt) : undefined; + const validAge = ageMs !== undefined && Number.isFinite(ageMs) && ageMs >= 0; + const lowerStatus = status?.toLowerCase(); + const lowerConclusion = conclusion?.toLowerCase(); + const queuedTooLong = lowerStatus === "queued" && validAge && ageMs > 20 * 60 * 1000; + const runningTooLong = + (lowerStatus === "in_progress" || lowerStatus === "running") && + validAge && + ageMs > 90 * 60 * 1000; + const stuck = Boolean(!conclusion && (queuedTooLong || runningTooLong)); + const title = [ + runner.actionsRepo, + runner.actionsRunID ? `run ${runner.actionsRunID}` : undefined, + status, + conclusion, + runner.createdAt ? `created ${relativeTime(runner.createdAt)}` : undefined, + ] + .filter(Boolean) + .join(" · "); + if (stuck) { + return { + label: `stuck ${compactAge(runner.createdAt)}`, + title, + tone: "warn", + stuck: true, + }; + } + const label = conclusion || status || "actions"; + return { + label: `gha ${label.replaceAll("_", " ")}`, + title, + tone: externalRunnerActionTone(lowerStatus, lowerConclusion), + stuck: false, + }; +} + +function externalRunnerActionTone( + status: string | undefined, + conclusion: string | undefined, +): string { + if (conclusion === "success") { + return "ok"; + } + if (conclusion === "failure" || conclusion === "timed_out") { + return "bad"; + } + if (conclusion === "cancelled" || conclusion === "skipped" || conclusion === "neutral") { + return "warn"; + } + if (status === "in_progress" || status === "running") { + return "ok"; + } + if (status === "queued" || status === "pending" || status === "waiting") { + return "warn"; + } + return ""; +} + +function externalRunnerActionBadge(state: ExternalRunnerActionState | undefined): string { + if (!state) { + return ""; + } + return `${escapeHTML(state.label)}`; +} + +function externalRunnerActionsCell(runner: ExternalRunnerRecord, actionsLinks: string): string { + const label = runner.actionsWorkflowName || workflowBasename(runner.workflow) || "external"; + const meta = [runner.job, runner.ref].filter(Boolean).join(" / "); + return `
${actionsLinks || `${escapeHTML(label)}`}${escapeHTML(meta || label)}
`; +} + +function externalRunnerAccessCell(runner: ExternalRunnerRecord, hasActions: boolean): string { + const label = hasActions ? "no box access" : "no access"; + const command = + runner.provider && runner.id ? `crabbox stop --provider ${runner.provider} ${runner.id}` : ""; + const copy = command + ? `` + : ""; + return `
${label}${copy}
`; +} + function externalRunnerActionsLinks(runner: ExternalRunnerRecord): string { - const links = []; + const links: string[] = []; if (runner.actionsRunURL) { const status = [runner.actionsRunStatus, runner.actionsRunConclusion].filter(Boolean).join("/"); links.push( @@ -701,6 +814,20 @@ function externalRunnerActionsLinks(runner: ExternalRunnerRecord): string { return links.length ? `${links.join("")}` : ""; } +function workflowBasename(workflow: string | undefined): string | undefined { + if (!workflow) { + return undefined; + } + const parts = workflow.split("/"); + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part) { + return part; + } + } + return workflow; +} + function portalHeader(options: PortalHeaderOptions): string { const variant = options.variant || "top"; const headerClass = variant === "bar" ? "vnc-bar" : "top"; @@ -1290,6 +1417,12 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .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; } + .state-stack { display:flex; align-items:center; gap:4px; flex-wrap:wrap; } + .action-pill { max-width:100%; overflow:hidden; text-overflow:ellipsis; } + .actions-stack { display:grid; gap:2px; min-width:0; } + .actions-stack small { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:10px; } + .external-access { flex-wrap:nowrap; } + .external-access span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .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; } @@ -1355,6 +1488,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response { .icon-btn:hover { background:#1b1f24; border-color:#3a4046; } .icon-btn:active { background:#22272d; } .icon-btn[data-state="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 45%, var(--line)); } + .icon-btn.mini { width:24px; height:24px; border-radius:6px; color:#9ca3af; flex:0 0 24px; } + .icon-btn.mini svg { width:13px; height:13px; } .screen { min-height:0; border:1px solid var(--line); border-radius:8px; background:var(--bg); overflow:hidden; box-shadow:inset 0 0 0 1px rgba(255,255,255,0.02); } .screen div { margin:0 auto; } .code-wait-screen { display:grid; place-items:center; padding:clamp(18px,5vw,64px); } @@ -1467,6 +1602,11 @@ function portalEnhancementsScript(): string { copyText(target?.textContent || "", button); }); }); + document.querySelectorAll("[data-copy-value]").forEach((button) => { + button.addEventListener("click", () => { + copyText(button.getAttribute("data-copy-value") || "", button); + }); + }); document.querySelectorAll("table[data-portal-table]").forEach((table, index) => { const body = table.tBodies[0]; if (!body) return; @@ -1662,6 +1802,10 @@ function relativeTime(value: string | undefined): string { return `${amount}${unit ? unit[1] : "s"} ${suffix}`; } +function compactAge(value: string | undefined): string { + return relativeTime(value).replace(" ago", "").replace(" from now", ""); +} + function leaseSortTime(lease: LeaseRecord): string { return lease.endedAt || lease.releasedAt || lease.updatedAt || lease.expiresAt || lease.createdAt; } diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index 6a8bdd9..36994df 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -719,7 +719,7 @@ 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,external:external,stale:stale,aws:aws,hetzner:hetzner,blacksmith-testbox:blacksmith,linux:linux,macos:macos,windows:windows,all:all"', + 'data-filter-buttons="active:active,ended:ended,external:external,stale:stale,stuck:stuck,aws:aws,hetzner:hetzner,blacksmith-testbox:blacksmith,linux:linux,macos:macos,windows:windows,all:all"', ); expect(body).toContain('data-filter-default="active"'); expect(body).not.toContain("external runners"); @@ -727,6 +727,10 @@ describe("fleet lease identity and idle", () => { expect(body).toContain('class="external-row"'); expect(body).toContain('aria-disabled="true"'); expect(body).toContain("no box access"); + expect(body).toContain("stuck"); + expect(body).toContain( + 'data-filter-tags="active stuck actions mine external blacksmith-testbox ready in_progress', + ); expect(body).toContain("tbx_01testbox"); expect(body).toContain("blacksmith-testbox"); expect(body).toContain("ci-check-testbox.yml"); @@ -735,6 +739,9 @@ describe("fleet lease identity and idle", () => { "https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml", ); expect(body).toContain('class="row-link"'); + expect(body).toContain( + 'data-copy-value="crabbox stop --provider blacksmith-testbox tbx_01testbox"', + ); expect(body).not.toContain("tbx_friendbox"); expect(body).toContain('data-provider="hetzner"'); expect(body).toContain('data-target="linux"');