feat(robomp): replace status tables with lifecycle cards
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../../config";
|
||||
import { activeView, setActiveView, type View } from "../../view";
|
||||
import { ThemeToggle } from "../ThemeToggle";
|
||||
import { Vitals } from "./Vitals";
|
||||
|
||||
// Inline nav glyphs — robomp has no icon library.
|
||||
function ActivityIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function OpsIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="3" width="20" height="6" rx="1.5" />
|
||||
<rect x="2" y="13" width="14" height="8" rx="1.5" />
|
||||
<path d="M20 17h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function TriageIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m14 4 6 6-10 10H4v-6L14 4Z" />
|
||||
<path d="m13 5 6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function LockIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 1 1 8 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavDef {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: () => JSX.Element;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavDef[] = [
|
||||
{ id: "operations", label: "Operations", icon: OpsIcon, locked: false },
|
||||
{ id: "activity", label: "Activity", icon: ActivityIcon, locked: false },
|
||||
{ id: "triage", label: "Triage", icon: TriageIcon, locked: !CONFIG.replayEnabled },
|
||||
];
|
||||
|
||||
export function Rail(): JSX.Element {
|
||||
return (
|
||||
<aside class="rmp-rail">
|
||||
{/* Brand lockup: gradient mark + wordmark + tag */}
|
||||
<div class="rmp-rail-brand">
|
||||
<span class="rmp-rail-mark" aria-hidden="true" />
|
||||
<span class="rmp-rail-wordmark">
|
||||
<span class="rmp-rail-name">robomp</span>
|
||||
<span class="rmp-rail-tag">triage · fix · ship</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Always-on vitals: health/sync + running# + failed# + 5 counts + runtime meta */}
|
||||
<Vitals />
|
||||
|
||||
{/* Nav: Operations / Activity / Triage */}
|
||||
<nav class="rmp-nav">
|
||||
{NAV.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rmp-nav-item"
|
||||
data-active={activeView() === item.id ? "true" : "false"}
|
||||
data-locked={item.locked ? "true" : "false"}
|
||||
aria-current={activeView() === item.id ? "page" : undefined}
|
||||
title={item.locked ? "Triage — locked (set ROBOMP_REPLAY_TOKEN to enable)" : item.label}
|
||||
onClick={() => setActiveView(item.id)}
|
||||
>
|
||||
<span class="rmp-nav-item-icon">{item.icon()}</span>
|
||||
<span class="rmp-nav-item-label">{item.label}</span>
|
||||
<Show when={item.locked}>
|
||||
<span class="rmp-nav-item-lock" title="read-only">
|
||||
<LockIcon />
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer: theme toggle + polling tag */}
|
||||
<div class="rmp-rail-footer">
|
||||
<ThemeToggle />
|
||||
<span class="rmp-rail-footer-tag">polling 3s</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createEffect, createSignal, type JSX, Show } from "solid-js";
|
||||
|
||||
import { activeView } from "../../view";
|
||||
import { Activity } from "../views/Activity";
|
||||
import { Operations } from "../views/Operations";
|
||||
import { Triage } from "../views/Triage";
|
||||
import { Rail } from "./Rail";
|
||||
import { TopBar } from "./TopBar";
|
||||
|
||||
export function Shell(): JSX.Element {
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false);
|
||||
|
||||
// Close the mobile drawer whenever the active view changes (nav tap).
|
||||
createEffect(() => {
|
||||
activeView();
|
||||
setDrawerOpen(false);
|
||||
});
|
||||
|
||||
const viewDisplay = (v: string): string => (activeView() === v ? "" : "none");
|
||||
|
||||
return (
|
||||
<div class="rmp-app">
|
||||
{/* Desktop rail (hidden <1024px) */}
|
||||
<div class="rmp-desktop-nav">
|
||||
<Rail />
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Show when={drawerOpen()}>
|
||||
<div
|
||||
class="rmp-mobile-drawer-overlay"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="rmp-mobile-drawer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation menu"
|
||||
>
|
||||
<Rail />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Main pane */}
|
||||
<div class="rmp-main">
|
||||
<TopBar onMenuToggle={() => setDrawerOpen(true)} />
|
||||
<main class="rmp-content scrollable">
|
||||
<div class="rmp-content-inner">
|
||||
{/* All 3 views stay mounted; visibility toggled by activeView so
|
||||
view-local UI state (Logs/Browse filters) persists and Browse
|
||||
does not refetch on view switches. The enter animation
|
||||
(rmp-view-enter) restarts automatically when a view flips
|
||||
none→flex, firing on first paint + view-switch ONLY, never on
|
||||
the 3s data poll (polls never change display). */}
|
||||
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("operations") }}>
|
||||
<Operations />
|
||||
</div>
|
||||
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("activity") }}>
|
||||
<Activity />
|
||||
</div>
|
||||
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("triage") }}>
|
||||
<Triage />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../../config";
|
||||
import { activeView, type View } from "../../view";
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
const TITLES: Record<View, string> = {
|
||||
operations: "Operations",
|
||||
activity: "Activity",
|
||||
triage: "Triage",
|
||||
};
|
||||
|
||||
function MenuIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar(props: TopBarProps): JSX.Element {
|
||||
return (
|
||||
<header class="rmp-topbar">
|
||||
<div class="rmp-topbar-left">
|
||||
<button
|
||||
type="button"
|
||||
class="rmp-mobile-menu-btn"
|
||||
aria-label="Open navigation menu"
|
||||
onClick={() => props.onMenuToggle()}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<h1 class="rmp-page-title">{TITLES[activeView()]}</h1>
|
||||
</div>
|
||||
|
||||
<div class="rmp-topbar-right">
|
||||
{/* View-contextual slot. Operations → read-only/replay chip. Sync + vitals live in the rail. */}
|
||||
<Show when={activeView() === "operations"}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={<span class="rmp-readonly-chip">read-only · replay off</span>}
|
||||
>
|
||||
<span class="rmp-readonly-chip">replay on</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { fmtDuration } from "../../format";
|
||||
import { isFetching, lastTickAt, lastTickError, statusResource } from "../../state";
|
||||
import { EVENT_STATE_ORDER } from "../../types";
|
||||
|
||||
// Ported verbatim from Header.tsx (dissolved).
|
||||
function relativeAgo(ms: number): string {
|
||||
const seconds = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
return `${fmtDuration(seconds)} ago`;
|
||||
}
|
||||
|
||||
const STATE_TONE: Record<string, string> = {
|
||||
queued: "var(--color-info)",
|
||||
running: "var(--color-warn)",
|
||||
done: "var(--color-ok)",
|
||||
failed: "var(--color-err)",
|
||||
skipped: "var(--color-ink-300)",
|
||||
};
|
||||
|
||||
export function Vitals(): JSX.Element {
|
||||
const runtime = () => statusResource()?.runtime;
|
||||
|
||||
// issue_event_counts ?? event_counts — preserves the Stats.tsx accessor + title.
|
||||
const counts = (): Record<string, number> => {
|
||||
const status = statusResource();
|
||||
if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
||||
return status.issue_event_counts ?? status.event_counts;
|
||||
};
|
||||
|
||||
// Match the pipeline derivation: running_events and the inflight snapshot overlap
|
||||
// (the worker pool keys inflight by `issue_key || delivery_id`), so union by
|
||||
// that same key instead of summing lengths — otherwise one live job counts as two.
|
||||
const runningTotal = (): number => {
|
||||
const s = statusResource();
|
||||
if (!s) return 0;
|
||||
const keys = new Set(s.running_events.map((e) => e.issue_key ?? e.delivery_id));
|
||||
for (const key of s.inflight) keys.add(key);
|
||||
return keys.size;
|
||||
};
|
||||
|
||||
const failedCount = (): number => counts().failed ?? 0;
|
||||
|
||||
const allowlist = (): string => {
|
||||
const list = runtime()?.repo_allowlist;
|
||||
return list && list.length ? list.join(", ") : "(none)";
|
||||
};
|
||||
|
||||
const syncTone = (): string => {
|
||||
if (lastTickError()) return "err";
|
||||
if (isFetching()) return "warn";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const syncText = (): string => {
|
||||
if (lastTickError()) return lastTickError()!;
|
||||
if (isFetching()) return "syncing…";
|
||||
return `synced ${relativeAgo(lastTickAt())}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="rmp-vitals">
|
||||
{/* health / sync */}
|
||||
<div class="rmp-vital-sync" title={lastTickError() ?? undefined}>
|
||||
<span
|
||||
class={`rmp-vital-dot ${syncTone()}${isFetching() ? " pulse" : ""}`}
|
||||
/>
|
||||
<Show
|
||||
when={lastTickError()}
|
||||
fallback={<span>{syncText()}</span>}
|
||||
>
|
||||
<span class="text-err truncate">{syncText()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* big running / failed */}
|
||||
<div class="rmp-vital-big">
|
||||
<div class="rmp-vital-big-cell">
|
||||
<span class="rmp-vital-big-label">running</span>
|
||||
<span
|
||||
class={`rmp-vital-big-num${
|
||||
runningTotal() > 0 ? " running-nonzero" : ""
|
||||
}`}
|
||||
>
|
||||
{runningTotal()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rmp-vital-big-cell">
|
||||
<span class="rmp-vital-big-label">failed</span>
|
||||
<span
|
||||
class={`rmp-vital-big-num${
|
||||
failedCount() > 0 ? " failed-nonzero" : ""
|
||||
}`}
|
||||
>
|
||||
{failedCount()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5 state counts (compact 2-col) */}
|
||||
<div class="rmp-vital-counts" title="newest non-skipped event per issue">
|
||||
{EVENT_STATE_ORDER.map((state) => (
|
||||
<div class="rmp-vital-count">
|
||||
<span class="rmp-vital-count-label">{state}</span>
|
||||
<span
|
||||
class="rmp-vital-count-num"
|
||||
style={{ color: STATE_TONE[state] ?? "var(--color-ink-200)" }}
|
||||
>
|
||||
{counts()[state] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* runtime meta */}
|
||||
<div class="rmp-vital-runtime">
|
||||
<RuntimeRow label="bot" value={runtime()?.bot_login} mono />
|
||||
<RuntimeRow
|
||||
label="model"
|
||||
value={runtime()?.model}
|
||||
mono
|
||||
title={
|
||||
runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined
|
||||
}
|
||||
/>
|
||||
<RuntimeRow
|
||||
label="conc"
|
||||
value={
|
||||
runtime()?.max_concurrency != null
|
||||
? String(runtime()?.max_concurrency)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RuntimeRow
|
||||
label="up"
|
||||
value={
|
||||
runtime()?.uptime_seconds != null
|
||||
? fmtDuration(runtime()?.uptime_seconds)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RuntimeRow label="repos" value={allowlist()} mono title={allowlist()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuntimeRowProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function RuntimeRow(props: RuntimeRowProps): JSX.Element {
|
||||
return (
|
||||
<div class="rmp-vital-runtime-row" title={props.title}>
|
||||
<span class="rmp-vital-runtime-label">{props.label}</span>
|
||||
<span
|
||||
class={`rmp-vital-runtime-value${props.mono ? " mono" : ""}`}
|
||||
title={props.value}
|
||||
>
|
||||
{props.value ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user