Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/routes/v2/error-issues.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5393,6 +5393,38 @@
"type": "string"
}
},
{
"in": "query",
"name": "state",
"required": false,
"schema": {
"enum": [
"open",
"closed"
],
"type": "string"
}
},
{
"in": "query",
"name": "search",
"required": false,
"schema": {
"maxLength": 200,
"type": "string"
}
},
{
"in": "query",
"name": "include_archived",
"required": false,
"schema": {
"enum": [
"true"
],
"type": "string"
}
},
{
"in": "query",
"name": "sort",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/errors/error-signal-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function ErrorSignalHeader({
) : null}
</span>
<span className={LANE.severity} />
<span className={LANE.identity}>Error</span>
<span className={LANE.identity}>Issue</span>
<span className={LANE.volume}>
<span className={LANE.spark}>Trend · 24h</span>
<span className={LANE.count}>Events</span>
Expand Down
102 changes: 65 additions & 37 deletions apps/web/src/components/errors/errors-hub-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,37 +64,22 @@ const SKELETON_ROWS = 8

const VIEW_LABEL: Record<HubView, string> = {
open: "Open",
triage: "Triage",
active: "Active",
resolved: "Resolved",
closed: "Closed",
all: "All",
} satisfies Record<HubView, string>

/**
* 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<HubView, ReadonlyArray<WorkflowState> | "all">

/** Views the server can narrow with `actionable=true`, so the page it returns
* is already the right one. */
export const ACTIONABLE_VIEWS: ReadonlyArray<HubView> = ["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. */
Expand Down Expand Up @@ -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<HubView, { icon: typeof CircleCheckIcon; title: string; description: string }>

Expand All @@ -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
Expand All @@ -260,6 +239,8 @@ export function ErrorsHubView({
onViewChange,
onSortChange,
onSeverityChange,
search,
onSearchChange,
stats,
paging,
onClearFilters,
Expand All @@ -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={
<>
<ToolbarSearch
query={search}
onSearch={onSearchChange}
placeholder="Search issues..."
size="sm"
className="w-52"
/>
<Select
value={sort}
onValueChange={(value) => {
Expand Down Expand Up @@ -383,8 +371,10 @@ export function ErrorsHubView({
toolbar={toolbar}
view={view}
severity={severity}
search={search}
onViewChange={onViewChange}
onSeverityChange={onSeverityChange}
onSearchChange={onSearchChange}
paging={paging}
onClearFilters={onClearFilters}
/>
Expand All @@ -400,8 +390,10 @@ function HubList({
toolbar,
view,
severity,
search,
onViewChange,
onSeverityChange,
onSearchChange,
paging,
onClearFilters,
}: {
Expand All @@ -410,8 +402,10 @@ function HubList({
toolbar: React.ReactNode
view: HubView
severity: SeverityFilter
search: string
onViewChange: (view: HubView) => void
onSeverityChange: (severity: SeverityFilter) => void
onSearchChange: (search: string | undefined) => void
paging: HubPaging | undefined
onClearFilters: (() => void) | undefined
}) {
Expand Down Expand Up @@ -476,8 +470,10 @@ function HubList({
<HubEmpty
view={view}
severity={severity}
search={search}
onViewChange={onViewChange}
onSeverityChange={onSeverityChange}
onSearchChange={onSearchChange}
onClearFilters={onClearFilters}
/>
) : (
Expand Down Expand Up @@ -516,14 +512,18 @@ function HubList({
function HubEmpty({
view,
severity,
search,
onViewChange,
onSeverityChange,
onSearchChange,
onClearFilters,
}: {
view: HubView
severity: SeverityFilter
search: string
onViewChange: (view: HubView) => void
onSeverityChange: (severity: SeverityFilter) => void
onSearchChange: (search: string | undefined) => void
onClearFilters: (() => void) | undefined
}) {
const empty = EMPTY_COPY[view]
Expand All @@ -536,6 +536,34 @@ function HubEmpty({
const narrowed = filtered || onClearFilters !== undefined
const presence = useSignalPresence("traces")

if (search !== "") {
return (
<Empty className="py-12">
<EmptyHeader>
<EmptyMedia variant="icon">
<MagnifierIcon size={18} />
</EmptyMedia>
<EmptyTitle>No issues match “{search}”</EmptyTitle>
<EmptyDescription>
{view === "all"
? "Search covers the type, message and service of every issue."
: `Nothing in ${VIEW_LABEL[view]} matches. It may be in another tab.`}
</EmptyDescription>
</EmptyHeader>
<div className="flex flex-wrap items-center justify-center gap-2">
{view !== "all" ? (
<Button size="sm" variant="outline" onClick={() => onViewChange("all")}>
Search all issues
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={() => onSearchChange(undefined)}>
Clear search
</Button>
</div>
</Empty>
)
}

// An all-clear is only true once traces arrive. Before that, the empty list means setup.
if (!narrowed && presence.status === "absent") {
return (
Expand Down
25 changes: 17 additions & 8 deletions apps/web/src/components/errors/errors-hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
import { useNavigate } from "@tanstack/react-router"

import type { ErrorIssueDocument, IssueKind } from "@maple/domain/http"
import { ERROR_ISSUE_SEARCH_MAX_LENGTH } from "@maple/domain/http/v2"

import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value"
import { Atom, Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom"
Expand All @@ -18,7 +19,7 @@ import {
sparkFingerprintHashes,
} from "@/lib/models/error-signal"

import { ACTIONABLE_VIEWS, ErrorsHubView, type HubPaging, viewCovers } from "./errors-hub-view"
import { ErrorsHubView, type HubPaging, viewCovers } from "./errors-hub-view"
import { ErrorsStatStrip } from "./errors-stat-strip"

/**
Expand Down Expand Up @@ -65,6 +66,8 @@ export interface ErrorsHubProps {
env?: string
kind?: IssueKind
regressed?: boolean
/** Free-text search, matched server-side on type, message, label and service. */
search?: string
/** Present while a sidebar filter is on, for the empty state's way out.
* Owned by the route, which is where navigation lives. */
onClearFilters?: () => void
Expand Down Expand Up @@ -130,15 +133,18 @@ export function ErrorsHub(props: ErrorsHubProps) {
...(props.service ? { service_name: props.service } : undefined),
...(props.env ? { deployment_environment: props.env } : undefined),
...(props.kind ? { kind: props.kind } : undefined),
// One state per request is all the API takes, so multi-state views are
// filtered client-side from the page. `actionable` covers the common
// triage+active case server-side so that page is the right one; the
// regression toggle is a single state and replaces it outright.
// The server narrows every view, so paging reaches every match. Filtering
// a mixed page client-side left Closed empty whenever the newest page was
// all open issues.
...(props.regressed
? { workflow_state: "regressed" as const }
: ACTIONABLE_VIEWS.includes(props.view)
? { actionable: "true" as const }
: undefined),
: props.view === "all"
? undefined
: { state: props.view }),
// Done issues are archived two weeks after closing; they still belong in Closed.
...(props.view === "open" ? undefined : { include_archived: "true" as const }),
// A pasted log line can outrun the API's cap; clamp rather than fail the page.
...(props.search ? { search: props.search.slice(0, ERROR_ISSUE_SEARCH_MAX_LENGTH) } : undefined),
...(warehouseFirst ? { fingerprint_hash: rankedHashes.join(",") } : undefined),
}
}, [
Expand All @@ -149,6 +155,7 @@ export function ErrorsHub(props: ErrorsHubProps) {
props.env,
props.kind,
props.regressed,
props.search,
warehouseFirst,
rankedHashes,
])
Expand Down Expand Up @@ -312,6 +319,8 @@ export function ErrorsHub(props: ErrorsHubProps) {
onViewChange={(value) => setSearch({ view: value === "open" ? undefined : value })}
onSortChange={(value) => setSearch({ sort: value === "last_seen" ? undefined : value })}
onSeverityChange={(value) => setSearch({ severity: value === "all" ? undefined : value })}
search={props.search ?? ""}
onSearchChange={(value) => setSearch({ q: value })}
stats={<ErrorsStatStrip filters={warehouseFilters} />}
paging={paging}
onClearFilters={props.onClearFilters}
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/components/errors/errors-stat-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ export function ErrorsStatStrip({ filters }: { filters: GetErrorsSummaryInput })
Result.builder(summaryResult)
.onInitial(() => (
<div className="flex items-center gap-4 px-3 py-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-28" />
</div>
))
// A failed summary must not take the list down with it — the rows carry
Expand All @@ -48,10 +47,10 @@ export function ErrorsStatStrip({ filters }: { filters: GetErrorsSummaryInput })
aria-busy={result.waiting || undefined}
>
<ToolbarStats className="flex-wrap gap-y-1">
<ToolbarStat value={summary.totalErrors} label="errors" />
{/* Events, not issues: the toolbar right below counts issues. Services
are counted in the sidebar already. */}
<ToolbarStat value={summary.totalErrors} label="error events" />
<ToolbarStat value={formatErrorRate(summary.errorRate)} label="of all spans" />
<ToolbarStat value={summary.affectedServicesCount} label="services" />
<ToolbarStat value={summary.affectedTracesCount} label="traces" />
</ToolbarStats>
<span className="text-xs text-muted-foreground/60">in the last 24 hours</span>
</div>
Expand Down
Loading
Loading