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:
parent
5aef2d1afc
commit
e815c7cb5c
5 changed files with 338 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
43
frontend/packages/swarm-ui/src/pages/IssueReportPage.css
Normal file
43
frontend/packages/swarm-ui/src/pages/IssueReportPage.css
Normal 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;
|
||||
}
|
||||
287
frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx
Normal file
287
frontend/packages/swarm-ui/src/pages/IssueReportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue