fabro/apps/fabro-web/app/lib/sse.ts
Bryan Helmkamp 1cd5d89316
Runs list view: table layout, server-side sort/pagination, redesigned toolbar (#367)
## Summary

Overhauls the `/runs` list view and consolidates the two runs endpoints
that backed it.

**API**
- Removes `GET /api/v1/boards/runs`, `PaginatedBoardRunList`, and
`BoardColumnDefinition`. The board view is now a pure frontend
rendering.
- `GET /api/v1/runs` gains `status` (repeatable `BoardColumn`), `sort`
(`created_at | updated_at | status | elapsed`, default `created_at`),
and `direction` (`asc | desc`, default `desc`).
- `BoardColumn` enum gains `removing`; default behavior hides
Removing-status runs, opt in with `?status=removing`.
- `PaginationMeta` gains an optional `total: int64`; `list_runs` fills
it in (free — it already filters all runs in memory before paging).

**List view UI**
- Renders as a real `<table>` with column headings instead of horizontal
cards.
- Sortable Status, Elapsed, Created, and Updated headers — click to
toggle direction, click another to switch sort key (resets to desc). URL
params drive `sort`/`direction`/`page`/`size`.
- New pager footer with rows-per-page selector (10/25/50/100), `Page X
of Y`, and first/prev/next/last icon buttons.
- Toolbar redesigned into left (search + filter buttons for
Time/Repo/Workflow + archived toggle) and right (column picker + view
toggle) sections. Filter buttons use Headless UI `Menu` popovers; the
column picker uses Headless UI `Listbox` with `multiple` for
multi-select. Hidden columns persist via `?hide=...`.

**Tests**
- 589 server tests pass, including new coverage for status filter
(single + repeated), Removing opt-in, sort × direction with `id desc`
tiebreak, and status-bucket sorting.
- Frontend tests updated for the matcher-based cache invalidation and
the new `buildBoardColumns` signature; 435 pass (3 pre-existing
`RunDetail full-height` failures unrelated to this change).

## Test plan

- [ ] `cargo build --workspace`
- [ ] `cargo nextest run -p fabro-server`
- [ ] `cd lib/packages/fabro-api-client && bun run generate` — no diff
(already regenerated and committed)
- [ ] `cd apps/fabro-web && bun run typecheck && bun test`
- [ ] Manual: visit `/runs` — board view still renders all columns in
canonical order, Removing runs hidden, archived toggle works.
- [ ] Manual: visit `/runs?view=list` — table renders with sortable
headers; clicking a header updates URL; pager advances; changing
rows-per-page resets to page 1; column picker hides/shows columns and
round-trips via `?hide=`.
- [ ] Manual: `curl '/api/v1/boards/runs'` → 404; `curl
'/api/v1/runs?status=removing'` returns only removing runs; `curl
'/api/v1/runs?sort=status&direction=asc'` returns runs grouped by status
bucket.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:25:58 -04:00

203 lines
5.4 KiB
TypeScript

import type { Key, MutatorCallback } from "swr";
export type KeyMatcher = (key: Key) => boolean;
export type KeyOrMatcher = Key | KeyMatcher;
export type MutateFn = (key: KeyOrMatcher) => ReturnType<MutatorCallback>;
export interface EventPayload {
event?: string;
[key: string]: unknown;
}
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close(): void;
}
export interface EventInvalidation {
keys: KeyOrMatcher[];
close?: boolean;
immediate?: boolean;
}
type EventResolver = (payload: EventPayload) => EventInvalidation;
export interface SharedEventSubscription {
source: EventSourceLike;
refcount: number;
mutators: Map<MutateFn, number>;
resolvers: Map<symbol, EventResolver>;
pendingKeys: Map<string, KeyOrMatcher>;
debounceTimer: ReturnType<typeof setTimeout> | null;
}
export function sseKeyDedupeId(key: KeyOrMatcher): string {
return typeof key === "function" ? `fn:${key.toString()}` : stringifyKeyValue(key);
}
export function createBrowserEventSource(url: string): EventSourceLike {
return new EventSource(url);
}
export function subscribeToSharedEventSource<TPayload extends EventPayload>({
subscriptions,
subscriptionKey,
url,
mutate,
resolveInvalidation,
eventSourceFactory = createBrowserEventSource,
debounceMs = 300,
}: {
subscriptions: Map<string, SharedEventSubscription>;
subscriptionKey: string;
url: string;
mutate: MutateFn;
resolveInvalidation: (payload: TPayload) => EventInvalidation;
eventSourceFactory?: (url: string) => EventSourceLike;
debounceMs?: number;
}): () => void {
let subscription = subscriptions.get(subscriptionKey);
if (!subscription) {
const source = eventSourceFactory(url);
subscription = {
source,
refcount: 0,
mutators: new Map(),
resolvers: new Map(),
pendingKeys: new Map(),
debounceTimer: null,
};
subscriptions.set(subscriptionKey, subscription);
source.onmessage = (message) => {
const current = subscriptions.get(subscriptionKey);
if (!current) return;
let payload: TPayload;
try {
payload = JSON.parse(message.data) as TPayload;
} catch {
return;
}
const keys = new Map<string, KeyOrMatcher>();
let close = false;
let immediate = false;
for (const resolver of current.resolvers.values()) {
const invalidation = resolver(payload);
for (const key of invalidation.keys) {
keys.set(sseKeyDedupeId(key), key);
}
close ||= Boolean(invalidation.close);
immediate ||= Boolean(invalidation.immediate);
}
queueInvalidations(current, [...keys.values()], { debounceMs, immediate });
if (close) {
closeSharedEventSource(subscriptions, subscriptionKey, { flushPending: true });
}
};
}
const resolverId = Symbol(subscriptionKey);
subscription.resolvers.set(
resolverId,
resolveInvalidation as EventResolver,
);
subscription.refcount += 1;
subscription.mutators.set(mutate, (subscription.mutators.get(mutate) ?? 0) + 1);
return () => {
const current = subscriptions.get(subscriptionKey);
if (!current) return;
current.resolvers.delete(resolverId);
const mutateCount = current.mutators.get(mutate) ?? 0;
if (mutateCount <= 1) {
current.mutators.delete(mutate);
} else {
current.mutators.set(mutate, mutateCount - 1);
}
current.refcount -= 1;
if (current.refcount <= 0) {
closeSharedEventSource(subscriptions, subscriptionKey);
}
};
}
function queueInvalidations(
subscription: SharedEventSubscription,
keys: KeyOrMatcher[],
{
debounceMs,
immediate,
}: {
debounceMs: number;
immediate?: boolean;
},
) {
if (keys.length === 0) return;
for (const key of keys) {
subscription.pendingKeys.set(sseKeyDedupeId(key), key);
}
if (immediate || debounceMs <= 0) {
flushInvalidations(subscription);
return;
}
if (subscription.debounceTimer) {
clearTimeout(subscription.debounceTimer);
}
subscription.debounceTimer = setTimeout(() => {
subscription.debounceTimer = null;
flushInvalidations(subscription);
}, debounceMs);
}
function flushInvalidations(subscription: SharedEventSubscription) {
if (subscription.pendingKeys.size === 0) return;
const keys = [...subscription.pendingKeys.values()];
subscription.pendingKeys.clear();
for (const mutator of subscription.mutators.keys()) {
for (const key of keys) {
void mutator(key);
}
}
}
function closeSharedEventSource(
subscriptions: Map<string, SharedEventSubscription>,
subscriptionKey: string,
{ flushPending = false }: { flushPending?: boolean } = {},
) {
const subscription = subscriptions.get(subscriptionKey);
if (!subscription) return;
if (flushPending) {
flushInvalidations(subscription);
}
if (subscription.debounceTimer) {
clearTimeout(subscription.debounceTimer);
}
subscription.source.close();
subscriptions.delete(subscriptionKey);
}
function stringifyKeyValue(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stringifyKeyValue(item)).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stringifyKeyValue(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? String(value);
}