swarm-ui: add the custom issue-report page

New /issues route: a sortable, filterable table over open issues across
every repo that has one -- repo picker (default: no filter, every repo
combined), hide-blocked toggle, and a label multi-select, consuming
swarm-controller's GET /api/repos + GET /api/issue-report / GET
/api/repos/{org}/{repo}/issue-report (see hyperhive#3831 for the row
shape). blocked and depended_on_by_count arrive pre-resolved per row --
this page does no dependency-graph walking of its own, just sort/filter
over what it's given. Default sort is depended_on_by_count descending,
matching mara's framing of the report's headline ordering.

Widened Table's TableColumn.header from string to ComponentChildren so
a column can carry a real clickable sort-toggle button instead of
forking a second table primitive for this one page.
This commit is contained in:
iris 2026-08-31 18:26:06 +02:00 committed by mara
commit e815c7cb5c
5 changed files with 338 additions and 1 deletions

View file

@ -6,6 +6,7 @@ import { AgentsPage } from './pages/AgentsPage.js';
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 { Panel } from './ui/panel/Panel.js';
function NotFound() {
@ -23,6 +24,7 @@ export function App() {
<Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/jobs" component={JobsPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/components" component={ComponentsPage} />
<Route component={NotFound} />
</Switch>

View file

@ -0,0 +1,43 @@
/* <IssueReportPage> the repo-picker + hide-blocked toggle sit in one
row (mirrors CreateAgentForm's row/wrap pattern: flex-wrap so narrow
viewports stack instead of overflowing), the label chips get their
own row below since the set is open-ended and shouldn't fight the
controls row for width. Sort buttons live inside the table's own
`<th>` cells (Table.tsx's `header` now accepts real markup for
exactly this) so they inherit the table's header styling for free
only the pointer cursor + no-underline reset is this page's to own. */
.issue-report-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 1.25em;
margin-bottom: 1em;
}
.issue-report-toggle {
display: flex;
align-items: center;
gap: 0.4em;
cursor: pointer;
}
.issue-report-labels {
display: flex;
flex-wrap: wrap;
gap: 0.6em;
margin-bottom: 1em;
}
.issue-report-label-chip {
display: flex;
align-items: center;
gap: 0.35em;
cursor: pointer;
color: var(--muted);
}
.issue-report-sort-btn {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
}

View file

@ -0,0 +1,287 @@
// <IssueReportPage> — mara's custom issue report: browse open issues
// across every repo that has one, sortable/filterable her own way, with
// "hide blocked (open dependency)" as the headline filter and a rank
// column for how many other open issues depend on each one. Backed by
// swarm-controller's `GET /api/repos` (dropdown source, only repos with
// an open issue), `GET /api/issue-report` (default — every repo
// combined) and `GET /api/repos/{org}/{repo}/issue-report` (once a
// specific repo is picked) — `blocked` and `depended_on_by_count` both
// arrive pre-resolved per row, so nothing here does its own
// dependency-graph walk.
//
// Sorting and the label/hide-blocked filters are client-side over
// whatever's currently loaded — only the repo selection re-fetches.
// Default sort is `depended_on_by_count` descending: mara's own framing
// ("i would also like to rank them by how many issues depend on them")
// reads as the report's headline ordering, not just one more column.
//
// No `RefreshIntervalPicker`/auto-poll like the roster pages — this
// reads as a "generate a report" action rather than a live status view
// (mara: a "generating report" spinner sub-5s is fine, doesn't need to
// be instant), so it fetches once per repo-selection change instead of
// polling.
import { useEffect, useMemo, 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 { Panel } from '../ui/panel/Panel.js';
import { SelectField } from '../ui/select-field/SelectField.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import './IssueReportPage.css';
interface IssueReportRow {
repo: string;
number: number;
title: string;
labels: string[];
assignee: string | null;
html_url: string | null;
blocked: boolean;
depended_on_by_count: number;
}
type SortKey = 'repo' | 'number' | 'title' | 'assignee' | 'blocked' | 'depended_on_by_count';
type SortDir = 'asc' | 'desc';
// Sentinel `<select>` value for "every repo" — `''` can't collide with a
// real `owner/name` value, which always contains a slash.
const ALL_REPOS = '';
function splitRepo(repo: string): { org: string; name: string } | null {
const i = repo.indexOf('/');
if (i < 0) return null;
return { org: repo.slice(0, i), name: repo.slice(i + 1) };
}
function compareRows(a: IssueReportRow, b: IssueReportRow, key: SortKey): number {
switch (key) {
case 'repo':
return a.repo.localeCompare(b.repo);
case 'number':
return a.number - b.number;
case 'title':
return a.title.localeCompare(b.title);
case 'assignee':
return (a.assignee ?? '').localeCompare(b.assignee ?? '');
case 'blocked':
return Number(a.blocked) - Number(b.blocked);
case 'depended_on_by_count':
return a.depended_on_by_count - b.depended_on_by_count;
}
}
function SortHeader({
label,
sortKey,
activeKey,
dir,
onSort,
}: {
label: string;
sortKey: SortKey;
activeKey: SortKey;
dir: SortDir;
onSort: (key: SortKey) => void;
}) {
const active = sortKey === activeKey;
return (
<button type="button" class="issue-report-sort-btn" onClick={() => onSort(sortKey)}>
{label}
{active ? <span aria-hidden="true"> {dir === 'asc' ? '▲' : '▼'}</span> : null}
</button>
);
}
export function IssueReportPage() {
const [repos, setRepos] = useState<string[] | null>(null);
const [repoFilter, setRepoFilter] = useState(ALL_REPOS);
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [loading, setLoading] = useState(false);
const [hideBlocked, setHideBlocked] = useState(false);
const [labelFilter, setLabelFilter] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('depended_on_by_count');
const [sortDir, setSortDir] = useState<SortDir>('desc');
// Repo dropdown source, fetched once — this page has no refresh
// cadence, and a repo gaining/losing its first/last open issue between
// visits is rare enough that a manual reload covers it.
useEffect(() => {
(async () => {
const r = await fetch('/api/repos');
if (!r.ok) {
setError(await readApiError(r));
return;
}
setRepos((await r.json()) as string[]);
})().catch((e: unknown) => setError({ detail: String(e) }));
}, []);
// The one fetch this page re-runs on state change — everything else
// (sort, hide-blocked, label filter) works over what's already loaded.
useEffect(() => {
let cancelled = false;
setLoading(true);
const split = repoFilter ? splitRepo(repoFilter) : null;
const url = split ? `/api/repos/${split.org}/${split.name}/issue-report` : '/api/issue-report';
(async () => {
const r = await fetch(url);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as IssueReportRow[];
if (cancelled) return;
setRows(data);
setError(null);
})()
.catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [repoFilter]);
// Label choices are derived from whatever's currently loaded, not a
// separate endpoint — naturally scoped to "all repos" or one repo
// depending on the current selection, and never asks for a label that
// isn't actually present in the report.
const allLabels = useMemo(() => {
const s = new Set<string>();
for (const row of rows ?? []) for (const l of row.labels) s.add(l);
return [...s].sort();
}, [rows]);
const visibleRows = useMemo(() => {
let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked);
if (labelFilter.size > 0) out = out.filter((r) => r.labels.some((l) => labelFilter.has(l)));
return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey);
return sortDir === 'asc' ? c : -c;
});
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]);
function onSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
}
function toggleLabel(label: string) {
setLabelFilter((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}
const columns: TableColumn<IssueReportRow>[] = [
// Shown even with one repo selected (every row shares the value) —
// a fixed column set means the table's shape doesn't shift under
// sort/filter state, and it's a free confirmation of what's loaded.
{
key: 'repo',
header: <SortHeader label="repo" sortKey="repo" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
render: (r) => r.repo,
},
{
key: 'number',
header: <SortHeader label="issue" sortKey="number" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
render: (r) =>
r.html_url ? (
<a href={r.html_url} target="_blank" rel="noreferrer">
#{r.number}
</a>
) : (
`#${r.number}`
),
},
{
key: 'title',
header: <SortHeader label="title" sortKey="title" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
render: (r) => r.title,
},
{
key: 'labels',
header: 'labels',
render: (r) => (r.labels.length ? r.labels.join(', ') : '—'),
},
{
key: 'assignee',
header: <SortHeader label="assignee" sortKey="assignee" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
render: (r) => r.assignee ?? '—',
},
{
key: 'blocked',
header: <SortHeader label="blocked" sortKey="blocked" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
render: (r) => (r.blocked ? <Badge tone="warning" value="blocked" /> : '—'),
},
{
key: 'depended_on_by_count',
header: (
<SortHeader
label="depended on by"
sortKey="depended_on_by_count"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
render: (r) => r.depended_on_by_count,
},
];
return (
<Panel title="issue report" icon="📋">
<div class="issue-report-controls">
<SelectField
id="issue-report-repo"
label="repo"
value={repoFilter}
onChange={setRepoFilter}
options={[
{ value: ALL_REPOS, label: 'all repos' },
...(repos ?? []).map((r) => ({ value: r, label: r })),
]}
/>
<label class="issue-report-toggle">
<input
type="checkbox"
checked={hideBlocked}
onChange={(e) => setHideBlocked((e.target as HTMLInputElement).checked)}
/>
hide blocked (open dependency)
</label>
</div>
{allLabels.length ? (
<div class="issue-report-labels">
{allLabels.map((l) => (
<label key={l} class="issue-report-label-chip">
<input type="checkbox" checked={labelFilter.has(l)} onChange={() => toggleLabel(l)} />
{l}
</label>
))}
</div>
) : null}
{error ? <ApiErrorPanel context="failed to load the issue report" problem={error} /> : null}
{!error && loading ? <p>generating report</p> : null}
{!loading && rows ? (
<Table
columns={columns}
rows={visibleRows}
rowKey={(r) => `${r.repo}#${r.number}`}
emptyMessage="no issues match the current filters"
/>
) : null}
</Panel>
);
}

View file

@ -48,6 +48,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: '/', label: 'hives', accent: 'var(--purple)' },
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
{ href: '/issues', label: 'issues', accent: 'var(--yellow)' },
{ href: '/components', label: 'components', accent: 'var(--blue)' },
];

View file

@ -14,7 +14,11 @@ import './Table.css';
export interface TableColumn<T> {
key: string;
header: string;
// `ComponentChildren`, not `string` — a plain string still satisfies
// this and renders identically, but a caller that wants a clickable
// sortable header (the issue-report page's own column-header buttons)
// can pass real markup instead of forking a second table primitive.
header: ComponentChildren;
render: (row: T) => ComponentChildren;
}