From f2011b9b86025084a8a250fdb134dd2a9422f53c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 14 Oct 2026 20:40:25 +0200 Subject: [PATCH 1/2] feat(errors): server-side Closed tab, issue search, errors:tick and errors:replay The Resolved tab filtered a mixed page of the 50 most recently seen issues in the browser, so it rendered empty, with no "Load more", whenever that page was all open issues. Done issues archived after 14 days never showed at all. - v2 GET /error_issues takes `state=open|closed`, `search` and `include_archived`; the web list asks the server for exactly its view. - Tabs are Open / Closed / All (old `view` values still decode). Closed holds verifying, done, cancelled and wontfix, archived ones included. - A debounced search box (`?q=`) over type, message, label and service. - The stat strip keeps error events and their share of spans; the toolbar counts issues. - `bun run errors:tick` fires the alerting minute cron on a local stack. - `bun run errors:replay` groups a sample of error events with today's fingerprint and a proposed v3, checks parity against production hashes, and lists the largest merges. --- apps/api/src/routes/v2/error-issues.http.ts | 3 + .../components/errors/error-signal-row.tsx | 2 +- .../src/components/errors/errors-hub-view.tsx | 102 ++++++++----- apps/web/src/components/errors/errors-hub.tsx | 23 +-- .../components/errors/errors-stat-strip.tsx | 9 +- apps/web/src/lab/errors-lab.tsx | 20 ++- apps/web/src/lib/errors/hub-params.ts | 14 +- apps/web/src/routes/errors/index.tsx | 10 +- apps/web/src/routes/errors/issues/index.tsx | 19 +-- docs/error-issue-lifecycle.md | 14 ++ package.json | 2 + .../ErrorIssueReadModelsService.test.ts | 52 +++++++ .../errors/ErrorIssueReadModelsService.ts | 10 +- packages/domain/src/http/v2/error-issues.ts | 7 + scripts/errors-replay.ts | 136 ++++++++++++++++++ scripts/errors-replay/candidate.ts | 115 +++++++++++++++ scripts/errors-replay/corpus.jsonl | 18 +++ scripts/errors-tick.ts | 44 ++++++ 18 files changed, 530 insertions(+), 70 deletions(-) create mode 100644 scripts/errors-replay.ts create mode 100644 scripts/errors-replay/candidate.ts create mode 100644 scripts/errors-replay/corpus.jsonl create mode 100644 scripts/errors-tick.ts diff --git a/apps/api/src/routes/v2/error-issues.http.ts b/apps/api/src/routes/v2/error-issues.http.ts index 1dae016783..6b41b49b05 100644 --- a/apps/api/src/routes/v2/error-issues.http.ts +++ b/apps/api/src/routes/v2/error-issues.http.ts @@ -151,6 +151,9 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu endTime: query.end_time, introducedAfter: query.introduced_after, actionable: query.actionable === "true", + stateGroup: query.state, + search: query.search?.trim() || undefined, + includeArchived: query.include_archived === "true", sort, limit: query.limit ?? 20, cursor, diff --git a/apps/web/src/components/errors/error-signal-row.tsx b/apps/web/src/components/errors/error-signal-row.tsx index bc70b8d3c9..32954b0f0d 100644 --- a/apps/web/src/components/errors/error-signal-row.tsx +++ b/apps/web/src/components/errors/error-signal-row.tsx @@ -180,7 +180,7 @@ export function ErrorSignalHeader({ ) : null} - Error + Issue Trend · 24h Events diff --git a/apps/web/src/components/errors/errors-hub-view.tsx b/apps/web/src/components/errors/errors-hub-view.tsx index 7bdad7569b..fa5e8ffcb3 100644 --- a/apps/web/src/components/errors/errors-hub-view.tsx +++ b/apps/web/src/components/errors/errors-hub-view.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router" import type { ErrorIssueId, WorkflowState } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" +import { ToolbarSearch } from "@maple/ui/components/toolbar" import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" @@ -63,37 +64,22 @@ const SKELETON_ROWS = 8 const VIEW_LABEL: Record = { open: "Open", - triage: "Triage", - active: "Active", - resolved: "Resolved", + closed: "Closed", all: "All", } satisfies Record /** - * Which workflow states each view covers. `triage` is the untouched queue, - * `active` is everything someone has picked up, `resolved` is closed work. - * `regressed` sits in triage because a fix that did not hold is untriaged - * again, not in-progress. - * - * `open` is the union of triage and active, spelled out rather than left as - * `"all"`. The SERVER already narrows it — `actionable=true` maps to exactly - * these five states — so on the real page the client filter passes every row it - * is given and discards nothing. Spelling it out is what lets the view be - * rendered off a fixture (`/lab/errors`) and still show the set the server - * would have returned. + * Which workflow states each view covers. The server applies the same sets + * (`state=open|closed`); spelling them out here lets `/lab/errors` render a + * view off a fixture, and drops a row the moment a triage action moves it out. + * `verifying` is Closed: the fix has merged and the tick, not a person, acts next. */ const VIEW_STATES = { open: ["triage", "regressed", "todo", "in_progress", "in_review"], - triage: ["triage", "regressed"], - active: ["todo", "in_progress", "in_review"], - resolved: ["done", "cancelled", "wontfix"], + closed: ["verifying", "done", "cancelled", "wontfix"], all: "all", } satisfies Record | "all"> -/** Views the server can narrow with `actionable=true`, so the page it returns - * is already the right one. */ -export const ACTIONABLE_VIEWS: ReadonlyArray = ["open", "triage", "active"] - /** Membership against the view's state set. The literal tuples above keep their * inferred element types, which do not accept an arbitrary `WorkflowState` as * an `includes` argument — widening happens here, once. */ @@ -200,27 +186,17 @@ const EMPTY_COPY = { open: { icon: CircleCheckIcon, title: "Nothing open", - description: "Every error here is closed out. Check Resolved to see recent fixes.", - }, - triage: { - icon: CircleCheckIcon, - title: "Nothing waiting on triage", - description: "Every error here has been picked up.", - }, - active: { - icon: CircleCheckIcon, - title: "Nothing in progress", - description: "No one has claimed an error yet. Start from the triage queue.", + description: "Every issue here is closed out. Check Closed to see recent fixes.", }, all: { icon: CircleCheckIcon, title: "No errors recorded", description: "Your services are sending traces and none have failed.", }, - resolved: { + closed: { icon: HistoryIcon, - title: "Nothing resolved yet", - description: "Errors you close land here, so you can check whether a fix held.", + title: "Nothing closed yet", + description: "Issues you close land here, so you can check whether a fix held.", }, } satisfies Record @@ -240,6 +216,9 @@ export interface ErrorsHubViewProps { onViewChange: (view: HubView) => void onSortChange: (sort: HubSort) => void onSeverityChange: (severity: SeverityFilter) => void + /** The applied search term, matched server-side. */ + search: string + onSearchChange: (search: string | undefined) => void /** The window totals above the toolbar. A slot rather than a component * because it is the one part of the list that fetches for itself. */ stats?: React.ReactNode @@ -260,6 +239,8 @@ export function ErrorsHubView({ onViewChange, onSortChange, onSeverityChange, + search, + onSearchChange, stats, paging, onClearFilters, @@ -278,14 +259,21 @@ export function ErrorsHubView({ tabs={HUB_VIEWS.map((value) => ({ value, label: VIEW_LABEL[value] }))} active={view} label="Filter errors" - countNoun={["error", "errors"]} + countNoun={["issue", "issues"]} /* No count until there is one to state. "0 errors" under a spinner is a claim about the data, and it is wrong every time. */ totalCount={status === "ready" ? sorted.length : undefined} - countLabel={status === "ready" && hasUnshownPages ? `${sorted.length}+ errors` : undefined} + countLabel={status === "ready" && hasUnshownPages ? `${sorted.length}+ issues` : undefined} onChange={onViewChange} trailing={ <> +