feat(robomp): replace status tables with lifecycle cards
This commit is contained in:
@@ -124,7 +124,7 @@ export function Browse(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.05)]">
|
||||
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft tint-err">
|
||||
{errorMessage()}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -132,7 +132,7 @@ export function Browse(): JSX.Element {
|
||||
<Show when={data().errors.length}>
|
||||
<For each={data().errors}>
|
||||
{(err) => (
|
||||
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.04)]">
|
||||
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft tint-err">
|
||||
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -155,7 +155,7 @@ export function Browse(): JSX.Element {
|
||||
<For each={filtered()}>
|
||||
{(issue) => (
|
||||
<div
|
||||
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-white/[0.025] transition-colors"
|
||||
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-surface-2 transition-colors"
|
||||
style={{
|
||||
"grid-template-columns": "1fr auto",
|
||||
opacity: issue.processed ? 0.55 : 1,
|
||||
@@ -178,16 +178,7 @@ export function Browse(): JSX.Element {
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
|
||||
<Pill state={issue.state}>{issue.state}</Pill>
|
||||
<Show when={issue.processed}>
|
||||
<span
|
||||
class="pill"
|
||||
style={{
|
||||
color: "#9ec9ff",
|
||||
"border-color": "rgba(100,175,255,0.32)",
|
||||
"background-color": "rgba(10,132,255,0.10)",
|
||||
}}
|
||||
>
|
||||
processed
|
||||
</span>
|
||||
<Pill state="processed">processed</Pill>
|
||||
</Show>
|
||||
<span>by {issue.author || "—"}</span>
|
||||
<span>updated {fmtAge(issue.updated_at)}</span>
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface GlassCardProps {
|
||||
// the inset content padding so tables/log lists can reach the edge.
|
||||
export function GlassCard(props: GlassCardProps): JSX.Element {
|
||||
const cls = (): string => {
|
||||
const base = "glass glass-rise rounded-[22px] overflow-hidden";
|
||||
const base = "glass glass-rise rounded-lg overflow-hidden";
|
||||
return props.class ? `${base} ${props.class}` : base;
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtDuration } from "../format";
|
||||
import { isFetching, lastTickAt, lastTickError, statusResource } from "../state";
|
||||
import type { RuntimeInfo } from "../types";
|
||||
|
||||
function relativeAgo(ms: number): string {
|
||||
const seconds = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
return `${fmtDuration(seconds)} ago`;
|
||||
}
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const runtime = (): RuntimeInfo | undefined => statusResource()?.runtime;
|
||||
|
||||
return (
|
||||
<header class="px-6 lg:px-10 pt-8 pb-6 flex flex-col gap-5">
|
||||
<div class="flex flex-wrap items-end gap-x-6 gap-y-3 justify-between">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="text-[26px] font-semibold tracking-tight leading-none">
|
||||
<span class="text-ink-50">robomp</span>
|
||||
<span class="inline-block ml-2 size-[7px] rounded-full align-middle bg-accent shadow-[0_0_10px_rgba(10,132,255,0.7)]" />
|
||||
</h1>
|
||||
<span class="eyebrow">triage · fix · ship</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[11px] text-ink-300">
|
||||
<Show
|
||||
when={lastTickError()}
|
||||
fallback={
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-block size-[7px] rounded-full ${
|
||||
isFetching() ? "bg-warn" : "bg-ok"
|
||||
}`}
|
||||
style={{
|
||||
"box-shadow": isFetching()
|
||||
? "0 0 8px rgba(255,214,10,0.6)"
|
||||
: "0 0 8px rgba(48,209,88,0.6)",
|
||||
}}
|
||||
/>
|
||||
{isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="flex items-center gap-2 text-err">
|
||||
<span class="inline-block size-[7px] rounded-full bg-err" />
|
||||
{lastTickError()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-7 gap-y-2 text-[12px] text-ink-300 tabular">
|
||||
<Meta label="bot" value={runtime()?.bot_login} mono />
|
||||
<Meta
|
||||
label="model"
|
||||
value={runtime()?.model}
|
||||
mono
|
||||
title={runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined}
|
||||
/>
|
||||
<Meta
|
||||
label="concurrency"
|
||||
value={
|
||||
runtime()?.max_concurrency != null ? String(runtime()?.max_concurrency) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="uptime"
|
||||
value={
|
||||
runtime()?.uptime_seconds != null ? fmtDuration(runtime()?.uptime_seconds) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="allowlist"
|
||||
value={
|
||||
runtime()?.repo_allowlist?.length ? runtime()!.repo_allowlist.join(", ") : "(none)"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<Show when={!CONFIG.replayEnabled}>
|
||||
<span class="pill skipped">read-only · trigger disabled</span>
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetaProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function Meta(props: MetaProps): JSX.Element {
|
||||
return (
|
||||
<span class="inline-flex items-baseline gap-1.5" title={props.title}>
|
||||
<span class="text-ink-500 uppercase tracking-[0.14em] text-[10px]">{props.label}</span>
|
||||
<span class={props.mono ? "font-mono text-[12px] text-ink-100" : "text-ink-100"}>
|
||||
{props.value ?? "…"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, fmtDuration, shortDelivery, shortText } from "../format";
|
||||
import { runCancel, runTrigger } from "../state";
|
||||
import type { WorkItem } from "../work-items";
|
||||
import { IssueLink, PrLink } from "./IssueLink";
|
||||
import { LifecycleStepper } from "./LifecycleStepper";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface IssueCardProps {
|
||||
item: WorkItem;
|
||||
}
|
||||
|
||||
// Verbatim cancel-confirm copy retained from the previous running table so the
|
||||
// operator-facing wording does not drift.
|
||||
const CANCEL_CONFIRM =
|
||||
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.";
|
||||
|
||||
// One issue = one card. Full shape when the item has an issue ref (the
|
||||
// common case); compact shape for orphan deliveries with no bound issue.
|
||||
// State tint is carried by `data-bucket` + CSS — no side-stripe, no shadow.
|
||||
export function IssueCard(props: IssueCardProps): JSX.Element {
|
||||
const item = (): WorkItem => props.item;
|
||||
const ref = () => item().ref;
|
||||
const full = (): boolean => ref() !== null;
|
||||
|
||||
return (
|
||||
<article class="rmp-card" data-bucket={item().bucket}>
|
||||
<Show when={full()} fallback={<CompactHead item={item()} />}>
|
||||
<div class="rmp-card-head">
|
||||
<div class="rmp-card-id">
|
||||
<Pill>{item().classification ?? "issue"}</Pill>
|
||||
<IssueLink repo={ref()!.repo} number={String(ref()!.number)} />
|
||||
</div>
|
||||
<ActivityPill item={item()} />
|
||||
</div>
|
||||
<LifecycleStepper
|
||||
state={item().issueState}
|
||||
classification={item().classification}
|
||||
failed={item().bucket === "failed"}
|
||||
live={item().bucket === "running"}
|
||||
/>
|
||||
</Show>
|
||||
<MetaRow item={item()} />
|
||||
<Show when={item().bucket === "failed" && item().error}>
|
||||
<div class="rmp-card-error err-cell" title={item().error ?? ""}>
|
||||
{shortText(item().error, 200)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
CONFIG.replayEnabled &&
|
||||
(item().bucket === "failed" ||
|
||||
(item().bucket === "running" && !item().inflightOnly))
|
||||
}
|
||||
>
|
||||
<div class="rmp-card-actions">
|
||||
<Show when={item().bucket === "failed" && item().deliveryId}>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => void runTrigger({ mode: "retry", delivery_id: item().deliveryId })}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={item().bucket === "running" && item().deliveryId}>
|
||||
<button class="tiny danger" onClick={() => void cancel(item().deliveryId)}>
|
||||
cancel
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactHead(props: { item: WorkItem }): JSX.Element {
|
||||
return (
|
||||
<div class="rmp-card-head">
|
||||
<div class="rmp-card-id">
|
||||
<code>{shortDelivery(props.item.deliveryId)}</code>
|
||||
</div>
|
||||
<ActivityPill item={props.item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPill(props: { item: WorkItem }): JSX.Element {
|
||||
const state = (): string | undefined =>
|
||||
props.item.latestEvent?.state ?? (props.item.bucket === "running" ? "running" : undefined);
|
||||
const label = (): string =>
|
||||
props.item.latestEvent?.state ??
|
||||
(props.item.bucket === "running" ? (props.item.inflightOnly ? "inflight" : "running") : "—");
|
||||
return (
|
||||
<Pill state={state()} dot={props.item.bucket === "running"}>
|
||||
{label()}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow(props: { item: WorkItem }): JSX.Element {
|
||||
const item = (): WorkItem => props.item;
|
||||
const live = () => item().live;
|
||||
|
||||
// Only the applicable fragments render; the CSS layer draws a · separator
|
||||
// before every non-first child, so we never emit leading/trailing dots.
|
||||
return (
|
||||
<div class="rmp-card-meta">
|
||||
<Show when={live()}>
|
||||
<Show when={live()!.model}>
|
||||
<code title={live()!.model ?? ""}>{live()!.model}</code>
|
||||
</Show>
|
||||
<Show when={live()!.last_tool}>
|
||||
<span>
|
||||
last action <code>{live()!.last_tool}</code>{" "}
|
||||
<span class="rmp-card-meta-dim">{fmtAge(live()!.last_tool_ts)}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<span>
|
||||
elapsed <span class="tabular">{liveElapsed(live()!.started_at)}</span>
|
||||
</span>
|
||||
<span>attempt #{live()!.attempts}</span>
|
||||
</Show>
|
||||
<Show when={!live() && item().inflightOnly}>
|
||||
<span>held by pool</span>
|
||||
</Show>
|
||||
<Show when={item().branch}>
|
||||
<code>{item().branch}</code>
|
||||
</Show>
|
||||
<Show when={item().ref && item().prNumber != null}>
|
||||
<PrLink repo={item().ref!.repo} number={item().prNumber} />
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
item().bucket !== "running" &&
|
||||
item().bucket !== "failed" &&
|
||||
item().latestEvent
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{item().latestEvent!.event_type} · attempt #{item().latestEvent!.attempts} ·{" "}
|
||||
{fmtAge(item().latestEvent!.received_at)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function cancel(deliveryId: string): Promise<void> {
|
||||
if (!window.confirm(CANCEL_CONFIRM)) return;
|
||||
await runCancel(deliveryId);
|
||||
}
|
||||
|
||||
// Elapsed for a live run. Guards against invalid/unparseable timestamps so we
|
||||
// never render a NaN-duration — falls back to "—".
|
||||
function liveElapsed(startedAt: string | null): string {
|
||||
if (!startedAt) return "—";
|
||||
const t = Date.parse(startedAt);
|
||||
if (Number.isNaN(t)) return "—";
|
||||
return fmtDuration((Date.now() - t) / 1000);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, shortText } from "../format";
|
||||
import { statusResource } from "../state";
|
||||
import { type IssueRow, type LatestEvent, TERMINAL_ISSUE_STATES } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink, PrLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface IssuesProps {
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
export function Issues(props: IssuesProps): JSX.Element {
|
||||
const active = (): IssueRow[] => {
|
||||
const s = statusResource();
|
||||
if (!s) return [];
|
||||
return s.issues.filter((i) => !TERMINAL_ISSUE_STATES.has(i.state));
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="active issues" accessory={<span class="tabular">{active().length}</span>}>
|
||||
<Show when={active().length} fallback={<div class="empty">no active issues</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>state</th>
|
||||
<th>last event</th>
|
||||
<th>class</th>
|
||||
<th>branch</th>
|
||||
<th>pr</th>
|
||||
<th>error</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={active()}>
|
||||
{(issue) => <IssueRowView issue={issue} onRetry={props.onRetry} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
issue: IssueRow;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
function IssueRowView(props: RowProps): JSX.Element {
|
||||
const ev = (): LatestEvent | null => props.issue.latest_event;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<IssueLink repo={props.issue.repo} number={props.issue.number} />
|
||||
</td>
|
||||
<td>
|
||||
<Pill>{props.issue.state}</Pill>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={ev()} fallback={<span class="text-ink-400">—</span>}>
|
||||
{(latest) => (
|
||||
<>
|
||||
<Pill state={latest().state}>{latest().state}</Pill>
|
||||
<span class="meta-line">
|
||||
{latest().event_type} · attempt #{latest().attempts} ·{" "}
|
||||
{fmtAge(latest().received_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.issue.classification ?? ""}</td>
|
||||
<td>
|
||||
{props.issue.branch ? (
|
||||
<code>{props.issue.branch}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<PrLink repo={props.issue.repo} number={props.issue.pr_number} />
|
||||
</td>
|
||||
<td class="err-cell">
|
||||
<Show
|
||||
when={ev()?.state === "failed" && ev()?.last_error}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<span title={ev()?.last_error ?? ""}>{shortText(ev()?.last_error)}</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && ev()?.state === "failed"}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => {
|
||||
const latest = ev();
|
||||
if (latest) props.onRetry(latest.delivery_id);
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import {
|
||||
CODE_STAGES,
|
||||
SIMPLE_CLASSIFICATIONS,
|
||||
SIMPLE_STAGES,
|
||||
stageOrdinal,
|
||||
} from "../work-items";
|
||||
|
||||
export interface LifecycleStepperProps {
|
||||
state: string | null;
|
||||
classification: string | null;
|
||||
failed: boolean;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
type NodeState = "complete" | "current" | "failed" | "pending";
|
||||
|
||||
// Horizontal lifecycle track. Picks the 2-node "simple" path for non-code
|
||||
// classifications (questions, enhancements, proposals) and the 5-node code
|
||||
// path otherwise. State is carried entirely by data attributes so the CSS
|
||||
// layer owns colour + the running pulse; this component stays dumb.
|
||||
//
|
||||
// Layout: equal-width cells (flex:1) so dot centres are evenly spaced; the
|
||||
// connector for cell i is absolutely positioned from this dot's centre to the
|
||||
// next dot's centre (one cell width), and the dot paints over it.
|
||||
export function LifecycleStepper(props: LifecycleStepperProps): JSX.Element {
|
||||
const stages = (): readonly string[] =>
|
||||
props.classification != null && SIMPLE_CLASSIFICATIONS.has(props.classification)
|
||||
? SIMPLE_STAGES
|
||||
: CODE_STAGES;
|
||||
const ord = (): number => stageOrdinal(props.state);
|
||||
|
||||
const nodeState = (i: number): NodeState => {
|
||||
const o = ord();
|
||||
if (i < o) return "complete";
|
||||
if (i === o) return props.failed ? "failed" : "current";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="rmp-step">
|
||||
<For each={stages()}>
|
||||
{(stage, i) => (
|
||||
<div class="rmp-step-cell">
|
||||
<Show when={i() < stages().length - 1}>
|
||||
<div class="rmp-step-seg" data-state={i() < ord() ? "complete" : "pending"} />
|
||||
</Show>
|
||||
<div
|
||||
class="rmp-step-node"
|
||||
data-state={nodeState(i())}
|
||||
data-live={nodeState(i()) === "current" && props.live}
|
||||
/>
|
||||
<span class="rmp-step-label" data-reached={i() <= ord()}>
|
||||
{stage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { statusResource } from "../state";
|
||||
import { buildWorkItems, type WorkItem } from "../work-items";
|
||||
import { IssueCard } from "./IssueCard";
|
||||
|
||||
// The unified Operations surface: every active work item as one lifecycle
|
||||
// card. Subsumes the old running, failed, and active issue tables. Failed
|
||||
// items sort first; running items show live agent activity. No GlassCard
|
||||
// wrapper — the cards ARE the card layer (no nested cards).
|
||||
export function Pipeline(): JSX.Element {
|
||||
const items = (): WorkItem[] => {
|
||||
const s = statusResource();
|
||||
return s ? buildWorkItems(s) : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={statusResource() !== undefined} fallback={<PipelineSkeleton />}>
|
||||
<Show when={items().length > 0} fallback={<PipelineEmpty />}>
|
||||
<div class="rmp-pipeline-head">
|
||||
<span class="rmp-pipeline-head-label">pipeline</span>
|
||||
<span class="rmp-pipeline-head-count tabular">{items().length}</span>
|
||||
</div>
|
||||
<div class="rmp-card-grid">
|
||||
<For each={items()}>{(item) => <IssueCard item={item} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
// First-load placeholder — skeleton, not spinner. Two cards so the grid shape
|
||||
// is legible before data arrives.
|
||||
function PipelineSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div class="rmp-card-grid">
|
||||
<div class="rmp-card-skeleton" />
|
||||
<div class="rmp-card-skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Teaching empty state: names what the surface does and what it's waiting on,
|
||||
// plus the watched repos so an idle console still explains itself.
|
||||
function PipelineEmpty(): JSX.Element {
|
||||
const allowlist = (): string[] => statusResource()?.runtime.repo_allowlist ?? [];
|
||||
return (
|
||||
<div class="rmp-pipeline-empty">
|
||||
<div class="rmp-pipeline-empty-title">
|
||||
No active work — robomp is idle, waiting for GitHub webhook events
|
||||
</div>
|
||||
<Show when={allowlist().length > 0}>
|
||||
<div class="rmp-pipeline-empty-sub">watching {allowlist().join(", ")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
|
||||
import { statusResource } from "../state";
|
||||
import { EVENT_STATE_ORDER, type EventState } from "../types";
|
||||
|
||||
const ACCENT: Record<EventState, string> = {
|
||||
queued: "text-[#9ec9ff]",
|
||||
running: "text-[#ffe26b]",
|
||||
done: "text-[#7fe5a3]",
|
||||
failed: "text-[#ff8e85]",
|
||||
skipped: "text-ink-300",
|
||||
};
|
||||
|
||||
export function Stats(): JSX.Element {
|
||||
const counts = (): Record<EventState, 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;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
class="glass glass-rise rounded-[22px] grid gap-px overflow-hidden"
|
||||
style={{
|
||||
"grid-template-columns": "repeat(5, minmax(0, 1fr))",
|
||||
"background-color": "rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
title="newest non-skipped event per issue"
|
||||
>
|
||||
<For each={EVENT_STATE_ORDER}>
|
||||
{(state) => (
|
||||
<div
|
||||
class="px-5 py-5 flex flex-col gap-1.5"
|
||||
style={{ "background-color": "rgba(8, 11, 16, 0.55)" }}
|
||||
>
|
||||
<span class="eyebrow">{state}</span>
|
||||
<span class={`text-[34px] leading-none font-semibold tabular ${ACCENT[state]}`}>
|
||||
{counts()[state] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import {
|
||||
type ThemePreference,
|
||||
preference,
|
||||
setPreference,
|
||||
} from "../theme";
|
||||
|
||||
// 3-way system → light → dark → system toggle. robomp has no icon library, so
|
||||
// each preference renders an inline 16px SVG (monitor / sun / moon) styled with
|
||||
// the existing `.btn.ghost.tiny` chrome.
|
||||
|
||||
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
|
||||
system: "light",
|
||||
light: "dark",
|
||||
dark: "system",
|
||||
};
|
||||
|
||||
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
|
||||
system: "System theme",
|
||||
light: "Light theme",
|
||||
dark: "Dark theme",
|
||||
};
|
||||
|
||||
function MonitorIcon(): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/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="14" rx="2" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SunIcon(): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/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"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon(): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeToggle(): JSX.Element {
|
||||
const icon = (): JSX.Element => {
|
||||
const p = preference();
|
||||
if (p === "light") return <SunIcon />;
|
||||
if (p === "dark") return <MoonIcon />;
|
||||
return <MonitorIcon />;
|
||||
};
|
||||
|
||||
const label = (): string => `${PREFERENCE_LABEL[preference()]} — click to switch`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost tiny"
|
||||
onClick={() => setPreference(NEXT_PREFERENCE[preference()])}
|
||||
aria-label={label()}
|
||||
title={label()}
|
||||
>
|
||||
{icon()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,22 @@ import { GlassCard } from "./GlassCard";
|
||||
const STATUS_TONE = {
|
||||
idle: "text-ink-400",
|
||||
pending: "text-ink-200",
|
||||
ok: "text-[#7fe5a3]",
|
||||
err: "text-[#ff8e85]",
|
||||
ok: "text-ok",
|
||||
err: "text-err",
|
||||
} as const;
|
||||
|
||||
export function Trigger(): JSX.Element {
|
||||
export interface TriggerProps {
|
||||
/**
|
||||
* `bar` — slim one-line command row for the Operations landing view.
|
||||
* `panel` — fuller form (current look) for the Triage view.
|
||||
* Both gate on CONFIG.replayEnabled; read-only degrades to a slim inline
|
||||
* chip (bar) or the first-run explainer panel (panel, handled by Triage).
|
||||
*/
|
||||
variant?: "bar" | "panel";
|
||||
}
|
||||
|
||||
export function Trigger(props: TriggerProps): JSX.Element {
|
||||
const variant = (): "bar" | "panel" => props.variant ?? "panel";
|
||||
const [issue, setIssue] = createSignal<string>("");
|
||||
|
||||
const validate = (): string | null => {
|
||||
@@ -32,8 +43,63 @@ export function Trigger(): JSX.Element {
|
||||
void runTrigger({ mode: "retry", issue: value });
|
||||
};
|
||||
|
||||
// ── bar variant: slim command row (Operations). Read-only → inline chip. ──
|
||||
return (
|
||||
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN or issue url</span>}>
|
||||
<Show when={variant() === "bar"} fallback={<PanelBody issue={issue} setIssue={setIssue} handleTriage={handleTriage} handleRetry={handleRetry} validate={validate} />}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
<div class="rmp-trigger-bar">
|
||||
<span class="rmp-readonly-chip">trigger disabled · read-only</span>
|
||||
<span class="rmp-trigger-bar-status">set ROBOMP_REPLAY_TOKEN to enable</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="rmp-trigger-bar">
|
||||
<input
|
||||
type="text"
|
||||
spellcheck={false}
|
||||
placeholder="owner/repo#NN or issue url"
|
||||
autocomplete="off"
|
||||
value={issue()}
|
||||
onInput={(ev) => setIssue(ev.currentTarget.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") handleTriage();
|
||||
}}
|
||||
class="flex-1 min-w-[200px] font-mono"
|
||||
/>
|
||||
<button class="primary tiny" onClick={handleTriage}>
|
||||
triage
|
||||
</button>
|
||||
<button class="tiny" onClick={handleRetry}>
|
||||
retry latest
|
||||
</button>
|
||||
<Show when={triggerStatus().text}>
|
||||
<span class={`rmp-trigger-bar-status ${STATUS_TONE[triggerStatus().kind]}`}>
|
||||
{triggerStatus().text}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelBodyProps {
|
||||
issue: () => string;
|
||||
setIssue: (v: string) => void;
|
||||
handleTriage: () => void;
|
||||
handleRetry: () => void;
|
||||
validate: () => string | null;
|
||||
}
|
||||
|
||||
// ── panel variant: fuller form (Triage). Read-only handled by the Triage
|
||||
// view's first-run panel; if replay is on, this is the active form. ──
|
||||
function PanelBody(p: PanelBodyProps): JSX.Element {
|
||||
return (
|
||||
<GlassCard
|
||||
heading="trigger"
|
||||
>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
@@ -50,22 +116,22 @@ export function Trigger(): JSX.Element {
|
||||
spellcheck={false}
|
||||
placeholder="octo/widget#42 or https://github.com/owner/repo/issues/42"
|
||||
autocomplete="off"
|
||||
value={issue()}
|
||||
onInput={(ev) => setIssue(ev.currentTarget.value)}
|
||||
value={p.issue()}
|
||||
onInput={(ev) => p.setIssue(ev.currentTarget.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") handleTriage();
|
||||
if (ev.key === "Enter") p.handleTriage();
|
||||
}}
|
||||
class="flex-1 min-w-[220px] font-mono"
|
||||
/>
|
||||
<button class="primary" onClick={handleTriage}>
|
||||
<button class="primary" onClick={p.handleTriage}>
|
||||
fetch & triage
|
||||
</button>
|
||||
<button onClick={handleRetry}>retry latest run</button>
|
||||
<button onClick={p.handleRetry}>retry latest run</button>
|
||||
</div>
|
||||
<Show
|
||||
when={triggerStatus().text}
|
||||
fallback={
|
||||
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{validate() ?? "ready"}</span>
|
||||
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{p.validate() ?? "ready"}</span>
|
||||
}
|
||||
>
|
||||
<span class={`text-[12px] ${STATUS_TONE[triggerStatus().kind]}`}>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, fmtDuration, shortDelivery, splitIssueKey } from "../format";
|
||||
import { runCancel, statusResource } from "../state";
|
||||
import type { RunningEvent } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
delivery_id: string;
|
||||
issue_key: string | null;
|
||||
event_type: string;
|
||||
attempts: number;
|
||||
model: string | null;
|
||||
last_tool: string | null;
|
||||
last_tool_ts: string | null;
|
||||
started_at: string | null;
|
||||
inflight_only: boolean;
|
||||
}
|
||||
|
||||
function rowsFor(running: RunningEvent[], inflight: string[]): Row[] {
|
||||
const out: Row[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of running) {
|
||||
const key = e.issue_key ?? e.delivery_id;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: e.delivery_id,
|
||||
issue_key: e.issue_key,
|
||||
event_type: e.event_type,
|
||||
attempts: e.attempts,
|
||||
model: e.model,
|
||||
last_tool: e.last_tool,
|
||||
last_tool_ts: e.last_tool_ts,
|
||||
started_at: e.started_at ?? e.received_at,
|
||||
inflight_only: false,
|
||||
});
|
||||
}
|
||||
for (const key of inflight) {
|
||||
if (seen.has(key)) continue;
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: "",
|
||||
issue_key: key,
|
||||
event_type: "",
|
||||
attempts: 0,
|
||||
model: null,
|
||||
last_tool: null,
|
||||
last_tool_ts: null,
|
||||
started_at: null,
|
||||
inflight_only: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cancelDelivery(deliveryId: string): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCancel(deliveryId);
|
||||
}
|
||||
|
||||
function elapsed(startedAt: string | null): string {
|
||||
if (!startedAt) return "—";
|
||||
const t = Date.parse(startedAt);
|
||||
if (Number.isNaN(t)) return "—";
|
||||
return fmtDuration((Date.now() - t) / 1000);
|
||||
}
|
||||
|
||||
export function Working(): JSX.Element {
|
||||
const rows = (): Row[] => {
|
||||
const s = statusResource();
|
||||
return s ? rowsFor(s.running_events, s.inflight) : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="currently working" accessory={<span class="tabular">{rows().length}</span>}>
|
||||
<Show when={rows().length} fallback={<div class="empty">idle — waiting for events</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>event</th>
|
||||
<th>state</th>
|
||||
<th>elapsed</th>
|
||||
<th>model</th>
|
||||
<th>last action</th>
|
||||
<th>attempt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={rows()}>{(r) => <WorkingRow row={r} />}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingRow(props: { row: Row }): JSX.Element {
|
||||
const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Show when={ref().number} fallback={<code>{shortDelivery(props.row.delivery_id)}</code>}>
|
||||
<IssueLink repo={ref().repo} number={ref().number} />
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.row.event_type || "—"}</td>
|
||||
<td>
|
||||
<Pill state="running" dot>
|
||||
{props.row.inflight_only ? "inflight" : "running"}
|
||||
</Pill>
|
||||
</td>
|
||||
<td class="tabular">{elapsed(props.row.started_at)}</td>
|
||||
<td>
|
||||
{props.row.model ? (
|
||||
<code title={props.row.model}>{props.row.model}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{props.row.last_tool ? (
|
||||
<span>
|
||||
<code>{props.row.last_tool}</code>
|
||||
<span class="text-ink-400 ml-2">{fmtAge(props.row.last_tool_ts)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span class="text-ink-400">{props.row.inflight_only ? "held by pool" : "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-ink-300 tabular">
|
||||
{props.row.inflight_only ? "—" : `#${props.row.attempts}`}
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && !props.row.inflight_only}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button class="tiny danger" onClick={() => cancelDelivery(props.row.delivery_id)}>
|
||||
cancel
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
import { runTrigger } from "../../state";
|
||||
import { Events } from "../Events";
|
||||
import { Logs } from "../Logs";
|
||||
|
||||
// Activity (investigate mode). Full history table + full-height log stream.
|
||||
// Logs finally get real vertical room.
|
||||
export function Activity(): JSX.Element {
|
||||
const handleRetry = (deliveryId: string): void => {
|
||||
void runTrigger({ mode: "retry", delivery_id: deliveryId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Events onRetry={handleRetry} />
|
||||
<Logs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
import { Pipeline } from "../Pipeline";
|
||||
import { Trigger } from "../Trigger";
|
||||
|
||||
// Operations (default landing view). A single unified pipeline: a slim
|
||||
// trigger command row on top, then every active work item drawn as a
|
||||
// lifecycle card (failed first, running live). Replaces the old three-table
|
||||
// layout (working / needs-attention / active-issues) — Pipeline derives all
|
||||
// three states from one status poll. Read-only collapses the trigger to a
|
||||
// chip; the cards lose their retry/cancel actions.
|
||||
export function Operations(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Trigger variant="bar" />
|
||||
<Pipeline />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../../config";
|
||||
import { Browse } from "../Browse";
|
||||
import { GlassCard } from "../GlassCard";
|
||||
import { Trigger } from "../Trigger";
|
||||
|
||||
// Triage (act mode). Full trigger form + issue browser. When replay is off,
|
||||
// renders ONE composed first-run panel (env + what unlocks) instead of two
|
||||
// large disabled cards. The rail marks this nav item locked + dimmed.
|
||||
export function Triage(): JSX.Element {
|
||||
return (
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={<FirstRunPanel />}
|
||||
>
|
||||
<Trigger variant="panel" />
|
||||
<Browse />
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function FirstRunPanel(): JSX.Element {
|
||||
return (
|
||||
<GlassCard heading="triage" accessory={<span class="rmp-readonly-chip">locked</span>}>
|
||||
<div class="rmp-firstrun">
|
||||
<div class="rmp-firstrun-head">
|
||||
<span class="rmp-readonly-chip">read-only</span>
|
||||
<h3 class="rmp-firstrun-title">manual actions are disabled</h3>
|
||||
</div>
|
||||
<p class="rmp-firstrun-body">
|
||||
Triage and retry let you drive the bot by hand — fetch a fresh issue, re-run a failed
|
||||
delivery, or browse the open backlog. These actions are gated behind a server-side
|
||||
replay token so the dashboard is safe to expose read-only.
|
||||
</p>
|
||||
<ul class="rmp-firstrun-steps">
|
||||
<li>
|
||||
set <code>ROBOMP_REPLAY_TOKEN</code> in the robomp server environment
|
||||
</li>
|
||||
<li>restart the server so the token is loaded</li>
|
||||
<li>reload — the Operations trigger bar and this Triage view unlock</li>
|
||||
</ul>
|
||||
<p class="rmp-firstrun-note">
|
||||
Monitoring stays fully live: Operations and Activity read the same 3s poll regardless of
|
||||
this setting.
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user