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/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 2cf018b67..42b0b9515 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -4541,6 +4541,14 @@ "type": "string" } }, + { + "in": "query", + "name": "fingerprint_hash", + "required": false, + "schema": { + "type": "string" + } + }, { "in": "query", "name": "deployment_environment", diff --git a/apps/web/src/api/warehouse/errors.ts b/apps/web/src/api/warehouse/errors.ts index 3bb115d20..d25ec8a4d 100644 --- a/apps/web/src/api/warehouse/errors.ts +++ b/apps/web/src/api/warehouse/errors.ts @@ -11,7 +11,7 @@ import { ErrorsByTypeRequest, ErrorsSummaryRequest, ErrorDetailTracesRequest, - ErrorsTimeseriesRequest, + ErrorsSparkRequest, FingerprintHash, ServiceName, } from "@maple/domain/http" @@ -27,6 +27,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 +46,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 +78,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 +101,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 +141,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 +152,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 +187,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 +218,8 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio services: input.services, deploymentEnvs: input.deploymentEnvs, fingerprintHashes: input.fingerprintHashes, + errorLabels: input.errorLabels, + serviceVersions: input.serviceVersions, }), }) }), @@ -207,16 +229,6 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio return { data: coerceErrorsSummary(result.data) } }) -export interface ErrorDetailTrace { - traceId: string - startTime: Date - durationMicros: number - spanCount: number - services: string[] - rootSpanName: string - errorMessage: string -} - const GetErrorDetailTracesInputSchema = Schema.Struct({ fingerprintHash: FingerprintHash, startTime: Schema.optional(WarehouseDateTimeString), @@ -275,48 +287,76 @@ export interface ErrorsTimeseriesItem { count: number } -const GetErrorsTimeseriesInputSchema = Schema.Struct({ - fingerprintHash: FingerprintHash, +/** + * 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))), - showSpam: Schema.optional(Schema.Boolean), }) -export type GetErrorsTimeseriesInput = (typeof GetErrorsTimeseriesInputSchema)["Encoded"] +export type GetErrorsSparkInput = (typeof GetErrorsSparkInputSchema)["Encoded"] -export function getErrorsTimeseries({ data }: { data: GetErrorsTimeseriesInput }) { - return getErrorsTimeseriesEffect({ data }) +export function getErrorsSpark({ data }: { data: GetErrorsSparkInput }) { + return getErrorsSparkEffect({ data }) } -const getErrorsTimeseriesEffect = Effect.fn("QueryEngine.getErrorsTimeseries")(function* ({ +const getErrorsSparkEffect = Effect.fn("QueryEngine.getErrorsSpark")(function* ({ data, }: { - data: GetErrorsTimeseriesInput + data: GetErrorsSparkInput }) { - const input = yield* decodeInput(GetErrorsTimeseriesInputSchema, data ?? {}, "getErrorsTimeseries") + const input = yield* decodeInput(GetErrorsSparkInputSchema, data ?? {}, "getErrorsSpark") const fallback = defaultErrorsTimeRange(yield* Clock.currentTimeMillis) - const result = yield* runWarehouseQuery("errorsTimeseries", () => + // 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.errorsTimeseries({ - payload: new ErrorsTimeseriesRequest({ + return yield* client.queryEngine.errorsSpark({ + payload: new ErrorsSparkRequest({ startTime: input.startTime ?? fallback.startTime, endTime: input.endTime ?? fallback.endTime, - fingerprintHash: input.fingerprintHash, + 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: result.data.map((raw) => ({ - bucket: String(raw.bucket), - count: Number(raw.count), - })), + 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/attributes/index.ts b/apps/web/src/components/attributes/index.ts index fff36de92..025e3f2fd 100644 --- a/apps/web/src/components/attributes/index.ts +++ b/apps/web/src/components/attributes/index.ts @@ -3,7 +3,6 @@ // existing `@/components/attributes` import path working. export { CopyableValue, - AttributesTable, AttributesSection, ResourceAttributesSection, tryParseJson, 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..e146c07b0 --- /dev/null +++ b/apps/web/src/components/errors/errors-hub.tsx @@ -0,0 +1,576 @@ +import { useCallback, useMemo, useReducer } from "react" +import { useNavigate } from "@tanstack/react-router" + +import type { ErrorIssueDocument, ErrorIssueId, 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" }) +} 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 ( -
-