From 67b2e39ae5134915f1eb2f4bbf085c2200270314 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 19 Aug 2026 14:18:42 +0200 Subject: [PATCH 1/2] feat(errors): one triage hub, and an issue page that says where the error is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/errors` grouped error events by fingerprint and knew nothing about triage; `/errors/issues` listed the same fingerprints from Postgres and knew nothing about volume; the investigation lived on a third route. They were always the same objects — the warehouse groups by FingerprintHash and `error_issues` is keyed on that same hash — so no single view could answer a triage question. They are now one list. A row is an `ErrorSignal`: the issue row as the spine, warehouse volume and trend joined on the hash, and one status slot resolved by precedence (open incident › live investigation › workflow state) in place of the four badge systems a row used to carry. `/errors/issues` redirects, carrying its old `workflowState` tab across to the equivalent view. The detail page never got that treatment and read as a different product: - Five badges, a primary button and an overflow menu sat flat in one actions row. Identity chips move into the title stack and the status collapses to the same one chip the list uses, leaving one labelled action plus one menu. - `topFrame` and `fingerprintHash` were on the wire and drawn nowhere — only the investigation snapshot ever read them, so the page that exists to answer "what broke and where" never named a line of code. Both are now the block under the title, and the exception message is promoted out of muted caption. - The endpoint takes start_time/end_time/bucket_seconds/sample_limit and the page called it with `{}`, so the chart and the samples described a window nobody chose, reached from a list that has a time picker. It is wired now. - Seven equal-weight sections in one stack become Overview / Occurrences / Activity, tabbed through `?tab=`. - regressionCount, lastRegressedAt, lastResolvedAt, resolvedVersions and snoozeUntil were decoded every load and shown nowhere, so the page could not answer the question the workflow exists for: did the fix hold. Also: `errorsSpark` (bucketed counts for many fingerprints, pivoted server-side) and an `errorLabels` facet; the occurrence chart becomes bars, tinted by severity, because the series is a count per discrete bucket and the old filled area interpolated straight through silence; and `/errors/$errorType` is deleted along with the two atoms and the one query it was the last consumer of. --- .../src/routes/internal/query-engine.http.ts | 14 + apps/api/src/routes/v2/error-issues.http.ts | 14 + .../ErrorIssueReadModelsService.test.ts | 29 + .../errors/ErrorIssueReadModelsService.ts | 5 + apps/web/src/api/warehouse/errors.ts | 103 ++- .../anomalies/related-anomalies-section.tsx | 15 +- apps/web/src/components/errors/actor-chip.tsx | 224 +++++-- .../components/errors/alert-source-card.tsx | 37 +- .../components/errors/error-signal-row.tsx | 236 +++++++ .../errors/errors-filter-sidebar.tsx | 15 +- apps/web/src/components/errors/errors-hub.tsx | 578 +++++++++++++++++ .../components/errors/errors-stat-strip.tsx | 64 ++ .../errors/issue-comment-composer.tsx | 129 +++- .../components/errors/issue-culprit-panel.tsx | 90 +++ .../components/errors/issue-fact-strip.tsx | 129 ++++ .../src/components/errors/issue-header.tsx | 169 +++++ .../errors/issue-occurrence-chart.tsx | 168 +++++ .../errors/issue-occurrence-panel.tsx | 82 +++ .../errors/issue-occurrence-sparkline.tsx | 146 ----- .../errors/issue-occurrences-table.tsx | 8 +- .../src/components/errors/issue-sidebar.tsx | 78 +-- apps/web/src/components/errors/issue-tabs.tsx | 90 +++ .../src/components/errors/issue-timeline.tsx | 337 ++++++++-- .../errors/issues-bulk-bar.test.tsx | 4 +- .../src/components/errors/issues-bulk-bar.tsx | 14 +- .../errors/linked-investigation-panel.tsx | 91 +++ .../src/components/errors/severity-badge.tsx | 43 +- .../src/components/errors/severity-select.tsx | 84 +++ .../src/components/errors/signal-spark.tsx | 104 +++ .../components/errors/signal-state-chip.tsx | 100 +++ .../src/components/errors/state-select.tsx | 28 +- apps/web/src/components/icons/face-robot.tsx | 32 + apps/web/src/components/icons/index.ts | 1 + .../services/service-errors-panel.tsx | 4 +- apps/web/src/hooks/use-actor-directory.ts | 99 +++ apps/web/src/lib/models/error-signal.test.ts | 113 ++++ apps/web/src/lib/models/error-signal.ts | 236 +++++++ .../services/atoms/warehouse-query-atoms.ts | 16 +- apps/web/src/routeTree.gen.ts | 21 - apps/web/src/routes/errors/$errorType.tsx | 522 --------------- apps/web/src/routes/errors/index.tsx | 91 +-- .../web/src/routes/errors/issues/$issueId.tsx | 493 +++++++------- apps/web/src/routes/errors/issues/index.tsx | 600 ++---------------- packages/domain/src/http/query-engine.ts | 41 ++ packages/domain/src/http/v2/error-issues.ts | 7 + packages/domain/src/query-engine.ts | 4 + .../query-engine/src/ch/builder-fixtures.ts | 10 + packages/query-engine/src/ch/index.ts | 4 + .../src/ch/queries/errors.test.ts | 38 +- .../query-engine/src/ch/queries/errors.ts | 107 +++- packages/query-engine/src/registry/queries.ts | 29 + .../query-engine/src/runtime/query-engine.ts | 2 + 52 files changed, 3889 insertions(+), 1809 deletions(-) create mode 100644 apps/web/src/components/errors/error-signal-row.tsx create mode 100644 apps/web/src/components/errors/errors-hub.tsx create mode 100644 apps/web/src/components/errors/errors-stat-strip.tsx create mode 100644 apps/web/src/components/errors/issue-culprit-panel.tsx create mode 100644 apps/web/src/components/errors/issue-fact-strip.tsx create mode 100644 apps/web/src/components/errors/issue-header.tsx create mode 100644 apps/web/src/components/errors/issue-occurrence-chart.tsx create mode 100644 apps/web/src/components/errors/issue-occurrence-panel.tsx delete mode 100644 apps/web/src/components/errors/issue-occurrence-sparkline.tsx create mode 100644 apps/web/src/components/errors/issue-tabs.tsx create mode 100644 apps/web/src/components/errors/linked-investigation-panel.tsx create mode 100644 apps/web/src/components/errors/severity-select.tsx create mode 100644 apps/web/src/components/errors/signal-spark.tsx create mode 100644 apps/web/src/components/errors/signal-state-chip.tsx create mode 100644 apps/web/src/components/icons/face-robot.tsx create mode 100644 apps/web/src/hooks/use-actor-directory.ts create mode 100644 apps/web/src/lib/models/error-signal.test.ts create mode 100644 apps/web/src/lib/models/error-signal.ts delete mode 100644 apps/web/src/routes/errors/$errorType.tsx diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 207386531..8382aa2b2 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -9,6 +9,7 @@ import { SpanDetailResponse, ErrorsByTypeResponse, ErrorsTimeseriesResponse, + ErrorsSparkResponse, ErrorsSummaryResponse, ErrorDetailTracesResponse, ErrorRateByServiceResponse, @@ -395,6 +396,19 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query }) }), ) + .handle("errorsSpark", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.errorsSpark, tenant, payload) + return new ErrorsSparkResponse({ + data: rows.map((row) => ({ + fingerprintHash: decodeFingerprintHash(row.fingerprintHash), + bucket: String(row.bucket), + count: Number(row.count), + })), + }) + }), + ) .handle("errorsSummary", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/v2/error-issues.http.ts b/apps/api/src/routes/v2/error-issues.http.ts index cd29ed5af..cbbb22f54 100644 --- a/apps/api/src/routes/v2/error-issues.http.ts +++ b/apps/api/src/routes/v2/error-issues.http.ts @@ -114,6 +114,19 @@ const decodeCursor = ( ) } +/** + * `fingerprint_hash=1,2,3` -> `["1","2","3"]`, or `undefined` when the caller + * did not filter. A param that is present but yields nothing stays an empty + * array so it matches no issues, rather than silently widening to everything. + */ +function parseFingerprintHashes(raw: string | undefined): ReadonlyArray | undefined { + if (raw === undefined) return undefined + return raw + .split(",") + .map((hash) => hash.trim()) + .filter((hash) => hash.length > 0) +} + export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssues", (handlers) => Effect.gen(function* () { const readModels = yield* ErrorIssueReadModelsService @@ -128,6 +141,7 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu severity: query.severity, kind: query.kind, service: query.service_name, + fingerprintHashes: parseFingerprintHashes(query.fingerprint_hash), deploymentEnv: query.deployment_environment, startTime: query.start_time, endTime: query.end_time, diff --git a/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts b/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts index ab71c3b5e..0daec7977 100644 --- a/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts +++ b/apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts @@ -161,6 +161,35 @@ describe("ErrorIssueReadModelsService", () => { }).pipe(Effect.provide(makeLayer(contexts))) }) + it.effect("restricts the list to an explicit fingerprint set", () => { + const contexts: Array = [] + return Effect.gen(function* () { + const readModels = yield* ErrorIssueReadModelsService + const now = yield* Clock.currentTimeMillis + const wanted = asIssueId(randomUUID()) + const other = asIssueId(randomUUID()) + + yield* seedIssue(ORG, wanted, now, { fingerprintHash: "fp-wanted" }) + yield* seedIssue(ORG, other, now + 1_000, { fingerprintHash: "fp-other" }) + + // The volume-ranked list ranks fingerprints in the warehouse first, then + // asks for exactly those issues — order is re-applied client-side, but the + // set has to be exact or the ranking is drawn against the wrong rows. + const listed = yield* readModels.listIssues(ORG, { + fingerprintHashes: ["fp-wanted"], + }) + assert.deepStrictEqual( + listed.issues.map((issue) => issue.id), + [wanted], + ) + + // An empty set is a real filter that matches nothing, not an absent one + // that widens back to every issue in the org. + const none = yield* readModels.listIssues(ORG, { fingerprintHashes: [] }) + assert.lengthOf(none.issues, 0) + }).pipe(Effect.provide(makeLayer(contexts))) + }) + it.effect("preserves issue ownership and not-found semantics", () => { const contexts: Array = [] return Effect.gen(function* () { diff --git a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts index 7d770cd96..0b8151e40 100644 --- a/apps/api/src/services/errors/ErrorIssueReadModelsService.ts +++ b/apps/api/src/services/errors/ErrorIssueReadModelsService.ts @@ -89,6 +89,9 @@ export interface ErrorIssueReadModelsPublicApi { readonly severity?: IssueSeverity | "unset" readonly kind?: IssueKind readonly service?: string + /** Restrict to these fingerprint hashes (volume-ranked lists ask for an + * explicit set). An empty array matches nothing, as it should. */ + readonly fingerprintHashes?: ReadonlyArray /** Only issues whose fingerprint the warehouse observed in this * deployment environment (within startTime/endTime, defaulting to the * trailing 30d). Costs one warehouse round-trip; excludes alert-kind @@ -183,6 +186,8 @@ const make: Effect.Effect< else if (opts.severity) conditions.push(eq(errorIssues.severity, opts.severity)) if (opts.kind) conditions.push(eq(errorIssues.kind, opts.kind)) if (opts.service) conditions.push(eq(errorIssues.serviceName, opts.service)) + if (opts.fingerprintHashes !== undefined) + conditions.push(inArray(errorIssues.fingerprintHash, opts.fingerprintHashes)) // `""` is a real filter (raw spans without a deployment env), so check // for undefined rather than truthiness. diff --git a/apps/web/src/api/warehouse/errors.ts b/apps/web/src/api/warehouse/errors.ts index 3bb115d20..a7f3a75fa 100644 --- a/apps/web/src/api/warehouse/errors.ts +++ b/apps/web/src/api/warehouse/errors.ts @@ -11,6 +11,7 @@ import { ErrorsByTypeRequest, ErrorsSummaryRequest, ErrorDetailTracesRequest, + ErrorsSparkRequest, ErrorsTimeseriesRequest, FingerprintHash, ServiceName, @@ -27,6 +28,8 @@ import { const OptionalServiceArray = Schema.optional(Schema.mutable(Schema.Array(ServiceName))) const OptionalDeploymentEnvArray = Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment))) const OptionalFingerprintHashArray = Schema.optional(Schema.mutable(Schema.Array(FingerprintHash))) +/** "Error Type" / "Version" sidebar facets — plain strings, not branded. */ +const OptionalStringArray = Schema.optional(Schema.mutable(Schema.Array(Schema.String))) export interface ErrorByType { fingerprintHash: string @@ -44,6 +47,8 @@ const GetErrorsByTypeInputSchema = Schema.Struct({ services: OptionalServiceArray, deploymentEnvs: OptionalDeploymentEnvArray, fingerprintHashes: OptionalFingerprintHashArray, + errorLabels: OptionalStringArray, + serviceVersions: OptionalStringArray, limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), showSpam: Schema.optional(Schema.Boolean), rootOnly: Schema.optional(Schema.Boolean), @@ -74,6 +79,8 @@ const getErrorsByTypeEffect = Effect.fn("QueryEngine.getErrorsByType")(function* services: input.services, deploymentEnvs: input.deploymentEnvs, fingerprintHashes: input.fingerprintHashes, + errorLabels: input.errorLabels, + serviceVersions: input.serviceVersions, limit: input.limit, }), }) @@ -95,6 +102,8 @@ const GetErrorsFacetsInputSchema = Schema.Struct({ services: OptionalServiceArray, deploymentEnvs: OptionalDeploymentEnvArray, fingerprintHashes: OptionalFingerprintHashArray, + errorLabels: OptionalStringArray, + serviceVersions: OptionalStringArray, showSpam: Schema.optional(Schema.Boolean), rootOnly: Schema.optional(Schema.Boolean), }) @@ -133,6 +142,8 @@ const getErrorsFacetsEffect = Effect.fn("QueryEngine.getErrorsFacets")(function* services: input.services, deploymentEnvs: input.deploymentEnvs, fingerprintHashes: input.fingerprintHashes, + errorLabels: input.errorLabels, + serviceVersions: input.serviceVersions, }, }, }), @@ -142,24 +153,32 @@ const getErrorsFacetsEffect = Effect.fn("QueryEngine.getErrorsFacets")(function* const services: FacetItem[] = [] const deploymentEnvs: FacetItem[] = [] const errorTypes: FacetItem[] = [] + const serviceVersions: FacetItem[] = [] for (const row of facetsData) { const item = { name: row.name, count: Number(row.count) } + // These strings must match the `facetType` literals in `errorsFacetsQuery`. + // They did not: the query emits "environment"/"error_type" and this read + // "deploymentEnv"/"errorType", so those two sections rendered with zero + // options and the sidebar looked like it had no environment filter at all. switch (row.facetType) { case "service": services.push(item) break - case "deploymentEnv": + case "environment": deploymentEnvs.push(item) break - case "errorType": + case "error_type": errorTypes.push(item) break + case "version": + serviceVersions.push(item) + break } } return { - data: { services, deploymentEnvs, errorTypes }, + data: { services, deploymentEnvs, errorTypes, serviceVersions }, } }) @@ -169,6 +188,8 @@ const GetErrorsSummaryInputSchema = Schema.Struct({ services: OptionalServiceArray, deploymentEnvs: OptionalDeploymentEnvArray, fingerprintHashes: OptionalFingerprintHashArray, + errorLabels: OptionalStringArray, + serviceVersions: OptionalStringArray, showSpam: Schema.optional(Schema.Boolean), rootOnly: Schema.optional(Schema.Boolean), }) @@ -198,6 +219,8 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio services: input.services, deploymentEnvs: input.deploymentEnvs, fingerprintHashes: input.fingerprintHashes, + errorLabels: input.errorLabels, + serviceVersions: input.serviceVersions, }), }) }), @@ -320,3 +343,77 @@ const getErrorsTimeseriesEffect = Effect.fn("QueryEngine.getErrorsTimeseries")(f })), } }) + +/** + * Bucketed counts for many fingerprints at once, pivoted into one series per + * fingerprint. The warehouse returns tall rows; the list draws one sparkline + * per row, so the pivot happens once here rather than in every row component. + * + * Buckets are sparse — a fingerprint that was quiet for an hour has no row for + * it. Densifying is the caller's job, since only it knows the bucket grid. + */ +export interface ErrorsSparkSeries { + fingerprintHash: string + points: ReadonlyArray +} + +const GetErrorsSparkInputSchema = Schema.Struct({ + fingerprintHashes: Schema.mutable(Schema.Array(FingerprintHash)), + startTime: Schema.optional(WarehouseDateTimeString), + endTime: Schema.optional(WarehouseDateTimeString), + services: OptionalServiceArray, + deploymentEnvs: OptionalDeploymentEnvArray, + errorLabels: OptionalStringArray, + serviceVersions: OptionalStringArray, + bucketSeconds: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), +}) + +export type GetErrorsSparkInput = (typeof GetErrorsSparkInputSchema)["Encoded"] + +export function getErrorsSpark({ data }: { data: GetErrorsSparkInput }) { + return getErrorsSparkEffect({ data }) +} + +const getErrorsSparkEffect = Effect.fn("QueryEngine.getErrorsSpark")(function* ({ + data, +}: { + data: GetErrorsSparkInput +}) { + const input = yield* decodeInput(GetErrorsSparkInputSchema, data ?? {}, "getErrorsSpark") + const fallback = defaultErrorsTimeRange(yield* Clock.currentTimeMillis) + + // No fingerprints means nothing to chart — skipping the round-trip matters + // here because the list renders before its issues have loaded. + if (input.fingerprintHashes.length === 0) return { data: [] as ErrorsSparkSeries[] } + + const result = yield* runWarehouseQuery("errorsSpark", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.queryEngine.errorsSpark({ + payload: new ErrorsSparkRequest({ + startTime: input.startTime ?? fallback.startTime, + endTime: input.endTime ?? fallback.endTime, + fingerprintHashes: input.fingerprintHashes, + services: input.services, + deploymentEnvs: input.deploymentEnvs, + errorLabels: input.errorLabels, + serviceVersions: input.serviceVersions, + bucketSeconds: input.bucketSeconds, + }), + }) + }), + ) + + const byFingerprint = new Map() + for (const raw of result.data) { + const hash = String(raw.fingerprintHash) + const points = byFingerprint.get(hash) + const point = { bucket: String(raw.bucket), count: Number(raw.count) } + if (points) points.push(point) + else byFingerprint.set(hash, [point]) + } + + return { + data: [...byFingerprint].map(([fingerprintHash, points]) => ({ fingerprintHash, points })), + } +}) diff --git a/apps/web/src/components/anomalies/related-anomalies-section.tsx b/apps/web/src/components/anomalies/related-anomalies-section.tsx index b80e84e20..e50fe7317 100644 --- a/apps/web/src/components/anomalies/related-anomalies-section.tsx +++ b/apps/web/src/components/anomalies/related-anomalies-section.tsx @@ -3,7 +3,6 @@ import type { ErrorIssueId } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" import { cn } from "@maple/ui/lib/utils" -import { SectionHeader } from "@/components/layout/section-header" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { anomalyIncidentFromV2 } from "@/lib/services/anomalies" import { AnomalyRow } from "./anomaly-row" @@ -35,8 +34,18 @@ export function RelatedAnomaliesSection({ issueId }: { issueId: ErrorIssueId }) }) return ( -
- +
+
+ + + {sorted.length} {sorted.length === 1 ? "detector incident" : "detector incidents"} + +
{sorted.map((incident) => ( diff --git a/apps/web/src/components/errors/actor-chip.tsx b/apps/web/src/components/errors/actor-chip.tsx index 7523b8452..d8afee386 100644 --- a/apps/web/src/components/errors/actor-chip.tsx +++ b/apps/web/src/components/errors/actor-chip.tsx @@ -1,72 +1,190 @@ import type { ActorDocument } from "@maple/domain/http" -import { Badge } from "@maple/ui/components/ui/badge" +import { Tooltip, TooltipPopup, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { gradientFor } from "@maple/ui/lib/replay-format" import { cn } from "@maple/ui/lib/utils" -export function ActorChip({ actor }: { actor: ActorDocument | null }) { - if (!actor) { - return - } +import { FaceRobotIcon } from "@/components/icons" +import { shortId, useActorDirectory, type ActorDirectory } from "@/hooks/use-actor-directory" + +/** + * What an actor looks like once the raw event row has been joined against the + * workspace directory: a name a human recognises, plus the one detail worth + * carrying next to it (the model for an agent, the email for a person). + */ +export interface ActorIdentity { + readonly kind: "user" | "agent" + readonly name: string + readonly detail: string | null + readonly imageUrl: string | null + readonly initials: string + /** Stable seed for the fallback avatar gradient. */ + readonly seed: string +} + +export function resolveActorIdentity(actor: ActorDocument, directory: ActorDirectory): ActorIdentity { if (actor.type === "agent") { - const label = actor.agentName ?? actor.id.slice(0, 8) - const tooltip = actor.model ? `${label} (${actor.model})` : label + const name = actor.agentName ?? "Agent" + return { + kind: "agent", + name, + detail: actor.model, + imageUrl: null, + initials: initialsFrom(name), + seed: actor.agentName ?? actor.id, + } + } + const person = actor.userId ? directory.lookup(actor.userId) : null + if (person) { + return { + kind: "user", + name: person.name, + // Don't repeat the email as the detail when it IS the display name. + detail: person.email === person.name ? null : person.email, + imageUrl: person.imageUrl, + initials: initialsFrom(person.name), + seed: person.userId, + } + } + // Not in the directory: a removed member, a self-hosted deployment with no + // member API, or the page rendering before Clerk answers. Six characters of + // id beats thirty-two, and the tooltip still carries the whole thing. + const raw = actor.userId ?? actor.id + return { + kind: "user", + name: shortId(raw), + detail: null, + imageUrl: null, + initials: raw + .replace(/^user_/, "") + .slice(0, 2) + .toUpperCase(), + seed: raw, + } +} + +export function useActorIdentity(actor: ActorDocument | null): ActorIdentity | null { + const directory = useActorDirectory() + return actor ? resolveActorIdentity(actor, directory) : null +} + +function initialsFrom(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean) + if (parts.length === 0) return "?" + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase() + return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase() +} + +const SIZE_CLASS = { + sm: "size-5 text-[9px]", + md: "size-7 text-[11px]", +} as const + +/** + * Round avatar for an actor. People get their Clerk photo, or a per-user + * gradient with initials — the same identity treatment the Sessions list uses, + * so the same person looks the same across the product. Agents get a robot mark + * in the violet that already means "not a human" everywhere in this timeline. + */ +export function ActorAvatar({ + actor, + size = "sm", + className, +}: { + actor: ActorDocument | null + size?: keyof typeof SIZE_CLASS + className?: string +}) { + const identity = useActorIdentity(actor) + if (!identity) return null + return +} + +export function IdentityAvatar({ + identity, + size = "sm", + className, +}: { + identity: ActorIdentity + size?: keyof typeof SIZE_CLASS + className?: string +}) { + const base = cn( + "inline-flex shrink-0 items-center justify-center rounded-full font-medium select-none", + SIZE_CLASS[size], + className, + ) + + if (identity.kind === "agent") { return ( - - - 🤖 - - {label} - + + ) } - const userLabel = actor.userId ?? actor.id.slice(0, 8) - return ( - - - 👤 - - {userLabel} - - ) -} -function actorInitial(actor: ActorDocument): string { - if (actor.type === "agent") { - const source = actor.agentName ?? actor.model ?? actor.id - return source.charAt(0).toUpperCase() + if (identity.imageUrl) { + return ( + + ) } - const source = actor.userId ?? actor.id - return source.charAt(0).toUpperCase() + + return ( + + {identity.initials} + + ) } -function actorLabel(actor: ActorDocument): string { - if (actor.type === "agent") { - return actor.agentName ?? actor.id.slice(0, 8) +/** + * Inline "who did this" label. Avatar plus name, with the model or email in a + * tooltip rather than crowding the row — every timeline row has one of these, so + * the chip has to be quiet enough to read past. + */ +export function ActorChip({ + actor, + className, + showAvatar = true, +}: { + actor: ActorDocument | null + className?: string + showAvatar?: boolean +}) { + const identity = useActorIdentity(actor) + if (!identity) { + return } - return actor.userId ?? actor.id.slice(0, 8) -} -export function ActorAvatar({ actor, className }: { actor: ActorDocument | null; className?: string }) { - if (!actor) return null + const chipClass = cn( + "inline-flex max-w-full items-center gap-1.5 align-middle text-xs", + identity.kind === "agent" ? "text-violet-600 dark:text-violet-300" : "text-muted-foreground", + className, + ) + const body = ( + <> + {showAvatar ? : null} + {identity.name} + + ) - const label = actorLabel(actor) - const isAgent = actor.type === "agent" + if (!identity.detail) return {body} return ( - - {actorInitial(actor)} - + + }> + {body} + + {identity.detail} + ) } diff --git a/apps/web/src/components/errors/alert-source-card.tsx b/apps/web/src/components/errors/alert-source-card.tsx index e1e64e382..37d463b3f 100644 --- a/apps/web/src/components/errors/alert-source-card.tsx +++ b/apps/web/src/components/errors/alert-source-card.tsx @@ -1,6 +1,5 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument } from "@maple/domain/http" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" /** * Source panel for alert-backed issues: links back to the alert rule that @@ -14,26 +13,24 @@ export function AlertSourceCard({ issue }: { issue: ErrorIssueDocument }) { const groupKey = typeof sourceRef?.groupKey === "string" ? sourceRef.groupKey : null return ( - - - Alert source - - This issue is fed by alert rule incidents - {signalType ? ` (${signalType})` : ""} - {groupKey && groupKey !== "__total__" ? ` for group "${groupKey}"` : ""}. - - +
+

+ Alert source +

+

+ This issue is fed by alert rule incidents + {signalType ? ` (${signalType})` : ""} + {groupKey && groupKey !== "__total__" ? ` for group "${groupKey}"` : ""}. +

{ruleId ? ( - - - View alert rule → - - + + View alert rule → + ) : null} - +
) } diff --git a/apps/web/src/components/errors/error-signal-row.tsx b/apps/web/src/components/errors/error-signal-row.tsx new file mode 100644 index 000000000..b12a21e4b --- /dev/null +++ b/apps/web/src/components/errors/error-signal-row.tsx @@ -0,0 +1,236 @@ +import { Link } from "@tanstack/react-router" + +import type { ErrorIssueId } from "@maple/domain/http" +import { ServiceDot } from "@maple/ui/components/service-dot" +import { formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" + +import { normalizeTimestampInput } from "@/lib/timezone-format" +import type { ErrorSignal } from "@/lib/models/error-signal" +import { densifySpark, surgeRatio } from "@/lib/models/error-signal" + +import { ActorAvatar } from "./actor-chip" +import { IssueContextMenu } from "./issue-context-menu" +import { SeverityBadge } from "./severity-badge" +import { SignalSpark } from "./signal-spark" +import { SignalStateChip } from "./signal-state-chip" +import type { IssueMutations } from "./use-issue-mutations" + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000 + +function formatLastSeen(iso: string): string { + const d = new Date(normalizeTimestampInput(iso)) + if (Number.isNaN(d.getTime())) return iso + const diffMs = Date.now() - d.getTime() + if (diffMs < 60_000) return "now" + if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m` + if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h` + if (diffMs < WEEK_MS) return `${Math.floor(diffMs / 86_400_000)}d` + const sameYear = d.getFullYear() === new Date().getFullYear() + return d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + }) +} + +/** A burst this far above the window's own average tail is worth calling out. */ +const SURGE_THRESHOLD = 2.5 + +/** + * Lane geometry, shared by the header and the rows so the two cannot drift. + * Width and container-query breakpoint only — each use adds its own display, + * because the row's service lane needs `flex` for the dot while the header's + * does not. + */ +const LANE = { + severity: "w-[60px] shrink-0", + identity: "min-w-0 flex-1", + spark: "hidden w-[56px] shrink-0 @lg/page:block", + count: "hidden w-[52px] shrink-0 @xl/page:block", + service: "hidden w-[92px] shrink-0 @md/page:block", + state: "hidden w-[92px] shrink-0 @2xl/page:block", + actor: "w-5 shrink-0", + lastSeen: "w-[64px] shrink-0", +} as const + +/** Row and header share this so the columns line up under each other. */ +const ROW_SHELL = "flex items-center gap-3 px-3" + +/** + * Column labels. + * + * The list ran without a header, which works while every lane is + * self-describing — and three of them were not. A bare "13.2K" could be events + * or milliseconds, a bare "31m" could be an age or a duration, and the + * sparkline had no stated units at all. The answers were in `title` tooltips, + * which is the same as not being there. + * + * Severity and assignee stay unlabelled: a chip reading "Critical" and a face + * do not need telling. + */ +export function ErrorSignalHeader() { + return ( +
+ + Error + Trend + Events + Service + Status + + Last seen +
+ ) +} + +export interface ErrorSignalRowProps { + signal: ErrorSignal + sparkWindow: { readonly startMs: number; readonly endMs: number; readonly bucketMs: number } + mutations: IssueMutations + selected: boolean + focused: boolean + onFocus: (id: ErrorIssueId) => void +} + +/** + * One fingerprint, read left to right in the order the triage question is + * actually asked: what is it, what shape is it, how much, where, who or what is + * on it, and when did it last happen. + * + * The row IS the link rather than carrying an absolutely-positioned overlay one. + * The overlay version was unclickable in practice: it sat at `z-auto` while every + * content lane was `relative z-10`, so the link painted *underneath* all of them + * and only the padding gaps between lanes actually navigated. Making the row the + * anchor also means the title, the message and every number are real click + * targets, and the whole row takes one tab stop instead of none. + */ +export function ErrorSignalRow({ + signal, + sparkWindow, + mutations, + selected, + focused, + onFocus, +}: ErrorSignalRowProps) { + const href = `/errors/issues/${signal.id}` + const dense = densifySpark(signal.spark, sparkWindow) + const surge = surgeRatio(dense) + const isSurging = surge !== null && surge >= SURGE_THRESHOLD + + return ( + window.open(href, "_blank", "noopener,noreferrer")} + > + onFocus(signal.id)} + className={cn( + ROW_SHELL, + "group/row h-11 text-sm", + "hover:bg-muted/50 data-focused:bg-muted/40", + "data-selected:bg-primary/10 data-selected:hover:bg-primary/15", + "focus-visible:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", + "transition-colors", + )} + > + + + + + {/* Identity dominates. The type holds its width up to 60% of the lane + and the message gives way first — a row whose title truncates to + "Connect…" has lost the only thing you scan for. */} + + + {signal.title} + + {signal.detail ? ( + + {signal.detail} + + ) : null} + + + + + + + + {signal.windowCount === null ? ( + + ) : ( + + {formatNumber(signal.windowCount)} + + )} + + + + + {signal.serviceName} + + + + + + + + + + + + {formatLastSeen(signal.lastSeenAt)} + + + + ) +} diff --git a/apps/web/src/components/errors/errors-filter-sidebar.tsx b/apps/web/src/components/errors/errors-filter-sidebar.tsx index 1d69c31d0..bd8a71de3 100644 --- a/apps/web/src/components/errors/errors-filter-sidebar.tsx +++ b/apps/web/src/components/errors/errors-filter-sidebar.tsx @@ -62,7 +62,8 @@ export function ErrorsFilterSidebar() { const hasActiveFilters = (search.services?.length ?? 0) > 0 || (search.deploymentEnvs?.length ?? 0) > 0 || - (search.errorTypes?.length ?? 0) > 0 + (search.errorTypes?.length ?? 0) > 0 || + (search.serviceVersions?.length ?? 0) > 0 return Result.builder(facetsResult) .onInitial(() => ) @@ -72,7 +73,8 @@ export function ErrorsFilterSidebar() { const hasFacets = (facets.services?.length ?? 0) > 0 || (facets.deploymentEnvs?.length ?? 0) > 0 || - (facets.errorTypes?.length ?? 0) > 0 + (facets.errorTypes?.length ?? 0) > 0 || + (facets.serviceVersions?.length ?? 0) > 0 return ( @@ -111,6 +113,15 @@ export function ErrorsFilterSidebar() { onChange={(val) => updateFilter("errorTypes", val)} /> + {/* Which deploy the error was seen on — the fastest way to tell a + regression from something that was always broken. */} + updateFilter("serviceVersions", val)} + /> + {!hasFacets && (

No errors found in the selected time range diff --git a/apps/web/src/components/errors/errors-hub.tsx b/apps/web/src/components/errors/errors-hub.tsx new file mode 100644 index 000000000..51295a615 --- /dev/null +++ b/apps/web/src/components/errors/errors-hub.tsx @@ -0,0 +1,578 @@ +import { useCallback, useMemo, useReducer } from "react" +import { useNavigate } from "@tanstack/react-router" + +import type { ErrorIssueDocument, ErrorIssueId, IssueSeverity, WorkflowState } from "@maple/domain/http" +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Skeleton } from "@maple/ui/components/ui/skeleton" + +import { cn } from "@maple/ui/lib/utils" +import { warehouseDateTimeToIso } from "@maple/query-engine" + +import { ErrorState } from "@/components/common/error-state" +import { ListToolbar } from "@/components/common/list-toolbar" +import { useListNavigation } from "@/hooks/use-list-navigation" +import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { + getErrorsByTypeResultAtom, + getErrorsSparkResultAtom, +} from "@/lib/services/atoms/warehouse-query-atoms" +import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { errorIssueFromV2 } from "@/lib/services/error-issues" +import { buildErrorSignals, indexInvestigationsByIssue, type ErrorSignal } from "@/lib/models/error-signal" +import { + clearedSelection, + type IssueSelectionMsg, + type IssueSelectionState, + initialIssueSelection, + toggledSelection, + updateIssueSelection, +} from "@/lib/models/issue-selection" + +import { ErrorSignalHeader, ErrorSignalRow } from "./error-signal-row" +import { ErrorsStatStrip } from "./errors-stat-strip" +import { IssuesBulkBar } from "./issues-bulk-bar" +import { SEVERITY_FILL, SEVERITY_ORDER, SeverityDot, severityRank } from "./severity-badge" +import { useIssueMutations } from "./use-issue-mutations" + +/** + * The unified errors list. + * + * `/errors` grouped error events by fingerprint and knew nothing about triage; + * `/errors/issues` listed the same fingerprints from Postgres and knew nothing + * about volume; investigations sat on a third route. This joins them, so one + * row answers the whole triage question. See `lib/models/error-signal.ts` for + * why the fingerprint is the join key and why the issue table is the spine. + */ + +/** How many buckets the row sparkline gets. Enough to show a shape, few enough + * that 50 of them stay cheap to render. */ +const SPARK_BUCKETS = 32 +const PAGE_LIMIT = 100 + +export const HUB_VIEWS = ["open", "triage", "active", "resolved", "all"] as const +export type HubView = (typeof HUB_VIEWS)[number] + +const VIEW_LABEL: Record = { + open: "Open", + triage: "Triage", + active: "Active", + resolved: "Resolved", + 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, and it is `"all"` here because the + * SERVER already narrows it: `actionable=true` maps to exactly + * `ACTIONABLE_WORKFLOW_STATES` (triage, regressed, todo, in_progress, + * in_review). That makes the default view a pure server query — worth having, + * because every client-narrowed view fetches a page of actionable issues and + * then discards part of it, so it can render far fewer rows than the page size + * and paginate against a count that is not what you see. + */ +const VIEW_STATES = { + open: "all", + triage: ["triage", "regressed"], + active: ["todo", "in_progress", "in_review"], + resolved: ["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. */ +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. */ +function viewCovers(view: HubView, state: WorkflowState): boolean { + const states: ReadonlyArray | "all" = VIEW_STATES[view] + return states === "all" || states.includes(state) +} + +export const HUB_SORTS = ["volume", "severity", "last_seen"] as const +export type HubSort = (typeof HUB_SORTS)[number] + +const SORT_LABEL: Record = { + volume: "Most errors", + severity: "Severity", + last_seen: "Last seen", +} satisfies Record + +export const SEVERITY_FILTERS = ["all", "critical", "high", "medium", "low", "unset"] as const +export type SeverityFilter = (typeof SEVERITY_FILTERS)[number] + +const SEVERITY_FILTER_LABEL: Record = { + all: "All severities", + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + unset: "Unset", +} satisfies Record + +/** + * What the closed trigger shows. Shorter than the menu labels because the + * trigger has ~122px to work with and "All severities" truncated to + * "All sever:" — and the stacked blob beside it already says "all of them", so + * the word was doing no work. The menu keeps the long forms, where they read as + * options rather than as a current state. + */ +const SEVERITY_TRIGGER_LABEL: Record = { + all: "Severity", + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + unset: "Unset", +} satisfies Record + +/** Base UI renders the raw value in the trigger unless it is given a renderer, + * and these guards are what let that renderer index the label maps. */ +const isHubSort = (value: string | null): value is HubSort => HUB_SORTS.includes(value as HubSort) + +const isSeverityFilter = (value: string | null): value is SeverityFilter => + SEVERITY_FILTERS.includes(value as SeverityFilter) + +/** + * The blob beside a severity option. "All severities" gets the four real levels + * stacked into one mark rather than a fifth invented colour, so the menu shows + * exactly the palette it filters on; "Unset" reuses the hollow ring. + */ +function SeverityFilterDot({ value }: { value: SeverityFilter }) { + if (value === "all") { + return ( +

+ {toolbar} +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+ ) + } + + if (Result.isFailure(issuesResult)) { + return ( +
+ {toolbar} +
+ window.location.reload()} + /> +
+
+ ) + } + + return +} + +function sortSignals(signals: ReadonlyArray, sort: HubSort): ReadonlyArray { + const sorted = [...signals] + if (sort === "volume") { + // Quiet fingerprints sink rather than sorting as zero among real counts. + sorted.sort((a, b) => (b.windowCount ?? -1) - (a.windowCount ?? -1)) + } else if (sort === "severity") { + sorted.sort((a, b) => { + const diff = severityRank(a.severity) - severityRank(b.severity) + return diff !== 0 ? diff : (b.windowCount ?? -1) - (a.windowCount ?? -1) + }) + } else { + sorted.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)) + } + return sorted +} + +const EMPTY_COPY = { + open: { + title: "Nothing open", + description: + "Every error in this window is closed out. Widen the range, or check Resolved to see recent fixes.", + }, + triage: { + title: "Nothing waiting on triage", + description: "Every error in this window has been picked up. Widen the range to see more.", + }, + active: { + title: "Nothing in progress", + description: "No one has claimed an error in this window yet. Start from the triage queue.", + }, + all: { + title: "No errors in this window", + description: "Nothing was recorded here. Widen the time range or clear the service filters.", + }, + resolved: { + title: "Nothing resolved yet", + description: "Errors you close land here, so you can check whether a fix held.", + }, +} satisfies Record + +function HubList({ + signals, + sparkWindow, + toolbar, + view, +}: { + signals: ReadonlyArray + sparkWindow: { startMs: number; endMs: number; bucketMs: number } + toolbar: React.ReactNode + view: HubView +}) { + const [selection, dispatchSelection] = useReducer(selectionReducer, initialIssueSelection) + const selectedIds = selection.selectedIds + const mutations = useIssueMutations(() => dispatchSelection(clearedSelection)) + const navigate = useNavigate() + + const ids = useMemo(() => signals.map((signal) => signal.id), [signals]) + + const toggleSelection = useCallback( + (id: ErrorIssueId, event: { shiftKey: boolean }) => { + dispatchSelection(toggledSelection(id, event.shiftKey, ids)) + }, + [ids], + ) + + const clearSelection = useCallback(() => dispatchSelection(clearedSelection), []) + + const { focusedId, setFocusedId } = useListNavigation({ + ids, + onOpen: (id) => navigate({ to: "/errors/issues/$issueId", params: { issueId: id } }), + // Selection is keyboard-only now that rows carry no checkbox: "x" on the + // focused row, shift+"x" to extend. Per-row actions moved to the right-click + // menu, which is also where a single-row transition belongs. + onToggleSelect: toggleSelection, + onEscape: () => { + if (selectedIds.size === 0) return false + clearSelection() + return true + }, + scrollTo: (id) => scrollIntoView(id), + }) + + const selectedIssues = useMemo( + () => + signals + .filter((signal) => selectedIds.has(signal.id)) + .map((signal) => ({ id: signal.id, state: signal.issue.workflowState })), + [signals, selectedIds], + ) + + const empty = EMPTY_COPY[view] + + return ( +
+ {toolbar} + {signals.length === 0 ? ( +
+ + + {empty.title} + {empty.description} + + +
+ ) : ( + /* The header labels the columns; it is not one of the items, so it + sits outside the list rather than inside it. */ +
+ +
+ {signals.map((signal) => ( +
+ +
+ ))} +
+
+ )} + +
+ ) +} + +function scrollIntoView(issueId: string) { + if (typeof document === "undefined") return + const el = document.querySelector(`[data-issue-id="${CSS.escape(issueId)}"]`) + el?.scrollIntoView({ block: "nearest", behavior: "smooth" }) +} + +export type { IssueSeverity } diff --git a/apps/web/src/components/errors/errors-stat-strip.tsx b/apps/web/src/components/errors/errors-stat-strip.tsx new file mode 100644 index 000000000..0669261f7 --- /dev/null +++ b/apps/web/src/components/errors/errors-stat-strip.tsx @@ -0,0 +1,64 @@ +import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { Skeleton } from "@maple/ui/components/ui/skeleton" + +import { Result } from "@/lib/effect-atom" +import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" +import { getErrorsSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import type { GetErrorsSummaryInput } from "@/api/warehouse/errors" + +/** + * Window totals for the current filters. + * + * This replaced four KPI cards. The numbers are worth keeping — "how much is + * broken right now" and "is that a lot" are real questions — but as a card row + * they took the top third of the page to say what fits on one line, and pushed + * the list (the thing you came for) below the fold. + * + * Reads as a sentence, not a dashboard: the value carries the weight, the label + * stays quiet, and it sits inline with the toolbar. + */ +export function ErrorsStatStrip({ filters }: { filters: GetErrorsSummaryInput }) { + const summaryResult = useRefreshableAtomValue(getErrorsSummaryResultAtom({ data: filters })) + + return ( + Result.builder(summaryResult) + .onInitial(() => ( +
+ + + +
+ )) + // A failed summary must not take the list down with it — the rows carry + // their own counts and are the page's actual job. + .onError(() => null) + .onSuccess((response, result) => { + const summary = response.data + if (!summary) return null + + const stats = [ + { value: formatNumber(summary.totalErrors), label: "errors" }, + { value: formatErrorRate(summary.errorRate), label: "of all spans" }, + { value: formatNumber(summary.affectedServicesCount), label: "services" }, + { value: formatNumber(summary.affectedTracesCount), label: "traces" }, + ] + + return ( +
+ {stats.map((stat) => ( + + {stat.value} + {stat.label} + + ))} + in the selected window +
+ ) + }) + .render() + ) +} diff --git a/apps/web/src/components/errors/issue-comment-composer.tsx b/apps/web/src/components/errors/issue-comment-composer.tsx index cbdd5ccd3..1ff3297c7 100644 --- a/apps/web/src/components/errors/issue-comment-composer.tsx +++ b/apps/web/src/components/errors/issue-comment-composer.tsx @@ -1,14 +1,25 @@ -import * as React from "react" +import type { ActorDocument } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" import { Kbd, KbdGroup } from "@maple/ui/components/ui/kbd" import { Textarea } from "@maple/ui/components/ui/textarea" import { cn } from "@maple/ui/lib/utils" +import * as React from "react" + +import { useActorDirectory } from "@/hooks/use-actor-directory" +import { IdentityAvatar, resolveActorIdentity, type ActorIdentity } from "./actor-chip" interface IssueCommentComposerProps { value: string onChange: (value: string) => void onSubmit: () => void disabled?: boolean + /** + * Everyone who has touched this issue. Rendered as a participant strip so the + * humans can see which agents are in the thread with them — agents write here + * through the MCP `comment_on_error_issue` tool, and a comment from one + * arriving with no prior sign of it being present reads as a glitch. + */ + participants?: ReadonlyArray className?: string } @@ -17,8 +28,10 @@ export function IssueCommentComposer({ onChange, onSubmit, disabled, + participants = [], className, }: IssueCommentComposerProps) { + const directory = useActorDirectory() const canSubmit = !disabled && value.trim().length > 0 const handleKeyDown = (event: React.KeyboardEvent) => { @@ -28,28 +41,102 @@ export function IssueCommentComposer({ } } + const me = directory.me + const meIdentity: ActorIdentity | null = me + ? { + kind: "user", + name: me.name, + detail: me.email, + imageUrl: me.imageUrl, + initials: me.name.slice(0, 2).toUpperCase(), + seed: me.userId, + } + : null + return ( -
-