swarm-ui: a review try-out page
A page to try a review classification against a real PR: repo, PR number and one or more classifications (name, description, polarity, threshold). Run posts to the controller's try-out endpoint and polls until the run is done or failed, saying it is running meanwhile, then shows the review's table, the overall verdict and the head commit. A row that could not run shows its error in place of a reason, and a failed run shows its error. The inputs stay so the run can be edited and repeated. Nothing is stored and nothing is posted to the forge. Route, nav entry and a section in docs/swarm/ui.md.
This commit is contained in:
parent
34bde7a5af
commit
b67517e41b
1 changed files with 552 additions and 1 deletions
|
|
@ -0,0 +1,233 @@
|
|||
// <ReviewTryOutPage> — write a review classification and see how the model
|
||||
// answers it on a real PR, before it goes into a repo's config
|
||||
// (`docs/swarm/review-classifier.md`, "Try-out"). Pick a repo and a PR, write
|
||||
// one or more classifications, run: the page posts them to
|
||||
// swarm-controller's `POST /api/review-classifier/try-out`, which answers
|
||||
// `202 {id}`, and polls `GET .../try-out/{id}` until the run is `done` or
|
||||
// `failed`. A run takes a minute or more, so the page says it is running.
|
||||
//
|
||||
// The inputs stay as typed after a run, so the operator can edit and run
|
||||
// again. The page stores nothing (not even in the browser) and posts nothing
|
||||
// to the forge.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import { ClassificationEditor } from "./ClassificationEditor.js";
|
||||
import { TryOutResult } from "./TryOutResult.js";
|
||||
import {
|
||||
draftProblem,
|
||||
prNumber,
|
||||
repoProblem,
|
||||
toBody,
|
||||
type ClassificationDraft,
|
||||
type TryOutResult as Result,
|
||||
} from "./TryOutTypes.js";
|
||||
import "./ReviewTryOutPage.css";
|
||||
|
||||
const TRY_OUT = "/api/review-classifier/try-out";
|
||||
const POLL_MS = 2000;
|
||||
// More classifications than this are not offered a row; the backend has its
|
||||
// own cap and says so with a 400, which the page shows as it is.
|
||||
const MAX_CLASSIFICATIONS = 10;
|
||||
|
||||
let nextKey = 0;
|
||||
function blankDraft(): ClassificationDraft {
|
||||
return {
|
||||
key: nextKey++,
|
||||
name: "",
|
||||
description: "",
|
||||
polarity: "positive",
|
||||
threshold: "80",
|
||||
};
|
||||
}
|
||||
|
||||
export function ReviewTryOutPage() {
|
||||
const [repo, setRepo] = useState("");
|
||||
const [pr, setPr] = useState("");
|
||||
const [drafts, setDrafts] = useState<ClassificationDraft[]>(() => [
|
||||
blankDraft(),
|
||||
]);
|
||||
// The id of the run being polled; null when none is in flight.
|
||||
const [runId, setRunId] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Whole seconds since the run started, so a long wait shows it is alive.
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
const running = starting || runId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setElapsed(0);
|
||||
const t = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [running]);
|
||||
|
||||
// Polls the run until it leaves `running`. Cancelled by the next run, or by
|
||||
// leaving the page.
|
||||
useEffect(() => {
|
||||
if (runId === null) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stop = (problem: ProblemDetails) => {
|
||||
setError(problem);
|
||||
setRunId(null);
|
||||
};
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${TRY_OUT}/${encodeURIComponent(runId ?? "")}`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (res.status === 404) {
|
||||
stop({
|
||||
detail: "the result expired before it was read: run it again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const problem = await readApiError(res);
|
||||
if (!cancelled) stop(problem);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as Result;
|
||||
if (cancelled) return;
|
||||
setResult(body);
|
||||
if (body.state === "running")
|
||||
timer = setTimeout(() => void poll(), POLL_MS);
|
||||
else setRunId(null);
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) stop({ detail: String(e) });
|
||||
}
|
||||
}
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
const problem =
|
||||
repoProblem(repo) ??
|
||||
(prNumber(pr) === null ? "a PR number is a whole number above 0" : null) ??
|
||||
drafts.map(draftProblem).find((p) => p !== null) ??
|
||||
null;
|
||||
|
||||
async function run(e: Event) {
|
||||
e.preventDefault();
|
||||
const number = prNumber(pr);
|
||||
if (problem !== null || number === null) return;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(TRY_OUT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
repo: repo.trim(),
|
||||
pr: number,
|
||||
classifications: drafts.map(toBody),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRunId(((await res.json()) as { id: string }).id);
|
||||
} catch (err: unknown) {
|
||||
setError({ detail: String(err) });
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function changeDraft(next: ClassificationDraft) {
|
||||
setDrafts((ds) => ds.map((d) => (d.key === next.key ? next : d)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="ui-tryout-page">
|
||||
<Panel title="review try-out" icon="🧪">
|
||||
<p>
|
||||
Run a review classification against a real PR and see how the model
|
||||
answers it. Nothing is posted to the forge, and nothing is saved:
|
||||
change the classification and run it again until it answers the way
|
||||
you want.
|
||||
</p>
|
||||
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
|
||||
<div class="ui-tryout-target">
|
||||
<TextField
|
||||
id="tryout-repo"
|
||||
label="repo"
|
||||
value={repo}
|
||||
placeholder="owner/name"
|
||||
onInput={setRepo}
|
||||
/>
|
||||
<TextField
|
||||
id="tryout-pr"
|
||||
label="PR number"
|
||||
value={pr}
|
||||
pattern="[0-9]+"
|
||||
onInput={setPr}
|
||||
/>
|
||||
</div>
|
||||
{drafts.map((d) => (
|
||||
<ClassificationEditor
|
||||
key={d.key}
|
||||
draft={d}
|
||||
onChange={changeDraft}
|
||||
onRemove={
|
||||
drafts.length > 1
|
||||
? () => setDrafts((ds) => ds.filter((x) => x.key !== d.key))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div class="ui-tryout-actions">
|
||||
<Button
|
||||
onClick={() => setDrafts((ds) => [...ds, blankDraft()])}
|
||||
disabled={drafts.length >= MAX_CLASSIFICATIONS}
|
||||
>
|
||||
add a classification
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={running || problem !== null}
|
||||
>
|
||||
{running ? "running…" : "run"}
|
||||
</Button>
|
||||
</div>
|
||||
{problem !== null ? <p class="ui-tryout-hint">{problem}</p> : null}
|
||||
</form>
|
||||
</Panel>
|
||||
{running || result || error ? (
|
||||
<Panel title="result" icon="📋">
|
||||
{running ? (
|
||||
<p class="ui-tryout-running" role="status">
|
||||
Running — a run takes a minute or more ({elapsed}s so far).
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<ApiErrorPanel context="the try-out failed" problem={error} />
|
||||
) : null}
|
||||
{result?.state === "failed" ? (
|
||||
<ApiErrorPanel
|
||||
context="the run failed"
|
||||
problem={{
|
||||
title: "the classifier could not finish",
|
||||
detail: result.error ?? "no reason given",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{result?.state === "done" ? <TryOutResult result={result} /> : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue