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 { ComponentsPage } from './pages/ComponentsPage.js';
|
||||||
import { JobsPage } from './pages/JobsPage.js';
|
import { JobsPage } from './pages/JobsPage.js';
|
||||||
import { HivesPage } from './pages/HivesPage.js';
|
import { HivesPage } from './pages/HivesPage.js';
|
||||||
|
import { IssueReportPage } from './pages/IssueReportPage.js';
|
||||||
import { Panel } from './ui/panel/Panel.js';
|
import { Panel } from './ui/panel/Panel.js';
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
|
|
@ -23,6 +24,7 @@ export function App() {
|
||||||
<Route path="/" component={HivesPage} />
|
<Route path="/" component={HivesPage} />
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</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: '/', label: 'hives', accent: 'var(--purple)' },
|
||||||
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
|
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
|
||||||
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
|
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
|
||||||
|
{ href: '/issues', label: 'issues', accent: 'var(--yellow)' },
|
||||||
{ href: '/components', label: 'components', accent: 'var(--blue)' },
|
{ href: '/components', label: 'components', accent: 'var(--blue)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ import './Table.css';
|
||||||
|
|
||||||
export interface TableColumn<T> {
|
export interface TableColumn<T> {
|
||||||
key: string;
|
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;
|
render: (row: T) => ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue