swarm-ui: a review runs page lists the review classifier's recent runs
One card per run, newest first, from GET /api/review-classifier/runs: repo, PR, head commit, trigger, when, how long, how many classifications and the outcome as a badge. A card opens to the try-out's result table, the overall verdict, the reason and the posted review's link. TryOutResult takes the answer it shows rather than a whole try-out result, and its styles move to their own file.
This commit is contained in:
parent
2fc0166427
commit
5193a101a6
9 changed files with 355 additions and 16 deletions
|
|
@ -16,6 +16,7 @@ domain, covers host-level detail for one hive.
|
|||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed |
|
||||
| `/review-tryout` | try a review classification against a real PR |
|
||||
|
||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||
|
|
@ -53,6 +54,33 @@ page. Selecting outside it, or Escape, closes it. You can stop or pause the
|
|||
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||
its agent's details.
|
||||
|
||||
### Review runs
|
||||
|
||||
`/review-runs` lists what the [review classifier](review-classifier.md) has
|
||||
run, newest first, one card per run. A card shows the repo and PR, the head
|
||||
commit, whether the run was a try-out or a requested review, how long ago it
|
||||
started, how long it took, how many classifications ran, and the outcome as a
|
||||
badge. Select a card to open it: it shows the same result table as the
|
||||
[try-out](#review-try-out), the overall verdict, the reason when the outcome
|
||||
has one, and a link to the posted review when there is one.
|
||||
|
||||
An outcome can be more than approve or request changes:
|
||||
|
||||
| badge | what happened |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `approved` | the run posted an approval |
|
||||
| `changes requested` | the run posted a request for changes |
|
||||
| `comment only` | there was no clean verdict, so it posted a comment |
|
||||
| `queue full` | the queue was full, so it turned the run away |
|
||||
| `post failed` | the run finished but posting the review failed |
|
||||
| `done` | a try-out finished |
|
||||
| `failed` | the run failed; the card says why |
|
||||
|
||||
The page refreshes every 30 seconds, and a card you opened stays open when it
|
||||
does. An empty list says there are no runs yet, and a failed load shows the reason the
|
||||
controller gave. The page shows no token counts, cache counts or cost, and
|
||||
stores and posts nothing.
|
||||
|
||||
### Review try-out
|
||||
|
||||
`/review-tryout` shows how the [review classifier](review-classifier.md)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ComponentsPage } from "./pages/ComponentsPage.js";
|
|||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { HivesPage } from "./pages/HivesPage.js";
|
||||
import { IssueReportPage } from "./pages/IssueReportPage.js";
|
||||
import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js";
|
||||
import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
|
||||
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
||||
import { Panel } from "./ui/panel/Panel.js";
|
||||
|
|
@ -34,6 +35,7 @@ export function App() {
|
|||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/schedules" component={SchedulesPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/review-runs" component={ReviewRunsPage} />
|
||||
<Route path="/review-tryout" component={ReviewTryOutPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
<Route component={NotFound} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
/* The run list: one card per run, the head row a button that opens it. */
|
||||
.ui-runs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.ui-runs-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em 0.75em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-runs-head:focus-visible {
|
||||
outline: 2px solid var(--purple);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ui-runs-title {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.ui-runs-meta {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-runs-head .ui-runs-meta {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.ui-runs-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.ui-runs-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-runs-empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// <ReviewRunsPage> — the review classifier's recent runs, newest first
|
||||
// (`docs/swarm/review-classifier.md`, "Recent runs"): which PRs it ran on,
|
||||
// what it answered, and which runs failed or were turned away. One card per
|
||||
// run; selecting a card expands it to the same result table the try-out page
|
||||
// shows. Reads `GET /api/review-classifier/runs`. The page stores nothing and
|
||||
// posts nothing, and shows no token counts, cache counts or cost.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { TryOutResult } from "../review-tryout/TryOutResult.js";
|
||||
import {
|
||||
OUTCOME_LABEL,
|
||||
OUTCOME_TONE,
|
||||
TRIGGER_LABEL,
|
||||
classificationCount,
|
||||
durationLabel,
|
||||
type ClassifierRun,
|
||||
} from "./RunsTypes.js";
|
||||
import "./ReviewRunsPage.css";
|
||||
|
||||
const RUNS = "/api/review-classifier/runs";
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
function RunCard({
|
||||
run,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
run: ClassifierRun;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const bodyId = `review-run-${run.id}`;
|
||||
return (
|
||||
<Card class="ui-runs-card">
|
||||
<button
|
||||
type="button"
|
||||
class="ui-runs-head"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span class="ui-runs-title">
|
||||
<strong>
|
||||
{run.repo} #{run.pr}
|
||||
</strong>
|
||||
{run.head_sha ? <code>{run.head_sha.slice(0, 7)}</code> : null}
|
||||
</span>
|
||||
<Badge
|
||||
tone={OUTCOME_TONE[run.outcome]}
|
||||
value={OUTCOME_LABEL[run.outcome]}
|
||||
/>
|
||||
<span class="ui-runs-meta">
|
||||
{TRIGGER_LABEL[run.trigger]} ·{" "}
|
||||
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
|
||||
{durationLabel(run.started_unix, run.finished_unix)} ·{" "}
|
||||
{classificationCount(run)}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div class="ui-runs-body" id={bodyId}>
|
||||
{run.detail ? <p class="ui-runs-detail">{run.detail}</p> : null}
|
||||
{run.rows && run.rows.length > 0 ? (
|
||||
<TryOutResult
|
||||
result={run}
|
||||
filterKey="swarm-ui:review-runs:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
{run.review_url ? (
|
||||
<p class="ui-runs-link">
|
||||
<a href={run.review_url} target="_blank" rel="noreferrer">
|
||||
the posted review
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<p class="ui-runs-meta">Requested by {run.requester}.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewRunsPage() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [runs, setRuns] = useState<ClassifierRun[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Which cards are expanded. Survives a refresh, so a list that updates
|
||||
// under you does not fold the card you are reading.
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(RUNS);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRuns((await res.json()) as ClassifierRun[]);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => void load());
|
||||
|
||||
function toggle(id: string) {
|
||||
setOpen((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="review runs"
|
||||
icon="📜"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
id="review-runs-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
The PRs the review classifier ran on, newest first: what it answered,
|
||||
and which runs failed or were turned away. Select a run to see its
|
||||
result table.
|
||||
</p>
|
||||
{error ? (
|
||||
<ApiErrorPanel context="the runs could not be loaded" problem={error} />
|
||||
) : null}
|
||||
{runs !== null && runs.length === 0 && !error ? (
|
||||
<p class="ui-runs-empty">No runs yet.</p>
|
||||
) : null}
|
||||
<div class="ui-runs-list">
|
||||
{(runs ?? []).map((run) => (
|
||||
<RunCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
open={open.has(run.id)}
|
||||
onToggle={() => toggle(run.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// Wire shape of the review classifier's run list
|
||||
// (`GET /api/review-classifier/runs`), and how the page words it. Rows and
|
||||
// the overall verdict are the try-out's own shapes.
|
||||
import type { BadgeTone } from "@hive/shared/badge.js";
|
||||
import type { TryOutOverall, TryOutRow } from "../review-tryout/TryOutTypes.js";
|
||||
|
||||
export type RunTrigger = "try-out" | "review";
|
||||
|
||||
export type RunOutcome =
|
||||
| "approved"
|
||||
| "changes-requested"
|
||||
| "no-verdict"
|
||||
| "queue-full"
|
||||
| "post-failed"
|
||||
| "done"
|
||||
| "failed";
|
||||
|
||||
/** One run, as the list returns it. Fields marked optional are absent when
|
||||
* they do not apply to the run. */
|
||||
export interface ClassifierRun {
|
||||
id: string;
|
||||
trigger: RunTrigger;
|
||||
repo: string;
|
||||
pr: number;
|
||||
requester: string;
|
||||
head_sha?: string;
|
||||
started_unix: number;
|
||||
finished_unix: number;
|
||||
outcome: RunOutcome;
|
||||
/** Why, for an outcome that needs a reason. */
|
||||
detail?: string;
|
||||
rows?: TryOutRow[];
|
||||
overall?: TryOutOverall;
|
||||
/** The review the run posted, when it posted one. */
|
||||
review_url?: string;
|
||||
}
|
||||
|
||||
export const OUTCOME_LABEL: Record<RunOutcome, string> = {
|
||||
approved: "approved",
|
||||
"changes-requested": "changes requested",
|
||||
"no-verdict": "comment only",
|
||||
"queue-full": "queue full",
|
||||
"post-failed": "post failed",
|
||||
done: "done",
|
||||
failed: "failed",
|
||||
};
|
||||
|
||||
export const OUTCOME_TONE: Record<RunOutcome, BadgeTone> = {
|
||||
approved: "positive",
|
||||
"changes-requested": "accent",
|
||||
"no-verdict": "neutral",
|
||||
"queue-full": "warning",
|
||||
"post-failed": "negative",
|
||||
done: "positive",
|
||||
failed: "negative",
|
||||
};
|
||||
|
||||
export const TRIGGER_LABEL: Record<RunTrigger, string> = {
|
||||
"try-out": "try-out",
|
||||
review: "requested review",
|
||||
};
|
||||
|
||||
/** How long a run took, as `45s`, `3m 12s` or `1h 4m`. A clock that ran
|
||||
* backwards reads as `0s`. */
|
||||
export function durationLabel(startedUnix: number, finishedUnix: number) {
|
||||
const total = Math.max(0, Math.round(finishedUnix - startedUnix));
|
||||
if (total < 60) return `${total}s`;
|
||||
const minutes = Math.floor(total / 60);
|
||||
if (minutes < 60) return `${minutes}m ${total % 60}s`;
|
||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
/** How many classifications ran, in words. A run that never got as far as
|
||||
* running any has no rows. */
|
||||
export function classificationCount(run: ClassifierRun): string {
|
||||
const n = run.rows?.length ?? 0;
|
||||
return `${n} ${n === 1 ? "classification" : "classifications"}`;
|
||||
}
|
||||
|
|
@ -62,13 +62,3 @@
|
|||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-tryout-row-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.ui-tryout-overall,
|
||||
.ui-tryout-sha {
|
||||
margin: 0.75em 0 0;
|
||||
}
|
||||
.ui-tryout-sha {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/* A finished run's table, the overall verdict under it and the head commit. */
|
||||
.ui-tryout-row-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.ui-tryout-overall,
|
||||
.ui-tryout-sha {
|
||||
margin: 0.75em 0 0;
|
||||
}
|
||||
.ui-tryout-sha {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Table, type TableColumn } from "../../ui/table/Table.js";
|
||||
import type { TryOutResult as Result, TryOutRow } from "./TryOutTypes.js";
|
||||
import "./TryOutResult.css";
|
||||
|
||||
const VERDICT_LABEL: Record<TryOutRow["verdict"], string> = {
|
||||
match: "match",
|
||||
|
|
@ -44,7 +45,21 @@ const COLUMNS: TableColumn<TryOutRow>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function TryOutResult({ result }: { result: Result }) {
|
||||
/** What the table needs of a run: the try-out's answer, or a listed run's. The
|
||||
* head commit is absent for a run that never read one. */
|
||||
export type TryOutAnswer = Pick<Result, "rows" | "overall"> & {
|
||||
head_sha?: string;
|
||||
};
|
||||
|
||||
export function TryOutResult({
|
||||
result,
|
||||
filterKey = "swarm-ui:review-tryout:table-filters",
|
||||
}: {
|
||||
result: TryOutAnswer;
|
||||
/** Where the column filters are remembered; a page showing several tables
|
||||
* keeps them apart from the try-out's. */
|
||||
filterKey?: string;
|
||||
}) {
|
||||
const rows = result.rows ?? [];
|
||||
return (
|
||||
<div class="ui-tryout-result">
|
||||
|
|
@ -54,7 +69,7 @@ export function TryOutResult({ result }: { result: Result }) {
|
|||
// By position: two classifications may share a name, and a shared key
|
||||
// would let one result render over the other.
|
||||
rowKey={(r) => String(rows.indexOf(r))}
|
||||
storageKey="swarm-ui:review-tryout:table-filters"
|
||||
storageKey={filterKey}
|
||||
emptyMessage="the run returned no classifications"
|
||||
/>
|
||||
{result.overall ? (
|
||||
|
|
@ -66,9 +81,11 @@ export function TryOutResult({ result }: { result: Result }) {
|
|||
/>
|
||||
</p>
|
||||
) : null}
|
||||
<p class="ui-tryout-sha">
|
||||
Ran on head <code>{result.head_sha.slice(0, 12)}</code>
|
||||
</p>
|
||||
{result.head_sha ? (
|
||||
<p class="ui-tryout-sha">
|
||||
Ran on head <code>{result.head_sha.slice(0, 12)}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||
{ href: "/review-runs", label: "review runs", accent: "var(--amber)" },
|
||||
{ href: "/review-tryout", label: "review try-out", accent: "var(--red)" },
|
||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||
];
|
||||
|
|
@ -63,7 +64,12 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
// modifier alongside the base class, not a replacement, so every other
|
||||
// route's layout is untouched. `/agents` joined for the same reason —
|
||||
// its own table view (plus the list+detail split) hits the same cap.
|
||||
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents", "/review-tryout"]);
|
||||
const WIDE_BODY_ROUTES = new Set([
|
||||
"/issues",
|
||||
"/agents",
|
||||
"/review-runs",
|
||||
"/review-tryout",
|
||||
]);
|
||||
|
||||
// `/agents/:name/terminal` (and a subagent's, under
|
||||
// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills
|
||||
|
|
|
|||
Loading…
Reference in a new issue