fix(portal): unify portal headers

This commit is contained in:
Vincent Koc 2026-05-06 00:30:27 -07:00
parent dd62f44f86
commit dadf115ac9
No known key found for this signature in database
2 changed files with 67 additions and 44 deletions

View File

@ -5,6 +5,13 @@ const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" st
const serverIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg>`;
const vncIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`;
const codeIcon = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 8-4 4 4 4"/><path d="m15 8 4 4-4 4"/><path d="m13 5-2 14"/></svg>`;
const portalBrand = "🦀 crabbox";
interface PortalHeaderOptions {
variant?: "top" | "bar";
meta: string;
actions?: string;
}
export interface PortalLeaseBridgeStatus {
webVNCBridgeConnected: boolean;
@ -43,13 +50,10 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
return html(
"Crabbox Portal",
`<main class="portal-shell">
<header class="top">
<div>
<h1>🦀 crabbox</h1>
<p>${escapeHTML(new URL(request.url).host)}</p>
</div>
<a class="button secondary" href="/portal/logout">log out</a>
</header>
${portalHeader({
meta: escapeHTML(new URL(request.url).host),
actions: `<a class="button secondary" href="/portal/logout">log out</a>`,
})}
<section class="panel table-panel">
<div class="section-head">
<h2>leases</h2>
@ -81,6 +85,7 @@ export function portalLeaseDetail(
bridgeStatus: PortalLeaseBridgeStatus,
): Response {
const slug = lease.slug || lease.id;
const target = lease.target || "linux";
const active = lease.state === "active";
const runRows = runs.length
? runs.map((run) => runRow(run)).join("")
@ -106,16 +111,13 @@ export function portalLeaseDetail(
return html(
`${slug} lease`,
`<main class="portal-shell lease-shell">
<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">
${portalHeader({
meta: `${escapeHTML(slug)} · ${escapeHTML(lease.provider)} ${escapeHTML(target)} lease <span class="mono">${escapeHTML(lease.id)}</span>`,
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">
@ -124,7 +126,7 @@ export function portalLeaseDetail(
</div>
<dl class="meta-grid">
${metaHTMLRow("provider", providerBadge(lease.provider))}
${metaHTMLRow("target", targetBadge(lease.target, lease.windowsMode))}
${metaHTMLRow("target", targetBadge(target, lease.windowsMode))}
${metaRow("class", lease.class)}
${metaRow("host", lease.host || "pending")}
${metaRow("ssh", lease.sshPort ? `${lease.sshUser || "crabbox"}@${lease.host || "host"}:${lease.sshPort}` : "pending")}
@ -203,17 +205,14 @@ export function portalRunDetail(
return html(
`${run.id} run`,
`<main class="portal-shell run-shell">
<header class="top">
<div>
<h1>${escapeHTML(run.id)}</h1>
<p>${escapeHTML(run.slug || run.leaseID)} <span class="mono">${escapeHTML(run.command.join(" "))}</span></p>
</div>
<div class="vnc-actions">
${portalHeader({
meta: `${escapeHTML(run.id)} · ${escapeHTML(run.slug || run.leaseID)} <span class="mono">${escapeHTML(run.command.join(" "))}</span>`,
actions: `
<a class="button secondary" href="/portal/leases/${encodeURIComponent(run.leaseID)}">lease</a>
<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">
@ -303,6 +302,7 @@ export function portalRunDetail(
export function portalVNC(lease: LeaseRecord): Response {
const nonce = scriptNonce();
const slug = lease.slug || lease.id;
const target = lease.target || "linux";
const title = `WebVNC ${slug}`;
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
@ -312,19 +312,17 @@ export function portalVNC(lease: LeaseRecord): Response {
return html(
title,
`<main class="vnc-page">
<header class="vnc-bar">
<div class="vnc-meta">
<h1>${escapeHTML(slug)}</h1>
<p>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(lease.target, lease.windowsMode)}<span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span></p>
</div>
<div class="vnc-actions">
${portalHeader({
variant: "bar",
meta: `<span>WebVNC ${escapeHTML(slug)}</span><span class="vnc-dot"></span>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(target, lease.windowsMode)}<span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span>`,
actions: `
<span id="status" class="status-pill">waiting for bridge</span>
<button id="vnc-reconnect" class="icon-btn" type="button" title="reconnect" aria-label="reconnect">${reconnectIcon}</button>
<button id="vnc-fullscreen" class="icon-btn" type="button" title="fullscreen" aria-label="toggle fullscreen">${fullscreenIcon}</button>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
</div>
</header>
`,
})}
<section id="screen" class="screen" aria-label="WebVNC display"></section>
<footer class="vnc-bridge">
<span class="vnc-bridge-label">bridge</span>
@ -487,24 +485,23 @@ export function portalError(title: string, message: string, status = 400): Respo
export function portalCode(lease: LeaseRecord): Response {
const nonce = scriptNonce();
const slug = lease.slug || lease.id;
const target = lease.target || "linux";
const bridgeCmd = codeBridgeCommand(lease);
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/code/health`;
const reloadIcon = `<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(
`Code ${slug}`,
`<main class="vnc-page code-wait-page">
<header class="vnc-bar">
<div class="vnc-meta">
<h1>${escapeHTML(slug)}</h1>
<p>${providerBadge(lease.provider)}<span class="vnc-dot"></span><span>code workspace</span><span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span></p>
</div>
<div class="vnc-actions">
${portalHeader({
variant: "bar",
meta: `<span>code ${escapeHTML(slug)}</span><span class="vnc-dot"></span>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(target, lease.windowsMode)}<span class="vnc-dot"></span><span>code workspace</span><span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span>`,
actions: `
<span id="code-status" class="status-pill">checking bridge</span>
<button id="code-reload" class="icon-btn" type="button" title="reload" aria-label="reload">${reloadIcon}</button>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
</div>
</header>
`,
})}
<section class="screen code-wait-screen" aria-label="Code bridge waiting state">
<div class="code-wait-card">
<span class="code-wait-kicker">code bridge</span>
@ -637,6 +634,22 @@ function leaseRow(
</tr>`;
}
function portalHeader(options: PortalHeaderOptions): string {
const variant = options.variant || "top";
const headerClass = variant === "bar" ? "vnc-bar" : "top";
const metaClass = variant === "bar" ? "vnc-meta portal-header-meta" : "portal-header-meta";
const actions = options.actions?.trim()
? `<div class="portal-actions">${options.actions.trim()}</div>`
: "";
return `<header class="${headerClass}">
<div class="${metaClass}">
<h1>${portalBrand}</h1>
<p>${options.meta}</p>
</div>
${actions}
</header>`;
}
function leaseOwnership(lease: LeaseRecord, owner: string, org: string): "mine" | "system" {
return lease.owner === owner && lease.org === org ? "mine" : "system";
}
@ -1052,7 +1065,9 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
td { font-size:13px; }
td small { display:block; color:var(--muted); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.top { position:sticky; top:0; z-index:20; display:flex; justify-content:space-between; gap:12px; align-items:center; min-height:38px; margin:0; padding:4px 0; background:linear-gradient(180deg, var(--bg) 72%, color-mix(in srgb, var(--bg) 0%, transparent)); backdrop-filter:blur(10px); }
.top p { font-size:12px; }
.portal-header-meta { min-width:0; }
.portal-header-meta h1 { white-space:nowrap; }
.portal-header-meta p { font-size:12px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.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; min-height:34px; padding:7px 10px; border-bottom:1px solid var(--line); }
@ -1153,7 +1168,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.vnc-meta p { display:inline-flex; align-items:center; gap:8px; color:var(--muted); font-size:12px; min-width:0; overflow:hidden; }
.vnc-meta .vnc-id { font-family:var(--mono); font-size:11px; opacity:0.85; }
.vnc-meta .vnc-dot { width:3px; height:3px; border-radius:50%; background:#3a4046; flex-shrink:0; }
.vnc-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; }
.portal-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; }
.status-pill { display:inline-flex; align-items:center; gap:8px; height:32px; padding:0 12px 0 11px; border-radius:8px; background:var(--panel-2); border:1px solid var(--line); font-size:12px; color:var(--muted); white-space:nowrap; transition:color 0.2s, border-color 0.2s; }
.status-pill::before { content:""; width:8px; height:8px; border-radius:50%; background:currentColor; box-shadow:0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); flex-shrink:0; }
.status-pill[data-tone="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); }
@ -1197,8 +1212,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.vnc-bar { flex-wrap:wrap; gap:8px; min-height:0; padding:6px 10px; margin:0 -12px; }
.vnc-meta { flex-wrap:wrap; gap:4px 10px; }
.vnc-meta p .vnc-id { display:none; }
.vnc-actions { gap:6px; }
.vnc-actions .button { min-height:30px; padding:0 10px; }
.portal-actions { gap:6px; }
.portal-actions .button { min-height:30px; padding:0 10px; }
.vnc-bridge-label { display:none; }
}
</style>

View File

@ -648,6 +648,7 @@ describe("fleet lease identity and idle", () => {
const body = await response.text();
expect(body).toContain('class="portal-shell"');
expect(body).toContain("<h1>🦀 crabbox</h1>");
expect(body).toContain('class="portal-actions"');
expect(body).toContain("table-scroll");
expect(body).toContain(".lease-table th:nth-child(1)");
expect(body).toContain(
@ -913,6 +914,8 @@ describe("fleet lease identity and idle", () => {
expect(body).toContain("data-copy-command");
expect(body).toContain('querySelector("code")');
expect(body).toContain('class="portal-shell lease-shell"');
expect(body).toContain("<h1>🦀 crabbox</h1>");
expect(body).toContain("blue-lobster · hetzner linux lease");
expect(body).toContain('data-search-placeholder="search runs"');
expect(body).toContain(
'data-filter-buttons="succeeded:succeeded,failed:failed,running:running,all:all"',
@ -950,6 +953,7 @@ describe("fleet lease identity and idle", () => {
expect(runPage.headers.get("content-type")).toBe("text/html; charset=utf-8");
const runBody = await runPage.text();
expect(runBody).toContain('class="portal-shell run-shell"');
expect(runBody).toContain("<h1>🦀 crabbox</h1>");
expect(runBody).toContain("run_000000000001");
expect(runBody).toContain("go test ./...");
expect(runBody).toContain("data-copy-command");
@ -1024,6 +1028,8 @@ describe("fleet lease identity and idle", () => {
const pageBody = await page.text();
expect(pageBody).toContain("crabbox code --id blue-lobster --open");
expect(pageBody).toContain('class="vnc-page code-wait-page"');
expect(pageBody).toContain("<h1>🦀 crabbox</h1>");
expect(pageBody).toContain("code blue-lobster");
expect(pageBody).toContain('id="code-status"');
expect(pageBody).toContain('id="code-copy"');
expect(pageBody).toContain("/portal/leases/cbx_000000000001/code/health");
@ -1134,6 +1140,8 @@ describe("fleet lease identity and idle", () => {
"crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
);
expect(pageBody).toContain("/portal/assets/novnc/rfb.js");
expect(pageBody).toContain("<h1>🦀 crabbox</h1>");
expect(pageBody).toContain("WebVNC blue-lobster");
expect(pageBody).toContain("function scheduleRetry");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
expect(pageBody).toContain("vnc-copy");