From 8e5355b3a1ed1562ebe52ceb380f12dd6281b4fc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:20:58 +0200 Subject: [PATCH 1/5] feat(ui): ConfirmDialog, Panel, SettingRow, KeyValueList, Meter, Delta, ListFooter, TruncatedId/Text primitives Badge gains pill shape, xs size, mono and muted/meta variants; Table gains sm/xs density, a bare variant and a sticky header; Alert gets a compact size; Item gets card/flush variants and an lg size. Shared tone, error-rate and HTTP-status maps live in @maple/ui/lib. RelativeTime, SheetDetailHeader, PageHero, DataTable, SearchToolbar and the link tab strip move to apps/web common. --- .../tools/tool-breakdown-tables.tsx | 2 +- .../tools/tool-errors-table.tsx | 2 +- .../components/analytics/ai/ai-sections.tsx | 2 +- .../analytics/analytics-breakdown-panel.tsx | 2 +- .../primitives => common}/data-table.tsx | 6 +- .../primitives => common}/page-hero.tsx | 12 +- .../src/components/common/relative-time.tsx | 70 +++++++++++ .../search-toolbar.tsx} | 0 .../components/common/sheet-detail-header.tsx | 48 ++++++++ .../underline-link-tabs.tsx | 24 +++- apps/web/src/components/errors/issue-tabs.tsx | 2 +- .../cloudflare/cloudflare-breakdown-panel.tsx | 2 +- .../cloudflare/cloudflare-platform-table.tsx | 2 +- .../cloudflare/cloudflare-worker-table.tsx | 2 +- .../infra/cloudflare/cloudflare-zone-dns.tsx | 2 +- .../cloudflare/cloudflare-zone-security.tsx | 2 +- .../cloudflare/cloudflare-zone-table.tsx | 2 +- .../src/components/infra/container-table.tsx | 2 +- .../components/infra/host-detail-header.tsx | 2 +- apps/web/src/components/infra/host-table.tsx | 2 +- .../infra/kubernetes/pod-peek-sheet.tsx | 2 +- apps/web/src/components/infra/node-table.tsx | 2 +- .../planetscale-branch-breakdown-panel.tsx | 2 +- .../planetscale-database-table.tsx | 2 +- apps/web/src/components/infra/pod-table.tsx | 2 +- .../infra/primitives/chart-card.tsx | 14 +-- .../infra/railway/railway-service-table.tsx | 2 +- .../src/components/infra/workload-table.tsx | 2 +- .../integrations/cloudflare-zone-board.tsx | 2 +- .../investigations/investigation-tabs.tsx | 2 +- .../src/components/services/section-card.tsx | 13 +-- apps/web/src/hooks/use-table-sort.ts | 2 +- apps/web/src/lab/infra-lab.tsx | 4 +- .../src/routes/infra/cloudflare/$zoneName.tsx | 2 +- .../infra/containers/$containerName.tsx | 2 +- .../web/src/routes/infra/containers/index.tsx | 4 +- apps/web/src/routes/infra/hosts/index.tsx | 4 +- apps/web/src/routes/infra/index.tsx | 2 +- .../infra/kubernetes/nodes/$nodeName.tsx | 2 +- .../routes/infra/kubernetes/nodes/index.tsx | 2 +- .../routes/infra/kubernetes/pods/$podName.tsx | 2 +- .../routes/infra/kubernetes/pods/index.tsx | 2 +- .../workloads/$kind/$workloadName.tsx | 2 +- .../infra/kubernetes/workloads/index.tsx | 2 +- .../src/routes/infra/planetscale/$dbName.tsx | 2 +- .../src/routes/infra/planetscale/index.tsx | 2 +- .../src/routes/infra/railway/$serviceId.tsx | 2 +- apps/web/src/routes/infra/railway/index.tsx | 6 +- .../ui/src/components/error-rate-value.tsx | 20 ++++ .../ui/src/components/http-status-code.tsx | 32 +++++ packages/ui/src/components/ui/alert.tsx | 9 +- packages/ui/src/components/ui/badge.tsx | 35 +++++- .../ui/src/components/ui/confirm-dialog.tsx | 110 ++++++++++++++++++ packages/ui/src/components/ui/delta.tsx | 67 +++++++++++ packages/ui/src/components/ui/item.tsx | 10 +- packages/ui/src/components/ui/key-value.tsx | 78 +++++++++++++ packages/ui/src/components/ui/list-footer.tsx | 98 ++++++++++++++++ packages/ui/src/components/ui/meter.tsx | 97 +++++++++++++++ packages/ui/src/components/ui/panel.tsx | 69 +++++++++++ packages/ui/src/components/ui/setting-row.tsx | 66 +++++++++++ packages/ui/src/components/ui/skeleton.tsx | 38 +++++- packages/ui/src/components/ui/status-dot.tsx | 2 + packages/ui/src/components/ui/table.tsx | 46 ++++++-- packages/ui/src/components/ui/toggle.tsx | 3 + .../ui/src/components/ui/truncated-id.tsx | 40 +++++++ .../ui/src/components/ui/truncated-text.tsx | 52 +++++++++ packages/ui/src/lib/error-rate.ts | 42 +++++++ packages/ui/src/lib/http.ts | 8 ++ packages/ui/src/lib/ids.ts | 23 ++++ packages/ui/src/lib/tone.ts | 77 ++++++++++++ 70 files changed, 1212 insertions(+), 87 deletions(-) rename apps/web/src/components/{infra/primitives => common}/data-table.tsx (96%) rename apps/web/src/components/{infra/primitives => common}/page-hero.tsx (71%) create mode 100644 apps/web/src/components/common/relative-time.tsx rename apps/web/src/components/{infra/primitives/list-toolbar.tsx => common/search-toolbar.tsx} (100%) create mode 100644 apps/web/src/components/common/sheet-detail-header.tsx rename apps/web/src/components/{errors => common}/underline-link-tabs.tsx (57%) create mode 100644 packages/ui/src/components/error-rate-value.tsx create mode 100644 packages/ui/src/components/http-status-code.tsx create mode 100644 packages/ui/src/components/ui/confirm-dialog.tsx create mode 100644 packages/ui/src/components/ui/delta.tsx create mode 100644 packages/ui/src/components/ui/key-value.tsx create mode 100644 packages/ui/src/components/ui/list-footer.tsx create mode 100644 packages/ui/src/components/ui/meter.tsx create mode 100644 packages/ui/src/components/ui/panel.tsx create mode 100644 packages/ui/src/components/ui/setting-row.tsx create mode 100644 packages/ui/src/components/ui/truncated-id.tsx create mode 100644 packages/ui/src/components/ui/truncated-text.tsx create mode 100644 packages/ui/src/lib/error-rate.ts create mode 100644 packages/ui/src/lib/ids.ts create mode 100644 packages/ui/src/lib/tone.ts diff --git a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx index f692f61dc3..1b49c6fe79 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx @@ -6,7 +6,7 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { ErrorState } from "@/components/common/error-state" -import { useTableSort, type SortDir } from "@/components/infra/primitives/data-table" +import { useTableSort, type SortDir } from "@/components/common/data-table" import { relativeRatio } from "@/components/infra/primitives/share-bar" import { ArrowUpDownIcon, ChevronRightIcon } from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" diff --git a/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx b/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx index 38c9defb4a..ae3214fb82 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-errors-table.tsx @@ -6,7 +6,7 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" -import { useTableSort } from "@/components/infra/primitives/data-table" +import { useTableSort } from "@/components/common/data-table" import { ErrorState } from "@/components/common/error-state" import { ChevronDownIcon, ChevronRightIcon } from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" diff --git a/apps/web/src/components/analytics/ai/ai-sections.tsx b/apps/web/src/components/analytics/ai/ai-sections.tsx index df95e5b663..3b9089c7eb 100644 --- a/apps/web/src/components/analytics/ai/ai-sections.tsx +++ b/apps/web/src/components/analytics/ai/ai-sections.tsx @@ -13,7 +13,7 @@ import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { formatRelativeTime } from "@maple/ui/lib/time-format" import type { AiContentFormat, AiCrawlPurpose, AiProduct } from "@maple/domain/ai-traffic" -import { ColumnHead, DataTable } from "../../infra/primitives/data-table" +import { ColumnHead, DataTable } from "@/components/common/data-table" import { shareBar } from "../../infra/primitives/share-bar" import { BarSpark } from "@/components/common/stat-rail" import { SPARK_COLOR } from "../../infra/severity-tokens" diff --git a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx index 2ba744e434..a9cbaa5aee 100644 --- a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx +++ b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx @@ -11,7 +11,7 @@ import { DialogTitle, } from "@maple/ui/components/ui/dialog" -import { ColumnHead, DataTable, useTableSort, type SortDir } from "../infra/primitives/data-table" +import { ColumnHead, DataTable, useTableSort, type SortDir } from "@/components/common/data-table" import { shareBar } from "../infra/primitives/share-bar" import { MaximizeIcon } from "@/components/icons" import type { WebAnalyticsFacetRow } from "@/api/warehouse/web-analytics" diff --git a/apps/web/src/components/infra/primitives/data-table.tsx b/apps/web/src/components/common/data-table.tsx similarity index 96% rename from apps/web/src/components/infra/primitives/data-table.tsx rename to apps/web/src/components/common/data-table.tsx index c4fc554ca2..67b49fab49 100644 --- a/apps/web/src/components/infra/primitives/data-table.tsx +++ b/apps/web/src/components/common/data-table.tsx @@ -1,6 +1,6 @@ -// Shared chrome for the infra resource tables (hosts, pods, nodes, workloads). -// Each table keeps its own row JSX — only the sort logic, column header, meta -// chip, container, and skeleton shell live here. +// Flex-row list table: fixed-width column lanes, whole-row links, sortable +// heads. Born in the infra resource tables; use it for any list whose rows are +// links. Semantic `` data goes through @maple/ui's Table instead. import { EmptyMessage } from "@maple/ui/components/ui/empty" import * as React from "react" diff --git a/apps/web/src/components/infra/primitives/page-hero.tsx b/apps/web/src/components/common/page-hero.tsx similarity index 71% rename from apps/web/src/components/infra/primitives/page-hero.tsx rename to apps/web/src/components/common/page-hero.tsx index dd39590cdd..2b5f835fc9 100644 --- a/apps/web/src/components/infra/primitives/page-hero.tsx +++ b/apps/web/src/components/common/page-hero.tsx @@ -1,6 +1,12 @@ import * as React from "react" +import { Badge } from "@maple/ui/components/ui/badge" import { cn } from "@maple/ui/lib/utils" +/** + * In-body page title for pages whose header row is a breadcrumb (infra, detail + * pages). Typography matches `PageLayout.Title` so a hero and a layout header + * read as the same heading. + */ interface PageHeroProps { title: React.ReactNode description?: React.ReactNode @@ -15,7 +21,7 @@ export function PageHero({ title, description, meta, actions, trailing, classNam
-

+

{title}

{trailing} @@ -32,8 +38,8 @@ export function PageHero({ title, description, meta, actions, trailing, classNam export function HeroChip({ children }: { children: React.ReactNode }) { return ( - + {children} - + ) } diff --git a/apps/web/src/components/common/relative-time.tsx b/apps/web/src/components/common/relative-time.tsx new file mode 100644 index 0000000000..ba240f546d --- /dev/null +++ b/apps/web/src/components/common/relative-time.tsx @@ -0,0 +1,70 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { + formatRelativeShort, + formatRelativeTime, + formatRelativeTimeOrDate, + type TimeInput, + toEpochMs, +} from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" + +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatTimestampInTimezone } from "@/lib/timezone-format" + +/** + * "5m ago" with the absolute time, in the viewer's selected timezone, on hover. + * Use this instead of pairing a relative label with `toLocaleString()` (which + * ignores the timezone setting) or a raw ISO tooltip. + */ +export function RelativeTime({ + value, + variant = "long", + prefix, + mono = false, + tooltip = "tooltip", + className, +}: { + value: TimeInput + /** `long` "5 minutes ago", `short` "5m", `orDate` relative inside a week, a date beyond. */ + variant?: "long" | "short" | "orDate" + /** Leading words inside the same span ("Last seen"). */ + prefix?: string + mono?: boolean + /** `tooltip` is the styled popover; `title` the native attribute for dense tables. */ + tooltip?: "tooltip" | "title" + className?: string +}) { + const { effectiveTimezone } = useTimezonePreference() + const epochMs = toEpochMs(value) + if (!Number.isFinite(epochMs)) { + return — + } + + const relative = + variant === "short" + ? formatRelativeShort(epochMs) + : variant === "orDate" + ? formatRelativeTimeOrDate(epochMs, Date.now(), effectiveTimezone) + : formatRelativeTime(epochMs) + const absolute = formatTimestampInTimezone(epochMs, { timeZone: effectiveTimezone, withYear: true }) + const label = prefix ? `${prefix} ${relative}` : relative + const classes = cn(mono && "font-mono tabular-nums", className) + const iso = new Date(epochMs).toISOString() + + if (tooltip === "title") { + return ( + + ) + } + + return ( + + } className={cn("cursor-default", classes)}> + {label} + + {absolute} + + ) +} diff --git a/apps/web/src/components/infra/primitives/list-toolbar.tsx b/apps/web/src/components/common/search-toolbar.tsx similarity index 100% rename from apps/web/src/components/infra/primitives/list-toolbar.tsx rename to apps/web/src/components/common/search-toolbar.tsx diff --git a/apps/web/src/components/common/sheet-detail-header.tsx b/apps/web/src/components/common/sheet-detail-header.tsx new file mode 100644 index 0000000000..4b32326254 --- /dev/null +++ b/apps/web/src/components/common/sheet-detail-header.tsx @@ -0,0 +1,48 @@ +import type * as React from "react" + +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { SheetDescription, SheetHeader, SheetTitle } from "@maple/ui/components/ui/sheet" +import { cn } from "@maple/ui/lib/utils" + +/** + * Header of a peek/detail sheet: kind eyebrow, mono title, optional meta row. + * `pr-14` leaves room for the sheet's own close button. + */ +export function SheetDetailHeader({ + kind, + title, + adornment, + description, + meta, + mono = true, + className, +}: { + /** "Trace", "Pod", "Action". */ + kind?: React.ReactNode + title: React.ReactNode + /** Status badge beside the title. */ + adornment?: React.ReactNode + /** Screen-reader description of the sheet; required by the dialog semantics. */ + description: string + /** Chips/facts row under the title. */ + meta?: React.ReactNode + mono?: boolean + className?: string +}) { + return ( + + {kind ? {kind} : null} + + {title} + {adornment} + + {description} + {meta ?
{meta}
: null} +
+ ) +} diff --git a/apps/web/src/components/errors/underline-link-tabs.tsx b/apps/web/src/components/common/underline-link-tabs.tsx similarity index 57% rename from apps/web/src/components/errors/underline-link-tabs.tsx rename to apps/web/src/components/common/underline-link-tabs.tsx index 035f011bdc..e324cc3791 100644 --- a/apps/web/src/components/errors/underline-link-tabs.tsx +++ b/apps/web/src/components/common/underline-link-tabs.tsx @@ -2,16 +2,30 @@ import type React from "react" import { cn } from "@maple/ui/lib/utils" /** - * The Link-based underline tab strip shared by the issue and investigation pages. - * Full-bleed out of the sticky area's `p-4` and flush to its bottom edge, so the - * underline reads as the boundary between header and content. + * Link-based underline tab strip for route tabs (issue, investigation, code + * review). `bleed` pulls it out of a `p-4` sticky header so the underline is + * flush with the header's bottom edge. */ -export function UnderlineTabStrip({ label, children }: { label: string; children: React.ReactNode }) { +export function UnderlineTabStrip({ + label, + bleed = true, + className, + children, +}: { + label: string + bleed?: boolean + className?: string + children: React.ReactNode +}) { return (
{children}
diff --git a/apps/web/src/components/errors/issue-tabs.tsx b/apps/web/src/components/errors/issue-tabs.tsx index 633dd739b5..e19abbd58e 100644 --- a/apps/web/src/components/errors/issue-tabs.tsx +++ b/apps/web/src/components/errors/issue-tabs.tsx @@ -1,7 +1,7 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueId } from "@maple/domain/http" -import { UnderlineTabCount, UnderlineTabStrip, underlineTabClass } from "./underline-link-tabs" +import { UnderlineTabCount, UnderlineTabStrip, underlineTabClass } from "@/components/common/underline-link-tabs" export const ISSUE_TABS = ["overview", "occurrences", "activity"] as const diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx index 5e3f1a3d51..e1432fc096 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx @@ -39,7 +39,7 @@ import { formatNumber } from "@maple/ui/lib/format" import { XmarkIcon } from "@/components/icons" import { MonoLinkButton } from "../primitives/mono-link-button" import { CompactFilterInput } from "./compact-filter-input" -import { ColumnHead, DataTable, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, useTableSort } from "@/components/common/data-table" import { SegmentPivot } from "../primitives/segment-pivot" import { shareBar } from "../primitives/share-bar" import { formatBytes, formatPercent } from "@maple/ui/lib/format" diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx index 643745522d..fd746f4616 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-platform-table.tsx @@ -8,7 +8,7 @@ import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import type { CloudflareDurableObjectRow, CloudflareQueueRow } from "@/api/warehouse/cloudflare-infra" import { cloudflarePlatformResourcesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, useTableSort } from "@/components/common/data-table" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { errorRateClass } from "./constants" diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx index 2d4a9a757f..9c57756b7c 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx @@ -3,7 +3,7 @@ import { LatencyValue } from "@maple/ui/components/latency-value" import type { CloudflareWorkerRow } from "@/api/warehouse/cloudflare-infra" import { formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, useTableSort } from "@/components/common/data-table" import { formatPercent } from "@maple/ui/lib/format" import { errorRateClass } from "./constants" diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-zone-dns.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-zone-dns.tsx index 09991aac6b..fa2d7f37dc 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-zone-dns.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-zone-dns.tsx @@ -9,7 +9,7 @@ import { Result } from "@/lib/effect-atom" import { cloudflareZoneDnsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable } from "../primitives/data-table" +import { ColumnHead, DataTable } from "@/components/common/data-table" import { formatPercent } from "@maple/ui/lib/format" import { StackedBreakdownChart } from "./cloudflare-zone-detail-charts" import { PanelScope } from "./panel-scope" diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-zone-security.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-zone-security.tsx index 1034fc7c4f..90aa2d78d8 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-zone-security.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-zone-security.tsx @@ -8,7 +8,7 @@ import { Result } from "@/lib/effect-atom" import { cloudflareZoneSecurityResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable } from "../primitives/data-table" +import { ColumnHead, DataTable } from "@/components/common/data-table" import { StackedBreakdownChart } from "./cloudflare-zone-detail-charts" import { PanelScope } from "./panel-scope" import type { CloudflareFilters } from "./filters" diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx index b3182ddd76..b5bac9c649 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx @@ -5,7 +5,7 @@ import { LatencyValue } from "@maple/ui/components/latency-value" import type { CloudflareZoneRow } from "@/api/warehouse/cloudflare-infra" import { formatLatency, formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { errorRateClass } from "./constants" diff --git a/apps/web/src/components/infra/container-table.tsx b/apps/web/src/components/infra/container-table.tsx index 6eb8b4fa67..25bda4f061 100644 --- a/apps/web/src/components/infra/container-table.tsx +++ b/apps/web/src/components/infra/container-table.tsx @@ -7,7 +7,7 @@ import type { ListContainersResponse } from "@maple/domain/http" import type { ContainerSortKey, SortDirection } from "@/api/warehouse/infra" import { HostStatusBadge } from "./status-badge" -import { ColumnHead, DataTable, ROW_LINK_CLASS } from "./primitives/data-table" +import { ColumnHead, DataTable, ROW_LINK_CLASS } from "@/components/common/data-table" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" import { formatRelativeTime } from "@maple/ui/lib/time-format" diff --git a/apps/web/src/components/infra/host-detail-header.tsx b/apps/web/src/components/infra/host-detail-header.tsx index 49661d7d5b..a8424fac96 100644 --- a/apps/web/src/components/infra/host-detail-header.tsx +++ b/apps/web/src/components/infra/host-detail-header.tsx @@ -6,7 +6,7 @@ import type { HostDetailSummaryResponse } from "@maple/domain/http" import { HostStatusBadge } from "./status-badge" import { PlatformLabel } from "./platform-label" -import { HeroChip, PageHero } from "./primitives/page-hero" +import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { severityLevel } from "./format" import { formatLoad, formatPercent } from "@maple/ui/lib/format" diff --git a/apps/web/src/components/infra/host-table.tsx b/apps/web/src/components/infra/host-table.tsx index cc92e2f4c3..c29edca312 100644 --- a/apps/web/src/components/infra/host-table.tsx +++ b/apps/web/src/components/infra/host-table.tsx @@ -15,7 +15,7 @@ import { type SortControls, ROW_LINK_CLASS, useTableSort, -} from "./primitives/data-table" +} from "@/components/common/data-table" import { formatLoad } from "@maple/ui/lib/format" import { formatRelativeTime } from "@maple/ui/lib/time-format" diff --git a/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx b/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx index 4f6a1ec48a..bda13e9aaf 100644 --- a/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx +++ b/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx @@ -26,7 +26,7 @@ import { bucketSecondsForRange } from "@/components/infra/constants" import { severityLevel } from "@/components/infra/format" import { PodDetailChart } from "@/components/infra/k8s-detail-chart" import { podKey, type PodRow } from "@/components/infra/pod-table" -import { HeroChip } from "@/components/infra/primitives/page-hero" +import { HeroChip } from "@/components/common/page-hero" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { HostStatusBadge } from "@/components/infra/status-badge" import type { TimeRangeSearch } from "@/components/time-range-picker/search" diff --git a/apps/web/src/components/infra/node-table.tsx b/apps/web/src/components/infra/node-table.tsx index fb64036239..545b27f7a7 100644 --- a/apps/web/src/components/infra/node-table.tsx +++ b/apps/web/src/components/infra/node-table.tsx @@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui import type { ListNodesResponse } from "@maple/domain/http" import { HostStatusBadge } from "./status-badge" -import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "./primitives/data-table" +import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" import { MetaLine } from "./primitives/meta-line" import { formatUptime } from "@maple/ui/lib/format" import { formatRelativeTime } from "@maple/ui/lib/time-format" diff --git a/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx b/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx index 8d9666553a..52fa380fdd 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx @@ -4,7 +4,7 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" -import { ColumnHead, DataTable, MetaChip, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, MetaChip, useTableSort } from "@/components/common/data-table" import { SegmentPivot } from "../primitives/segment-pivot" import { relativeRatio, shareBar } from "../primitives/share-bar" import type { BranchCandidate } from "./branch-selection" diff --git a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx index f5a346a561..8fea264404 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx @@ -8,7 +8,7 @@ import { cn } from "@maple/ui/lib/utils" import type { V2PlanetScaleDatabase } from "@maple/domain/http/v2" import type { PlanetScaleDatabaseStat } from "@/api/warehouse/service-map" import { formatNumber } from "@maple/ui/lib/format" -import { ColumnHead, DataTable, MetaChip, ROW_LINK_CLASS, useTableSort } from "../primitives/data-table" +import { ColumnHead, DataTable, MetaChip, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" import { MISSING, abnormalState, diff --git a/apps/web/src/components/infra/pod-table.tsx b/apps/web/src/components/infra/pod-table.tsx index 9b968d4da2..a9f7998fa5 100644 --- a/apps/web/src/components/infra/pod-table.tsx +++ b/apps/web/src/components/infra/pod-table.tsx @@ -10,7 +10,7 @@ import type { PodSortKey, SortDirection } from "@/api/warehouse/infra" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { HostStatusBadge } from "./status-badge" -import { ColumnHead, DataTable, ROW_LINK_CLASS } from "./primitives/data-table" +import { ColumnHead, DataTable, ROW_LINK_CLASS } from "@/components/common/data-table" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" import { formatRelativeTime } from "@maple/ui/lib/time-format" diff --git a/apps/web/src/components/infra/primitives/chart-card.tsx b/apps/web/src/components/infra/primitives/chart-card.tsx index 78dc2f34be..be22c5e23f 100644 --- a/apps/web/src/components/infra/primitives/chart-card.tsx +++ b/apps/web/src/components/infra/primitives/chart-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react" -import { cn } from "@maple/ui/lib/utils" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { ChartEmpty } from "@maple/ui/components/charts" /** Every infra detail chart plots at this height, so a grid of them never staggers. */ @@ -30,16 +30,10 @@ export function ChartCard({ className?: string }) { return ( -
-
-
- {title} - {scope} -
-
{legend}
-
+ + {children} -
+ ) } diff --git a/apps/web/src/components/infra/railway/railway-service-table.tsx b/apps/web/src/components/infra/railway/railway-service-table.tsx index a29c1cd504..83cf2eba7b 100644 --- a/apps/web/src/components/infra/railway/railway-service-table.tsx +++ b/apps/web/src/components/infra/railway/railway-service-table.tsx @@ -14,7 +14,7 @@ import { ROW_LINK_CLASS, type SortControls, useTableSort, -} from "../primitives/data-table" +} from "@/components/common/data-table" import { FleetBand, FleetBandLoading } from "../primitives/fleet-band" import { MetaLine } from "../primitives/meta-line" import { MeterRows } from "../primitives/meter-rows" diff --git a/apps/web/src/components/infra/workload-table.tsx b/apps/web/src/components/infra/workload-table.tsx index 19593a02f6..d8efd7f5ec 100644 --- a/apps/web/src/components/infra/workload-table.tsx +++ b/apps/web/src/components/infra/workload-table.tsx @@ -9,7 +9,7 @@ import type { WorkloadKind } from "@/api/warehouse/infra" import { HostStatusBadge } from "./status-badge" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" -import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "./primitives/data-table" +import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" import { formatRelativeTime } from "@maple/ui/lib/time-format" export type WorkloadRow = ListWorkloadsResponse["data"][number] diff --git a/apps/web/src/components/integrations/cloudflare-zone-board.tsx b/apps/web/src/components/integrations/cloudflare-zone-board.tsx index 68846aa706..332e159e10 100644 --- a/apps/web/src/components/integrations/cloudflare-zone-board.tsx +++ b/apps/web/src/components/integrations/cloudflare-zone-board.tsx @@ -8,7 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui import { SearchInput } from "@maple/ui/components/ui/search-input" import { cn } from "@maple/ui/lib/utils" -import { ColumnHead, type SortDir } from "@/components/infra/primitives/data-table" +import { ColumnHead, type SortDir } from "@/components/common/data-table" import { formatNumber } from "@maple/ui/lib/format" import { formatRelativeTime } from "@maple/ui/lib/time-format" import { CLOUDFLARE_ACCENT } from "./integration-catalog" diff --git a/apps/web/src/components/investigations/investigation-tabs.tsx b/apps/web/src/components/investigations/investigation-tabs.tsx index 18dddf5287..47a493cd38 100644 --- a/apps/web/src/components/investigations/investigation-tabs.tsx +++ b/apps/web/src/components/investigations/investigation-tabs.tsx @@ -4,7 +4,7 @@ import { UnderlineTabCount, UnderlineTabStrip, underlineTabClass, -} from "@/components/errors/underline-link-tabs" +} from "@/components/common/underline-link-tabs" export const INVESTIGATION_TABS = ["overview", "evidence", "chat", "transcript"] as const diff --git a/apps/web/src/components/services/section-card.tsx b/apps/web/src/components/services/section-card.tsx index a3dde24c35..d007b98c96 100644 --- a/apps/web/src/components/services/section-card.tsx +++ b/apps/web/src/components/services/section-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react" -import { cn } from "@maple/ui/lib/utils" +import { Panel, PanelBody, PanelHeader } from "@maple/ui/components/ui/panel" interface SectionCardProps { title: string @@ -16,12 +16,9 @@ interface SectionCardProps { */ export function SectionCard({ title, action, children, className }: SectionCardProps) { return ( -
-
- {title} - {action} -
-
{children}
-
+ + + {children} + ) } diff --git a/apps/web/src/hooks/use-table-sort.ts b/apps/web/src/hooks/use-table-sort.ts index d323819234..656c095cc8 100644 --- a/apps/web/src/hooks/use-table-sort.ts +++ b/apps/web/src/hooks/use-table-sort.ts @@ -1,7 +1,7 @@ // Client-side column sorting shared by the infra resource tables and the // dashboard table widget. // -// This used to live inside `components/infra/primitives/data-table.tsx`. It +// This used to live inside `components/infra/primitives/data-table.tsx (now common/data-table.tsx)`. It // moved out when the dashboard table widget needed the same comparator: the // widget renders arbitrary warehouse rows, so it cannot import an infra // primitive, and a second comparator would immediately disagree with the first diff --git a/apps/web/src/lab/infra-lab.tsx b/apps/web/src/lab/infra-lab.tsx index c3f08be90b..995269253d 100644 --- a/apps/web/src/lab/infra-lab.tsx +++ b/apps/web/src/lab/infra-lab.tsx @@ -24,8 +24,8 @@ import { summarizeRailway, } from "@/components/infra/overview/summaries" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar } from "@/components/infra/primitives/list-toolbar" -import { PageHero } from "@/components/infra/primitives/page-hero" +import { ListToolbar } from "@/components/common/search-toolbar" +import { PageHero } from "@/components/common/page-hero" import { RailwayServiceTable, RailwaySummaryBand, diff --git a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx index 80aceda93f..567ede13bb 100644 --- a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx +++ b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx @@ -10,7 +10,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" import { CloudflareIcon } from "@/components/icons" -import { HeroChip, PageHero } from "@/components/infra/primitives/page-hero" +import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { CloudflareBreakdownPanel } from "@/components/infra/cloudflare/cloudflare-breakdown-panel" diff --git a/apps/web/src/routes/infra/containers/$containerName.tsx b/apps/web/src/routes/infra/containers/$containerName.tsx index c960e72c70..60ed2795c5 100644 --- a/apps/web/src/routes/infra/containers/$containerName.tsx +++ b/apps/web/src/routes/infra/containers/$containerName.tsx @@ -14,7 +14,7 @@ import { ErrorState } from "@/components/common/error-state" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { DockerIcon } from "@/components/icons" import { ContainerDetailChart } from "@/components/infra/container-detail-chart" -import { PageHero, HeroChip } from "@/components/infra/primitives/page-hero" +import { PageHero, HeroChip } from "@/components/common/page-hero" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { containerDetailSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" diff --git a/apps/web/src/routes/infra/containers/index.tsx b/apps/web/src/routes/infra/containers/index.tsx index c0edc00c9b..f4b96eae78 100644 --- a/apps/web/src/routes/infra/containers/index.tsx +++ b/apps/web/src/routes/infra/containers/index.tsx @@ -9,9 +9,9 @@ import { OptionalStringArrayParam } from "@/lib/search-params" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ErrorState } from "@/components/common/error-state" import { DockerIcon, MagnifierIcon } from "@/components/icons" -import { PageHero } from "@/components/infra/primitives/page-hero" +import { PageHero } from "@/components/common/page-hero" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" import { HostsViewTabs } from "@/components/infra/hosts-view-tabs" import { ContainerTable, ContainerTableLoading } from "@/components/infra/container-table" import { diff --git a/apps/web/src/routes/infra/hosts/index.tsx b/apps/web/src/routes/infra/hosts/index.tsx index 3465601d22..ee528383b6 100644 --- a/apps/web/src/routes/infra/hosts/index.tsx +++ b/apps/web/src/routes/infra/hosts/index.tsx @@ -21,8 +21,8 @@ import { HostsViewTabs } from "@/components/infra/hosts-view-tabs" import { InfraSetupEmpty } from "@/components/infra/infra-empty-state" import { InstallHostModal } from "@/components/infra/install-modal" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" -import { PageHero } from "@/components/infra/primitives/page-hero" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" +import { PageHero } from "@/components/common/page-hero" import { listHostsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" diff --git a/apps/web/src/routes/infra/index.tsx b/apps/web/src/routes/infra/index.tsx index fbbe36c946..a6ca4bfe80 100644 --- a/apps/web/src/routes/infra/index.tsx +++ b/apps/web/src/routes/infra/index.tsx @@ -14,7 +14,7 @@ import { SourcesTable, presentSources, } from "@/components/infra/overview/infra-overview" -import { PageHero } from "@/components/infra/primitives/page-hero" +import { PageHero } from "@/components/common/page-hero" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useInfraSurfaces } from "@/hooks/use-infra-surfaces" import { diff --git a/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx b/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx index e880b4d536..1893dcfab1 100644 --- a/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx +++ b/apps/web/src/routes/infra/kubernetes/nodes/$nodeName.tsx @@ -14,7 +14,7 @@ import { KubernetesShell } from "@/components/infra/kubernetes/kubernetes-shell" import { NodeDetailChart } from "@/components/infra/k8s-detail-chart" import { PodTable } from "@/components/infra/pod-table" import { bucketSecondsForRange } from "@/components/infra/constants" -import { PageHero, HeroChip } from "@/components/infra/primitives/page-hero" +import { PageHero, HeroChip } from "@/components/common/page-hero" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { diff --git a/apps/web/src/routes/infra/kubernetes/nodes/index.tsx b/apps/web/src/routes/infra/kubernetes/nodes/index.tsx index 057fa87a96..c91a56be24 100644 --- a/apps/web/src/routes/infra/kubernetes/nodes/index.tsx +++ b/apps/web/src/routes/infra/kubernetes/nodes/index.tsx @@ -14,7 +14,7 @@ import { NodeTable, NodeTableLoading } from "@/components/infra/node-table" import { deriveHostStatus, type HostStatus } from "@/components/infra/format" import { NodesFilterSidebarView, type NodeFilters } from "@/components/infra/k8s-filter-sidebar" import { FleetBand, type FleetBandCell } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" import { statusLabel } from "@/components/infra/severity-tokens" import { listNodesResultAtom, nodeFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" diff --git a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx index 04f2268598..eb11ae82d8 100644 --- a/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx +++ b/apps/web/src/routes/infra/kubernetes/pods/$podName.tsx @@ -16,7 +16,7 @@ import { KubernetesShell } from "@/components/infra/kubernetes/kubernetes-shell" import { PodDetailChart } from "@/components/infra/k8s-detail-chart" import { bucketSecondsForRange } from "@/components/infra/constants" import { severityLevel } from "@/components/infra/format" -import { PageHero, HeroChip } from "@/components/infra/primitives/page-hero" +import { PageHero, HeroChip } from "@/components/common/page-hero" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" diff --git a/apps/web/src/routes/infra/kubernetes/pods/index.tsx b/apps/web/src/routes/infra/kubernetes/pods/index.tsx index 39039e9a2d..a1b5338661 100644 --- a/apps/web/src/routes/infra/kubernetes/pods/index.tsx +++ b/apps/web/src/routes/infra/kubernetes/pods/index.tsx @@ -17,7 +17,7 @@ import { PodPeekSheet } from "@/components/infra/kubernetes/pod-peek-sheet" import { PodsFilterSidebarView, type PodFilters } from "@/components/infra/k8s-filter-sidebar" import { PodTable, PodTableLoading, podKey, type PodRow } from "@/components/infra/pod-table" import { FleetBand, FleetBandLoading, type FleetBandCell } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" import { podFilterChips } from "@/lib/infra/pod-filter-chips" import { listPodsResultAtom, diff --git a/apps/web/src/routes/infra/kubernetes/workloads/$kind/$workloadName.tsx b/apps/web/src/routes/infra/kubernetes/workloads/$kind/$workloadName.tsx index bedae11dbc..208725ffe1 100644 --- a/apps/web/src/routes/infra/kubernetes/workloads/$kind/$workloadName.tsx +++ b/apps/web/src/routes/infra/kubernetes/workloads/$kind/$workloadName.tsx @@ -17,7 +17,7 @@ import { WorkloadDetailChart } from "@/components/infra/k8s-detail-chart" import { PodTable } from "@/components/infra/pod-table" import { bucketSecondsForRange } from "@/components/infra/constants" import { severityLevel } from "@/components/infra/format" -import { PageHero, HeroChip } from "@/components/infra/primitives/page-hero" +import { PageHero, HeroChip } from "@/components/common/page-hero" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { diff --git a/apps/web/src/routes/infra/kubernetes/workloads/index.tsx b/apps/web/src/routes/infra/kubernetes/workloads/index.tsx index 0b1b173a15..7d59a38263 100644 --- a/apps/web/src/routes/infra/kubernetes/workloads/index.tsx +++ b/apps/web/src/routes/infra/kubernetes/workloads/index.tsx @@ -17,7 +17,7 @@ import { deriveHostStatus, severityLevel } from "@/components/infra/format" import { WorkloadTable, WorkloadTableLoading, type WorkloadRow } from "@/components/infra/workload-table" import { WorkloadsFilterSidebarView, type WorkloadFilters } from "@/components/infra/k8s-filter-sidebar" import { FleetBand, type FleetBandCell } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { listWorkloadsResultAtom, workloadFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" diff --git a/apps/web/src/routes/infra/planetscale/$dbName.tsx b/apps/web/src/routes/infra/planetscale/$dbName.tsx index af53e369e2..185622d7d7 100644 --- a/apps/web/src/routes/infra/planetscale/$dbName.tsx +++ b/apps/web/src/routes/infra/planetscale/$dbName.tsx @@ -18,7 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ErrorState } from "@/components/common/error-state" import { PlanetScaleIcon } from "@/components/icons" -import { PageHero, HeroChip } from "@/components/infra/primitives/page-hero" +import { PageHero, HeroChip } from "@/components/common/page-hero" import { PlanetScaleAlertMenu } from "@/components/infra/planetscale/planetscale-alert-menu" import { PlanetScaleBranchScope } from "@/components/infra/planetscale/branch-scope" import { diff --git a/apps/web/src/routes/infra/planetscale/index.tsx b/apps/web/src/routes/infra/planetscale/index.tsx index fd5375a284..7537afef21 100644 --- a/apps/web/src/routes/infra/planetscale/index.tsx +++ b/apps/web/src/routes/infra/planetscale/index.tsx @@ -11,7 +11,7 @@ import { DocsLink, EmptyActions } from "@/components/common/docs-link" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ErrorState } from "@/components/common/error-state" import { PlanetScaleIcon } from "@/components/icons" -import { PageHero } from "@/components/infra/primitives/page-hero" +import { PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { PlanetScaleDatabaseTable, diff --git a/apps/web/src/routes/infra/railway/$serviceId.tsx b/apps/web/src/routes/infra/railway/$serviceId.tsx index 2d31c9738e..cc6efa8354 100644 --- a/apps/web/src/routes/infra/railway/$serviceId.tsx +++ b/apps/web/src/routes/infra/railway/$serviceId.tsx @@ -9,7 +9,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout" import { chartBucketSeconds, type ChartUnit } from "@/components/infra/chart-utils" import { ChartCard } from "@/components/infra/primitives/chart-card" import { InfraMetricChart } from "@/components/infra/primitives/infra-metric-chart" -import { HeroChip, PageHero } from "@/components/infra/primitives/page-hero" +import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { formatCores, shareOfLimit } from "@/components/infra/railway/format" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" diff --git a/apps/web/src/routes/infra/railway/index.tsx b/apps/web/src/routes/infra/railway/index.tsx index ca00386adb..33758ac126 100644 --- a/apps/web/src/routes/infra/railway/index.tsx +++ b/apps/web/src/routes/infra/railway/index.tsx @@ -13,10 +13,10 @@ import { unsyncedEnvironments, } from "@/components/integrations/railway-integration-card" import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { PageHero } from "@/components/infra/primitives/page-hero" -import { DataTable } from "@/components/infra/primitives/data-table" +import { PageHero } from "@/components/common/page-hero" +import { DataTable } from "@/components/common/data-table" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/infra/primitives/list-toolbar" +import { ListToolbar, countLabel } from "@/components/common/search-toolbar" import { RailwayServiceTable, RailwayServiceTableLoading, diff --git a/packages/ui/src/components/error-rate-value.tsx b/packages/ui/src/components/error-rate-value.tsx new file mode 100644 index 0000000000..2545ea7430 --- /dev/null +++ b/packages/ui/src/components/error-rate-value.tsx @@ -0,0 +1,20 @@ +import { formatErrorRate } from "../lib/format" +import { ERROR_RATE_TEXT, errorRateLevel } from "../lib/error-rate" +import { cn } from "../lib/utils" + +/** An error rate (0..1), formatted and toned by the shared thresholds in lib/error-rate.ts. */ +export function ErrorRateValue({ + rate, + format = formatErrorRate, + className, +}: { + rate: number + format?: (rate: number) => string + className?: string +}) { + return ( + + {format(rate)} + + ) +} diff --git a/packages/ui/src/components/http-status-code.tsx b/packages/ui/src/components/http-status-code.tsx new file mode 100644 index 0000000000..c359a34f2a --- /dev/null +++ b/packages/ui/src/components/http-status-code.tsx @@ -0,0 +1,32 @@ +import { httpStatusTone } from "../lib/http" +import { TONE_SOFT, TONE_TEXT } from "../lib/tone" +import { cn } from "../lib/utils" + +/** An HTTP status code, toned by class (5xx red, 4xx amber). `badge` adds a tinted chip. */ +export function HttpStatusCode({ + code, + variant = "text", + className, +}: { + code: number | null | undefined + variant?: "text" | "badge" + className?: string +}) { + if (code == null) return null + const tone = httpStatusTone(code) + return ( + + {code} + + ) +} diff --git a/packages/ui/src/components/ui/alert.tsx b/packages/ui/src/components/ui/alert.tsx index fc5f16bb54..8a1d53d073 100644 --- a/packages/ui/src/components/ui/alert.tsx +++ b/packages/ui/src/components/ui/alert.tsx @@ -6,9 +6,15 @@ const alertVariants = cva( "relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-xl border px-3.5 py-3 text-card-foreground text-sm has-[>svg]:has-data-[slot=alert-action]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-data-[slot=alert-action]:grid-cols-[1fr_auto] has-[>svg]:gap-x-2 [&>svg]:h-lh [&>svg]:w-4", { defaultVariants: { + size: "default", variant: "default", }, variants: { + size: { + default: "", + // Inline callout inside a form, panel or chat turn. + sm: "rounded-lg px-2.5 py-1.5 text-xs [&>svg]:w-3.5", + }, variant: { default: "bg-transparent dark:bg-input/32 [&>svg]:text-muted-foreground", error: "border-destructive/32 bg-destructive/4 [&>svg]:text-destructive", @@ -23,11 +29,12 @@ const alertVariants = cva( export function Alert({ className, variant, + size, ...props }: React.ComponentProps<"div"> & VariantProps): React.ReactElement { return (
{ variant?: VariantProps["variant"] size?: VariantProps["size"] + shape?: VariantProps["shape"] + /** Mono + tabular figures, for ids, versions and counts. */ + mono?: boolean } -export function Badge({ className, variant, size, render, ...props }: BadgeProps): React.ReactElement { +export function Badge({ + className, + variant, + size, + shape, + mono = false, + render, + ...props +}: BadgeProps): React.ReactElement { const defaultProps = { - className: cn(badgeVariants({ className, size, variant })), + className: cn(badgeVariants({ className, mono, shape, size, variant })), "data-slot": "badge", } diff --git a/packages/ui/src/components/ui/confirm-dialog.tsx b/packages/ui/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000000..9245c0fcb0 --- /dev/null +++ b/packages/ui/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,110 @@ +"use client" + +import * as React from "react" +import { AlertWarningIcon } from "../icons" +import { cn } from "../../lib/utils" +import { Button } from "./button" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "./alert-dialog" + +export interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: React.ReactNode + description?: React.ReactNode + confirmLabel: React.ReactNode + cancelLabel?: React.ReactNode + /** `destructive` (default) shows the warning media and a red confirm button. */ + tone?: "destructive" | "default" + /** Replaces the default warning icon; `null` drops the media block. */ + icon?: React.ReactNode | null + /** + * A returned promise drives the pending state and closes the dialog when it + * resolves; a rejection keeps it open so the caller's toast can explain. + */ + onConfirm: () => void | Promise + /** Externally owned pending state, for callers that track their own mutation. */ + pending?: boolean + /** Third button between cancel and confirm (e.g. "Delete section only"). */ + secondaryAction?: { label: React.ReactNode; onClick: () => void | Promise } + /** Extra body below the description (a typed-name check, a list of affected items). */ + children?: React.ReactNode + className?: string +} + +/** The confirm-before-acting dialog: title, consequence, cancel, confirm with a pending state. */ +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + cancelLabel = "Cancel", + tone = "destructive", + icon, + onConfirm, + pending: pendingProp = false, + secondaryAction, + children, + className, +}: ConfirmDialogProps): React.ReactElement { + const [running, setRunning] = React.useState(false) + const pending = pendingProp || running + + const run = (action: () => void | Promise) => { + const result = action() + if (!(result instanceof Promise)) return + setRunning(true) + result.then( + () => { + setRunning(false) + onOpenChange(false) + }, + () => setRunning(false), + ) + } + + const media = + icon === null ? null : ( + + {icon ?? } + + ) + + return ( + (pending ? undefined : onOpenChange(next))}> + + + {media} + {title} + {description ? {description} : null} + + {children} + + {cancelLabel} + {secondaryAction ? ( + + ) : null} + + + + + ) +} diff --git a/packages/ui/src/components/ui/delta.tsx b/packages/ui/src/components/ui/delta.tsx new file mode 100644 index 0000000000..682a2f5da9 --- /dev/null +++ b/packages/ui/src/components/ui/delta.tsx @@ -0,0 +1,67 @@ +import type * as React from "react" +import { formatPercent } from "../../lib/format" +import { cn } from "../../lib/utils" + +/** Relative change from `previous` to `current`, or null when there is no baseline. */ +export function relativeChange(current: number, previous: number): number | null { + if (!Number.isFinite(previous) || previous <= 0 || !Number.isFinite(current)) return null + return (current - previous) / previous +} + +/** + * Change against a previous window. Colour follows improvement, not direction + * (`invert` for metrics where down is good, like error rate or latency); the + * arrow always points the way the number moved, so colour is never the only cue. + */ +export function Delta({ + ratio, + current, + previous, + invert = false, + suffix, + flatThreshold = 0.001, + className, +}: { + /** Relative change (0.12 = +12%). Or pass `current` + `previous`. */ + ratio?: number | null + current?: number + previous?: number + invert?: boolean + /** Trailing muted text, e.g. "vs prev". */ + suffix?: React.ReactNode + /** |ratio| below this renders as flat. */ + flatThreshold?: number + className?: string +}): React.ReactElement { + const change = + ratio !== undefined + ? ratio + : current !== undefined && previous !== undefined + ? relativeChange(current, previous) + : null + + if (change === null || !Number.isFinite(change)) { + return – + } + + const flat = Math.abs(change) < flatThreshold + const rose = change > 0 + const good = invert ? !rose : rose + const magnitude = formatPercent(Math.abs(change)) + + return ( + + {flat ? "→" : rose ? "↑" : "↓"} + {magnitude} + {suffix ? {suffix} : null} + + ) +} diff --git a/packages/ui/src/components/ui/item.tsx b/packages/ui/src/components/ui/item.tsx index 29a2720db6..08750477ad 100644 --- a/packages/ui/src/components/ui/item.tsx +++ b/packages/ui/src/components/ui/item.tsx @@ -39,11 +39,17 @@ const itemVariants = cva( default: "border-transparent", outline: "border-border", muted: "bg-muted/50 border-transparent", + // Raised panel row on the page surface (integration headers, linked resources). + card: "rounded-lg border-border/60 bg-card", + // Row inside a `divide-y` list: no frame of its own. + flush: "rounded-none border-transparent", }, size: { default: "gap-2.5 px-3 py-2.5", sm: "gap-2.5 px-3 py-2.5", xs: "gap-2 px-2.5 py-2 [[data-slot=dropdown-menu-content]_&]:p-0", + // Page-level rows at body text size. + lg: "gap-3 px-4 py-3 text-sm", }, }, defaultVariants: { @@ -113,7 +119,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
) {
& { + layout?: Layout + /** Hairline between rows. */ + divided?: boolean +}): React.ReactElement { + return ( +
div]:py-1.5", + className, + )} + data-layout={layout} + data-slot="key-value-list" + {...props} + /> + ) +} + +export function KeyValue({ + label, + children, + mono = false, + copyValue, + className, +}: { + label: React.ReactNode + children: React.ReactNode + /** Mono + tabular figures for ids, hashes and numbers. */ + mono?: boolean + /** Adds a copy button for this raw string. */ + copyValue?: string + className?: string +}): React.ReactElement { + return ( +
+
{label}
+
+ {children} + {copyValue ? : null} +
+
+ ) +} diff --git a/packages/ui/src/components/ui/list-footer.tsx b/packages/ui/src/components/ui/list-footer.tsx new file mode 100644 index 0000000000..fb69676aba --- /dev/null +++ b/packages/ui/src/components/ui/list-footer.tsx @@ -0,0 +1,98 @@ +import type * as React from "react" +import { cn } from "../../lib/utils" +import { Button } from "./button" +import { Spinner } from "./spinner" + +/** "Load more" with a spinner while the next page is in flight. */ +export function LoadMoreButton({ + loading = false, + onClick, + label = "Load more", + className, +}: { + loading?: boolean + onClick: () => void + label?: React.ReactNode + className?: string +}): React.ReactElement { + return ( + + ) +} + +/** + * The footer under a paged list: how much is shown, and the button for more. + * `capped` is for lists that stop at a server limit with no further pages. + */ +export function ListFooter({ + shown, + total, + noun, + hasMore = false, + loading = false, + failed = false, + capped = false, + onLoadMore, + align = "center", + className, +}: { + shown?: number + total?: number + /** Plural noun for the count copy ("logs", "traces"). */ + noun?: string + hasMore?: boolean + loading?: boolean + /** The last page failed; the button becomes a retry. */ + failed?: boolean + capped?: boolean + onLoadMore?: () => void + align?: "start" | "center" + className?: string +}): React.ReactElement | null { + const count = + shown === undefined || !noun + ? null + : total !== undefined && total > shown + ? `Showing ${shown.toLocaleString()} of ${total.toLocaleString()} ${noun}` + : capped + ? `Showing first ${shown.toLocaleString()} ${noun}, narrow the filters to see more` + : hasMore + ? `Showing ${shown.toLocaleString()} ${noun}, more available` + : `${shown.toLocaleString()} ${noun}` + + const button = + onLoadMore && (hasMore || failed) ? ( + + ) : null + + if (!count && !button) return null + + return ( +
+ {count ? {count} : null} + {button} +
+ ) +} + +/** In-table "Loading more…" row for virtualized/infinite lists. */ +export function LoadingMoreRow({ label = "Loading more…", className }: { label?: string; className?: string }) { + return ( +
+ + {label} +
+ ) +} diff --git a/packages/ui/src/components/ui/meter.tsx b/packages/ui/src/components/ui/meter.tsx new file mode 100644 index 0000000000..16422fb049 --- /dev/null +++ b/packages/ui/src/components/ui/meter.tsx @@ -0,0 +1,97 @@ +import type * as React from "react" +import { cn } from "../../lib/utils" + +/** Width for a fraction: clamped to 0..1, and a non-zero value is never thinner than `minVisible`%. */ +function widthPercent(fraction: number, minVisible: number): number { + if (!Number.isFinite(fraction) || fraction <= 0) return 0 + return Math.max(Math.min(fraction, 1) * 100, minVisible) +} + +/** + * Horizontal share/usage bar. Pass `value` + `max` for a share of the largest + * row, or `value` alone as a 0..1 fraction. + */ +export function Meter({ + value, + max = 1, + minVisible = 1.5, + fillClassName = "bg-primary/60", + className, + label, +}: { + value: number + max?: number + /** Smallest visible width, in percent, for any non-zero value. */ + minVisible?: number + fillClassName?: string + className?: string + /** Accessible name; the bar is decorative without one. */ + label?: string +}): React.ReactElement { + const fraction = max > 0 ? value / max : 0 + return ( +
+
+
+ ) +} + +export interface BarSegment { + key: string + value: number + /** Fill class (`bg-severity-error`) or, with `color`, left empty. */ + className?: string + /** Raw CSS colour for data-driven palettes. */ + color?: string + /** Native tooltip text. */ + title?: string +} + +/** Stacked composition bar: each segment's width is its share of the total. */ +export function SegmentedBar({ + segments, + total, + className, +}: { + segments: ReadonlyArray + /** Defaults to the segment sum; pass a larger total to leave the remainder empty. */ + total?: number + className?: string +}): React.ReactElement { + const sum = total ?? segments.reduce((acc, s) => acc + Math.max(0, s.value), 0) + return ( +
+ {sum > 0 + ? segments.map((segment) => + segment.value > 0 ? ( +
+ ) : null, + ) + : null} +
+ ) +} diff --git a/packages/ui/src/components/ui/panel.tsx b/packages/ui/src/components/ui/panel.tsx new file mode 100644 index 0000000000..2f6f1f0cf1 --- /dev/null +++ b/packages/ui/src/components/ui/panel.tsx @@ -0,0 +1,69 @@ +import type * as React from "react" +import { cn } from "../../lib/utils" + +/** + * Flat bordered section frame (charts, side lists, detail sections). `Card` is + * the raised, rounded-2xl surface for standalone content; a panel sits inside + * a page grid and stays quiet. + */ +export function Panel({ className, ...props }: React.ComponentProps<"section">): React.ReactElement { + return ( +
+ ) +} + +/** Header strip: title (+ scope marker) on the left, an action or legend on the right. */ +export function PanelHeader({ + title, + scope, + action, + divided = true, + className, + children, +}: { + title?: React.ReactNode + /** Marker beside the title saying what the panel is filtered to. */ + scope?: React.ReactNode + action?: React.ReactNode + /** Bottom border under the strip. Chart panels turn it off so the plot runs up to the title. */ + divided?: boolean + className?: string + children?: React.ReactNode +}): React.ReactElement { + return ( +
+ {children ?? ( +
+ {title} + {scope} +
+ )} + {action ?
{action}
: null} +
+ ) +} + +export function PanelTitle({ className, ...props }: React.ComponentProps<"h3">): React.ReactElement { + return ( +

+ ) +} + +export function PanelBody({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { + return
+} diff --git a/packages/ui/src/components/ui/setting-row.tsx b/packages/ui/src/components/ui/setting-row.tsx new file mode 100644 index 0000000000..9dff342767 --- /dev/null +++ b/packages/ui/src/components/ui/setting-row.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Field as FieldPrimitive } from "@base-ui/react/field" +import type * as React from "react" +import { cn } from "../../lib/utils" + +export interface SettingRowProps { + label: React.ReactNode + description?: React.ReactNode + /** Leading icon or logo plate. */ + icon?: React.ReactNode + /** The Switch / Select / Button on the right. Base UI controls inside are labelled by `label`. */ + control: React.ReactNode + /** Draws the bordered box (stand-alone rows); omit inside a `divide-y` list. */ + framed?: boolean + /** Highlights the row while its setting is on. */ + active?: boolean + disabled?: boolean + /** Content under the row (an expanded sub-form while the toggle is on). */ + children?: React.ReactNode + className?: string +} + +/** Settings row: label and description on the left, the control on the right. */ +export function SettingRow({ + label, + description, + icon, + control, + framed = false, + active = false, + disabled, + children, + className, +}: SettingRowProps): React.ReactElement { + return ( + +
+
+ {icon ?
{icon}
: null} +
+ + {label} + + {description ? ( + + {description} + + ) : null} +
+
+
{control}
+
+ {children} +
+ ) +} diff --git a/packages/ui/src/components/ui/skeleton.tsx b/packages/ui/src/components/ui/skeleton.tsx index 0cb1e867e3..3df6f51035 100644 --- a/packages/ui/src/components/ui/skeleton.tsx +++ b/packages/ui/src/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import type React from "react" +import * as React from "react" import { cn } from "../../lib/utils" export function Skeleton({ className, ...props }: React.ComponentProps<"div">): React.ReactElement { @@ -16,3 +16,39 @@ export function Skeleton({ className, ...props }: React.ComponentProps<"div">): /> ) } + +/** A stack of identical skeleton rows: the loading state of a list or panel body. */ +export function SkeletonList({ + rows = 4, + rowClassName = "h-8", + gap = "px", + renderRow, + className, +}: { + rows?: number + rowClassName?: string + gap?: "px" | "1" | "2" | "3" + /** Custom row skeleton; receives the index for staggered widths. */ + renderRow?: (index: number) => React.ReactNode + className?: string +}): React.ReactElement { + return ( +
+ {Array.from({ length: rows }, (_, i) => + renderRow ? ( + {renderRow(i)} + ) : ( + + ), + )} +
+ ) +} diff --git a/packages/ui/src/components/ui/status-dot.tsx b/packages/ui/src/components/ui/status-dot.tsx index 8c99a70d33..1cfa8a1e46 100644 --- a/packages/ui/src/components/ui/status-dot.tsx +++ b/packages/ui/src/components/ui/status-dot.tsx @@ -10,6 +10,8 @@ export const statusDotVariants = cva("relative inline-flex shrink-0 rounded-full warning: "bg-warning", error: "bg-destructive", info: "bg-info", + // In-flight work (running investigations, live streams). Pair with `pulse`. + live: "bg-primary", neutral: "bg-muted-foreground/50", // Colour comes from the caller's className (service colours, severity maps). custom: "", diff --git a/packages/ui/src/components/ui/table.tsx b/packages/ui/src/components/ui/table.tsx index 79b60f64c4..eeed6f0ce1 100644 --- a/packages/ui/src/components/ui/table.tsx +++ b/packages/ui/src/components/ui/table.tsx @@ -1,17 +1,32 @@ import type * as React from "react" import { cn } from "../../lib/utils" -export type TableVariant = "default" | "card" +export type TableVariant = "default" | "card" | "bare" +/** Row density. `sm` is the dense text-xs table; `xs` the 11px inline table (chat renderers, previews). */ +export type TableSize = "default" | "sm" | "xs" export function Table({ className, variant = "default", + size = "default", + scroll = true, ...props }: React.ComponentProps<"table"> & { variant?: TableVariant + size?: TableSize + /** + * Wrap in a horizontal scroll container. Turn off when an ancestor already + * scrolls (virtualized lists): the wrapper would capture a sticky header. + */ + scroll?: boolean }): React.ReactElement { return ( -
+

): React.ReactElement { - return +export function TableHeader({ + className, + sticky = false, + ...props +}: React.ComponentProps<"thead"> & { + /** Pin to the top of the nearest scrolling ancestor. Pair with `
`. */ + sticky?: boolean +}): React.ReactElement { + return ( + + ) } export function TableBody({ className, ...props }: React.ComponentProps<"tbody">): React.ReactElement { @@ -58,7 +90,7 @@ export function TableRow({ className, ...props }: React.ComponentProps<"tr">): R return ( ): return (
): return ( + {shortId(value, kind, { length, ellipsis })} + + ) + if (!copy) return text + return ( + + {text} + + + ) +} diff --git a/packages/ui/src/components/ui/truncated-text.tsx b/packages/ui/src/components/ui/truncated-text.tsx new file mode 100644 index 0000000000..2b3d6e42c2 --- /dev/null +++ b/packages/ui/src/components/ui/truncated-text.tsx @@ -0,0 +1,52 @@ +"use client" + +import * as React from "react" +import { cn } from "../../lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip" + +/** + * Single-line text that truncates with an ellipsis and reveals the full value + * on hover. `tooltip="overflow"` uses a styled Tooltip shown only when the + * text is actually cut off; `"title"` uses the native attribute. + */ +export function TruncatedText({ + children, + text, + mono = false, + tooltip = "title", + className, +}: { + children?: React.ReactNode + /** The full string for the hover text; defaults to `children` when it is a string. */ + text?: string + mono?: boolean + tooltip?: "title" | "overflow" | "none" + className?: string +}): React.ReactElement { + const full = text ?? (typeof children === "string" ? children : undefined) + const [clipped, setClipped] = React.useState(false) + const classes = cn("block min-w-0 truncate", mono && "font-mono", className) + + if (tooltip !== "overflow" || !full) { + return ( + + {children ?? full} + + ) + } + + return ( + + } + className={classes} + onPointerEnter={(event) => + setClipped(event.currentTarget.scrollWidth > event.currentTarget.clientWidth) + } + > + {children ?? full} + + {full} + + ) +} diff --git a/packages/ui/src/lib/error-rate.ts b/packages/ui/src/lib/error-rate.ts new file mode 100644 index 0000000000..3548c536fb --- /dev/null +++ b/packages/ui/src/lib/error-rate.ts @@ -0,0 +1,42 @@ +// Error-rate (0..1) → tone. One threshold pair for every table, map node and +// stat tile: >= 5% is critical, >= 1% is a warning, anything lower is quiet. + +import { formatErrorRate } from "./format" +import { TONE_COLOR, TONE_FILL, TONE_TEXT, type Tone } from "./tone" + +export const ERROR_RATE_CRIT = 0.05 +export const ERROR_RATE_WARN = 0.01 + +export type ErrorRateLevel = Extract + +export function errorRateLevel(rate: number): ErrorRateLevel { + if (!Number.isFinite(rate) || rate <= 0) return "neutral" + if (rate >= ERROR_RATE_CRIT) return "crit" + if (rate >= ERROR_RATE_WARN) return "warn" + return "neutral" +} + +/** Quiet floor is `text-foreground/80` so a 0.3% still reads as a value, not a label. */ +export const ERROR_RATE_TEXT: Record = { + crit: TONE_TEXT.crit, + warn: TONE_TEXT.warn, + neutral: "text-foreground/80", +} satisfies Record + +export const ERROR_RATE_FILL: Record = { + crit: TONE_FILL.crit, + warn: TONE_FILL.warn, + neutral: "bg-muted-foreground/30", +} satisfies Record + +export const ERROR_RATE_COLOR: Record = { + crit: TONE_COLOR.crit, + warn: TONE_COLOR.warn, + neutral: TONE_COLOR.neutral, +} satisfies Record + +export function errorRateClass(rate: number): string { + return ERROR_RATE_TEXT[errorRateLevel(rate)] +} + +export { formatErrorRate } diff --git a/packages/ui/src/lib/http.ts b/packages/ui/src/lib/http.ts index 0a9e6bd67d..850caab5f3 100644 --- a/packages/ui/src/lib/http.ts +++ b/packages/ui/src/lib/http.ts @@ -196,3 +196,11 @@ export const HTTP_METHOD_COLORS: Record = { HEAD: "bg-[#8A7F72]", OPTIONS: "bg-[#5A5248]", } satisfies Record + +/** HTTP status → tone: 5xx is critical, 4xx a warning, everything else quiet. */ +export function httpStatusTone(code: number | null | undefined): "crit" | "warn" | "neutral" { + if (code == null || !Number.isFinite(code)) return "neutral" + if (code >= 500) return "crit" + if (code >= 400) return "warn" + return "neutral" +} diff --git a/packages/ui/src/lib/ids.ts b/packages/ui/src/lib/ids.ts new file mode 100644 index 0000000000..a09b8bbccc --- /dev/null +++ b/packages/ui/src/lib/ids.ts @@ -0,0 +1,23 @@ +// Display lengths for the ids we print shortened. One table, so a trace id is +// the same 8 characters on the list, the peek sheet and the breadcrumb. + +export type IdKind = "trace" | "span" | "sha" | "session" | "generic" + +export const ID_DISPLAY_LENGTH: Record = { + trace: 8, + span: 8, + sha: 7, + session: 12, + generic: 12, +} satisfies Record + +/** Shorten an id for display. `ellipsis` appends "…" when something was cut. */ +export function shortId( + value: string, + kind: IdKind = "generic", + options: { length?: number; ellipsis?: boolean } = {}, +): string { + const length = options.length ?? ID_DISPLAY_LENGTH[kind] + if (value.length <= length) return value + return options.ellipsis ? `${value.slice(0, length)}…` : value.slice(0, length) +} diff --git a/packages/ui/src/lib/tone.ts b/packages/ui/src/lib/tone.ts new file mode 100644 index 0000000000..222b90ba82 --- /dev/null +++ b/packages/ui/src/lib/tone.ts @@ -0,0 +1,77 @@ +// The one severity vocabulary for status colour. Domain maps (alert severity, +// anomaly kind, health, log level) key into `Tone` instead of each carrying +// its own Tailwind classes, so "warning" is the same amber everywhere. + +export type Tone = "crit" | "warn" | "ok" | "info" | "neutral" + +/** Value text: table cells, stat values, inline numbers. */ +export const TONE_TEXT: Record = { + crit: "text-severity-error", + warn: "text-severity-warn", + ok: "text-foreground", + info: "text-severity-info", + neutral: "text-muted-foreground", +} satisfies Record + +/** Solid fill: dots, meter bars, legend swatches. */ +export const TONE_FILL: Record = { + crit: "bg-severity-error", + warn: "bg-severity-warn", + ok: "bg-severity-info", + info: "bg-severity-info", + neutral: "bg-muted-foreground/50", +} satisfies Record + +/** Tinted surface + text: chips, badges, row highlights. */ +export const TONE_SOFT: Record = { + crit: "bg-severity-error/12 text-severity-error", + warn: "bg-severity-warn/12 text-severity-warn", + ok: "bg-severity-info/12 text-severity-info", + info: "bg-severity-info/12 text-severity-info", + neutral: "bg-muted text-muted-foreground", +} satisfies Record + +/** Border accent for outlined chips and callouts. */ +export const TONE_BORDER: Record = { + crit: "border-severity-error/40", + warn: "border-severity-warn/40", + ok: "border-severity-info/40", + info: "border-severity-info/40", + neutral: "border-border", +} satisfies Record + +/** Raw CSS colour for SVG fills and inline styles. */ +export const TONE_COLOR: Record = { + crit: "var(--severity-error)", + warn: "var(--severity-warn)", + ok: "var(--severity-info)", + info: "var(--severity-info)", + neutral: "var(--muted-foreground)", +} satisfies Record + +/** Maps any severity-ish word (log levels, alert severities, statuses) onto a tone. */ +export function severityTone(level: string | null | undefined): Tone { + switch ((level ?? "").toLowerCase()) { + case "fatal": + case "critical": + case "crit": + case "error": + case "err": + case "unhealthy": + case "failed": + return "crit" + case "warn": + case "warning": + case "degraded": + return "warn" + case "ok": + case "healthy": + case "success": + return "ok" + case "info": + case "notice": + return "info" + default: + return "neutral" + } +} From b1cf4dc8fbac2c945ccf73318b45e89b122994c8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:21:13 +0200 Subject: [PATCH 2/5] feat(ui): shared formatCountdown --- packages/ui/src/lib/time-format.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ui/src/lib/time-format.ts b/packages/ui/src/lib/time-format.ts index 4e6960c0d8..aaebd8eec9 100644 --- a/packages/ui/src/lib/time-format.ts +++ b/packages/ui/src/lib/time-format.ts @@ -117,3 +117,19 @@ export function formatRelativeTimeOrDate( } return formatRelativeFrom(epochMs, nowMs) } + +const pad2 = (value: number) => value.toString().padStart(2, "0") + +/** + * Clock-style countdown for a remaining duration: `4:05`, `1:04:05`, or with + * `fixedHours` always `01:04:05` (a pill that must not change width). + */ +export function formatCountdown(remainingMs: number, options: { fixedHours?: boolean } = {}): string { + const total = Number.isFinite(remainingMs) && remainingMs > 0 ? Math.ceil(remainingMs / 1000) : 0 + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 + if (options.fixedHours) return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}` + if (hours > 0) return `${hours}:${pad2(minutes)}:${pad2(seconds)}` + return `${minutes}:${pad2(seconds)}` +} From 2dbcf07249ac9f2048af6bfc839fac2e1b24e47c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:38:17 +0200 Subject: [PATCH 3/5] refactor(web): migrate call sites onto the shared UI primitives ConfirmDialog, SettingRow, Panel, KeyValueList, Meter/SegmentedBar, Delta, ListFooter, SkeletonList, TruncatedId/Text, RelativeTime, SheetDetailHeader, Badge pill/xs/mono, Table density, Alert sm, StatRail tiles and the shared tone, error-rate and HTTP-status maps replace their hand-rolled copies across web, local-ui and the ui package. Error rates now share one threshold pair (5% crit, 1% warn). Deletes MetaChip, Pill, FilterTabs, KpiCard, StatCard and the local delta, countdown and id-shortening helpers. --- .../src/components/log-detail-sheet.tsx | 30 +- .../src/components/service-map-panel.tsx | 33 +- .../src/components/span-detail-panel.tsx | 84 ++--- apps/local-ui/src/components/view-states.tsx | 16 +- apps/local-ui/src/views/errors-view.tsx | 6 +- .../local-ui/src/views/metric-detail-view.tsx | 11 +- apps/local-ui/src/views/metrics-list-view.tsx | 5 +- .../src/views/service-detail-view.tsx | 3 +- .../local-ui/src/views/services-list-view.tsx | 9 +- .../src/views/session-detail-view.tsx | 95 ++--- .../local-ui/src/views/sessions-list-view.tsx | 33 +- apps/local-ui/src/views/trace-detail-view.tsx | 5 +- apps/local-ui/src/views/trace-list-view.tsx | 23 +- .../account/active-sessions-section.tsx | 64 +--- .../account/connected-accounts-section.tsx | 139 +++----- .../account/email-addresses-section.tsx | 74 ++-- .../components/account/passkeys-section.tsx | 73 ++-- .../components/account/password-section.tsx | 26 +- .../components/account/profile-section.tsx | 76 ++-- .../components/account/two-factor-section.tsx | 43 +-- .../agent-sessions/agent-sessions-list.tsx | 56 ++- .../session-detail/payload-view.tsx | 57 +-- .../agent-sessions/session-detail/pill.tsx | 41 --- .../session-detail/session-header.tsx | 13 +- .../session-detail/session-overview.tsx | 54 ++- .../session-detail/session-transcript.tsx | 24 +- .../session-detail/session-waterfall.tsx | 47 ++- .../session-detail/span-expansion.tsx | 93 ++--- .../agent-sessions/session-detail/tool-io.tsx | 7 +- .../tools/tool-breakdown-tables.tsx | 47 +-- .../tools/tool-detail-sessions.tsx | 8 +- .../tools/tool-metric-strip.tsx | 38 +- .../ai-elements/inline/inline-error.tsx | 5 +- .../ai-elements/inline/inline-log.tsx | 5 +- .../ai-elements/inline/inline-service.tsx | 18 +- .../ai-elements/inline/inline-trace.tsx | 10 +- .../components/ai-elements/markdown-table.tsx | 15 +- .../renderers/components/data-table.tsx | 29 +- .../renderers/components/error-list.tsx | 18 +- .../renderers/components/metrics-list.tsx | 52 ++- .../renderers/components/span-tree.tsx | 3 +- .../renderers/components/trace-list.tsx | 64 ++-- .../alerts/alert-severity-badge.tsx | 11 +- .../components/alerts/destination-card.tsx | 11 +- .../components/alerts/destination-dialog.tsx | 40 +-- .../alerts/notifications-section.tsx | 12 +- .../components/analytics/ai/ai-sections.tsx | 16 +- .../analytics/ai/analytics-ai-tab.tsx | 4 +- .../analytics/analytics-metric-strip.tsx | 38 +- .../analytics/product-event-trace-samples.tsx | 22 +- .../components/anomalies/anomaly-format.ts | 30 +- .../src/components/anomalies/anomaly-hero.tsx | 68 ++-- .../anomalies/anomaly-linked-issue-card.tsx | 7 +- .../src/components/anomalies/anomaly-row.tsx | 80 +++-- .../components/anomalies/anomaly-sidebar.tsx | 66 ++-- .../src/components/anomalies/meta-chip.tsx | 15 - .../web/src/components/chat/approval-card.tsx | 26 +- .../chat/approval-renderers/dashboard.tsx | 10 +- .../chat/approval-renderers/index.tsx | 24 +- apps/web/src/components/chat/auto-contexts.ts | 11 +- .../src/components/chat/chat-conversation.tsx | 15 +- apps/web/src/components/chat/chat-page.tsx | 5 +- apps/web/src/components/chat/chat-sidebar.tsx | 5 +- .../chat/investigation-attachment-card.tsx | 3 +- apps/web/src/components/chat/task-card.tsx | 12 +- .../components/chat/turn-failure-notice.tsx | 42 ++- .../chat/widget-fix-attachment-card.tsx | 3 +- .../code-review/code-review-analytics.tsx | 226 +++++------- .../code-review/code-review-format.ts | 38 +- .../code-review/code-review-layout.tsx | 8 +- .../code-review/code-review-settings.tsx | 103 ++++-- .../code-review/review-detail-sheet.tsx | 44 ++- .../code-review/review-rules-form.tsx | 33 +- apps/web/src/components/common/data-table.tsx | 5 +- .../src/components/common/search-toolbar.tsx | 2 +- .../components/common/sheet-detail-header.tsx | 2 +- apps/web/src/components/common/stat-rail.tsx | 8 +- .../config/list-config-panel.tsx | 43 +-- .../dashboard-builder/config/query-panel.tsx | 13 +- .../config/variables-manager-dialog.tsx | 116 ++++--- .../dashboard-builder/config/warning-list.tsx | 19 + .../history/preview-banner.tsx | 4 +- .../dashboard-builder/list/dashboard-list.tsx | 56 +-- .../sections/section-dialogs.tsx | 98 ++---- .../templates/template-list.tsx | 8 +- .../toolbar/share-dashboard-dialog.tsx | 8 +- .../dashboard-builder/widgets/list-widget.tsx | 11 +- .../widgets/table-widget.tsx | 8 +- .../widgets/types/preset-preview.tsx | 33 +- .../dashboard/service-health-section.tsx | 18 +- .../components/dashboard/service-health.ts | 8 + .../dashboard/service-usage-cards.tsx | 127 ++----- .../src/components/errors/errors-hub-view.tsx | 29 +- .../errors/issue-detail-skeleton.tsx | 8 +- .../components/errors/issue-fact-strip.tsx | 14 +- .../errors/issue-incidents-table.tsx | 26 +- .../components/errors/issue-notes-callout.tsx | 9 +- .../errors/issue-occurrences-table.tsx | 19 +- .../errors/issue-pull-requests-panel.tsx | 58 ++-- .../src/components/errors/issue-timeline.tsx | 10 +- apps/web/src/components/errors/lease-hud.tsx | 13 +- .../components/errors/signal-state-chip.tsx | 2 +- .../header/onboarding-checklist-button.tsx | 35 +- .../cloudflare/cloudflare-breakdown-panel.tsx | 65 ++-- .../infra/cloudflare/cloudflare-kpi-cards.tsx | 3 +- .../cloudflare/cloudflare-platform-table.tsx | 2 +- .../cloudflare/cloudflare-worker-table.tsx | 2 +- .../cloudflare/cloudflare-zone-chart.tsx | 11 +- .../cloudflare/cloudflare-zone-table.tsx | 2 +- .../components/infra/cloudflare/constants.ts | 18 - .../infra/container-summary-band.tsx | 5 +- .../src/components/infra/container-table.tsx | 17 +- .../components/infra/host-detail-header.tsx | 10 +- .../components/infra/host-summary-band.tsx | 5 +- apps/web/src/components/infra/host-table.tsx | 17 +- .../infra/kubernetes/pod-peek-sheet.tsx | 58 ++-- apps/web/src/components/infra/node-table.tsx | 17 +- .../infra/overview/infra-overview.tsx | 33 +- .../components/infra/overview/summaries.ts | 8 +- .../planetscale/planetscale-activity-feed.tsx | 19 +- .../planetscale-branch-breakdown-panel.tsx | 42 +-- .../planetscale/planetscale-top-queries.tsx | 28 +- apps/web/src/components/infra/pod-table.tsx | 20 +- .../infra/primitives/fleet-band.tsx | 37 +- .../infra/primitives/scope-chip.tsx | 17 +- .../infra/railway/railway-service-table.tsx | 22 +- .../infra/service-lens/service-lens-rail.tsx | 8 +- .../src/components/infra/severity-tokens.ts | 24 +- .../web/src/components/infra/status-badge.tsx | 3 +- .../src/components/infra/workload-table.tsx | 17 +- .../components/ingest/connection-status.tsx | 10 +- .../integrations/chat-integration-card.tsx | 111 +++--- .../integrations/cloudflare-account-card.tsx | 46 +-- .../integrations/cloudflare-stat-cards.tsx | 103 ++---- .../integrations/cloudflare-zone-board.tsx | 122 +++---- .../integrations/github-integration-card.tsx | 327 ++++++++---------- .../integrations/hazel-integration-card.tsx | 13 +- .../integrations/integration-catalog.tsx | 12 +- .../planetscale-integration-card.tsx | 141 ++++---- .../planetscale-metrics-health.tsx | 19 +- .../planetscale-metrics-token-form.tsx | 5 +- .../integrations/railway-integration-card.tsx | 70 ++-- .../investigations/action-detail-sheet.tsx | 44 +-- .../investigations/flow/flow-nodes.tsx | 16 +- .../investigations/flow/provenance-canvas.tsx | 5 +- .../investigations/investigation-table.tsx | 26 +- .../investigations/run-progress.tsx | 2 +- .../investigations/verdict-card.tsx | 27 +- .../src/components/logs/log-hero-header.tsx | 26 +- .../src/components/logs/log-meta-strip.tsx | 5 +- .../components/logs/log-trace-timeline.tsx | 3 +- apps/web/src/components/logs/logs-table.tsx | 18 +- .../components/metrics/metric-breakdown.tsx | 71 ++-- .../src/components/metrics/metric-detail.tsx | 13 +- .../metrics/metric-metadata-panel.tsx | 48 ++- .../metrics/metric-preview-card.tsx | 7 +- .../metrics/metric-preview-grid.tsx | 38 +- .../metrics/metrics-summary-cards.tsx | 80 +---- .../src/components/metrics/metrics-table.tsx | 37 +- .../src/components/onboarding/step-region.tsx | 12 +- .../src/components/region/region-badge.tsx | 14 +- .../releases/release-deploy-overview.tsx | 79 ++--- .../releases/release-detail-panels.tsx | 80 ++--- .../components/releases/release-health.tsx | 14 +- .../releases/release-issues-panel.tsx | 8 +- .../src/components/releases/release-model.ts | 3 +- .../components/releases/releases-live-now.tsx | 29 +- .../components/releases/releases-table.tsx | 31 +- .../components/releases/releases-timeline.tsx | 44 +-- .../replays/replay-editor-timeline.tsx | 5 +- .../replays/session-detail-parts.tsx | 12 +- .../replays/session-events-panel.tsx | 45 +-- .../src/components/replays/session-pills.tsx | 22 +- .../src/components/replays/sessions-list.tsx | 54 +-- .../components/replays/trace-replay-link.tsx | 11 +- .../service-map/service-map-view.tsx | 124 +++---- .../service-map/three/spatial-layout.ts | 6 +- .../components/service-map/three/viewport.tsx | 32 +- .../components/services/dependency-table.tsx | 27 +- .../src/components/services/issue-line.tsx | 19 +- .../components/services/service-api-tab.tsx | 32 +- .../services/service-dependencies-tab.tsx | 15 +- .../services/service-errors-panel.tsx | 8 +- .../services/service-operations-tab.tsx | 26 +- .../services/service-recent-deploys.tsx | 71 ++-- .../services/service-table-cells.tsx | 12 - .../services/service-top-operations-panel.tsx | 26 +- .../services/service-usage-panel.tsx | 28 +- .../services/service-workloads-panel.tsx | 37 +- .../components/services/services-table.tsx | 39 +-- .../settings/ai-triage-settings-section.tsx | 36 +- .../components/settings/api-keys-section.tsx | 174 ++++------ .../settings/attribute-mappings-section.tsx | 65 ++-- .../components/settings/audit-log-section.tsx | 232 ++++++------- .../settings/automation-section.tsx | 9 +- .../settings/billing-controls-card.tsx | 9 +- .../settings/billing-controls-dialog.tsx | 51 ++- .../settings/billing-details-dialog.tsx | 5 +- .../settings/billing-details-section.tsx | 4 +- .../src/components/settings/billing-kpis.tsx | 158 ++++----- .../components/settings/cost-breakdown.tsx | 14 +- .../settings/create-api-key-dialog.tsx | 6 +- .../settings/data-platform-usage-section.tsx | 63 ++-- .../settings/escalation-policy-section.tsx | 16 +- .../settings/feature-usage-cards.tsx | 39 ++- .../src/components/settings/filter-tab.tsx | 39 --- .../settings/format-feature-usage.ts | 6 + .../components/settings/ingestion-section.tsx | 57 +-- .../components/settings/invoices-section.tsx | 14 +- .../components/settings/members-section.tsx | 91 +++-- .../settings/notifications-section.tsx | 62 ++-- .../org-clickhouse-settings-section.tsx | 133 ++++--- .../settings/organization-section.tsx | 76 ++-- .../src/components/settings/plan-offer.tsx | 15 +- .../src/components/settings/pricing-cards.tsx | 27 +- .../settings/recommended-mappings-section.tsx | 24 +- .../settings/roll-api-key-dialog.tsx | 4 +- .../settings/scrape-targets-section.tsx | 205 +++++------ .../settings/setup-audit-section.tsx | 22 +- .../traces/advanced-filter-dialog.tsx | 19 +- .../components/traces/span-detail-panel.tsx | 33 +- .../components/traces/trace-anatomy-strip.tsx | 13 +- .../src/components/traces/trace-logs-link.tsx | 16 +- .../components/traces/trace-peek-sheet.tsx | 74 ++-- .../src/components/traces/traces-table.tsx | 102 +++--- .../commit-markers/commit-markers-layer.tsx | 5 +- .../vcs/commit-markers/marker-layout.ts | 3 +- .../components/vcs/commit-sha-hover-card.tsx | 35 +- apps/web/src/hooks/use-actor-directory.ts | 3 +- apps/web/src/lab/infra-lab.tsx | 6 +- apps/web/src/lab/service-map-3d/index.tsx | 6 +- apps/web/src/lib/logs/log-search-query.ts | 3 - .../src/routes/agent-sessions/$sessionId.tsx | 8 +- apps/web/src/routes/alerts/$ruleId.tsx | 50 ++- apps/web/src/routes/analytics/index.tsx | 13 +- apps/web/src/routes/anomalies/$incidentId.tsx | 52 ++- apps/web/src/routes/anomalies/index.tsx | 20 +- apps/web/src/routes/code-review/issues.tsx | 27 +- .../src/routes/code-review/pull-requests.tsx | 93 +++-- .../src/routes/dashboards/$dashboardId.tsx | 9 +- .../$dashboardId_.widgets.$widgetId.tsx | 9 +- apps/web/src/routes/dashboards/index.tsx | 13 +- .../web/src/routes/errors/issues/$issueId.tsx | 58 ++-- .../src/routes/infra/cloudflare/$zoneName.tsx | 4 +- .../web/src/routes/infra/containers/index.tsx | 4 +- apps/web/src/routes/infra/hosts/$hostName.tsx | 19 +- apps/web/src/routes/infra/hosts/index.tsx | 4 +- .../routes/infra/kubernetes/nodes/index.tsx | 9 +- .../routes/infra/kubernetes/pods/index.tsx | 9 +- .../kubernetes/services/$serviceName.tsx | 92 +++-- .../infra/kubernetes/workloads/index.tsx | 11 +- apps/web/src/routes/infra/railway/index.tsx | 4 +- apps/web/src/routes/integrations.tsx | 5 +- apps/web/src/routes/investigations/index.tsx | 57 +-- apps/web/src/routes/logs/$logId.tsx | 15 +- apps/web/src/routes/logs/index.tsx | 3 +- apps/web/src/routes/mcp-authorize.tsx | 12 +- .../recommendations/$recommendationKey.tsx | 12 +- apps/web/src/routes/releases/$commitSha.tsx | 18 +- apps/web/src/routes/replays/$sessionId.tsx | 3 +- apps/web/src/routes/select-plan.tsx | 11 +- apps/web/src/routes/traces/$traceId.tsx | 11 +- .../service-map/service-map-node.tsx | 27 +- .../ui/src/components/traces/flow-node.tsx | 11 +- .../ui/src/components/traces/span-row.tsx | 33 +- .../ui/src/components/traces/span-tooltip.tsx | 11 +- .../ui/src/components/ui/confirm-dialog.tsx | 14 +- packages/ui/src/components/ui/delta.tsx | 5 +- packages/ui/src/components/ui/key-value.tsx | 9 +- packages/ui/src/components/ui/list-footer.tsx | 12 +- packages/ui/src/components/ui/meter.tsx | 12 +- packages/ui/src/lib/log-attributes.ts | 7 +- 272 files changed, 3968 insertions(+), 5112 deletions(-) delete mode 100644 apps/web/src/components/agent-sessions/session-detail/pill.tsx delete mode 100644 apps/web/src/components/anomalies/meta-chip.tsx create mode 100644 apps/web/src/components/dashboard-builder/config/warning-list.tsx delete mode 100644 apps/web/src/components/settings/filter-tab.tsx create mode 100644 apps/web/src/components/settings/format-feature-usage.ts diff --git a/apps/local-ui/src/components/log-detail-sheet.tsx b/apps/local-ui/src/components/log-detail-sheet.tsx index e4cfd4caab..e917a9352e 100644 --- a/apps/local-ui/src/components/log-detail-sheet.tsx +++ b/apps/local-ui/src/components/log-detail-sheet.tsx @@ -12,6 +12,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@maple/ui/components/u import { ScrollArea } from "@maple/ui/components/ui/scroll-area" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { shortId } from "@maple/ui/lib/ids" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" import { CircleInfoIcon, @@ -174,7 +176,7 @@ function LogHeroHeader({ log, onClose }: { log: LocalLog; onClose: () => void })
- + {log.serviceName} - + {metricName} - + {entry ? : null} {unitLabel ? ( - + {unitLabel} ) : null} diff --git a/apps/local-ui/src/views/metrics-list-view.tsx b/apps/local-ui/src/views/metrics-list-view.tsx index 67774ea179..ea8ae543c4 100644 --- a/apps/local-ui/src/views/metrics-list-view.tsx +++ b/apps/local-ui/src/views/metrics-list-view.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef, useState } from "react" import { useVirtualizer } from "@tanstack/react-virtual" import { Badge } from "@maple/ui/components/ui/badge" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" import { METRIC_TYPE_COLORS, MetricTypeBadge } from "@maple/ui/components/metrics/metric-type-badge" import { cn } from "@maple/ui/lib/utils" @@ -230,9 +231,9 @@ function MetricPreviewCard({ className="group flex h-[124px] flex-col gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-2 focus-visible:outline-ring" >
- + {entry.metricName} - +
diff --git a/apps/local-ui/src/views/service-detail-view.tsx b/apps/local-ui/src/views/service-detail-view.tsx index ccdd2a0cb1..c21965d945 100644 --- a/apps/local-ui/src/views/service-detail-view.tsx +++ b/apps/local-ui/src/views/service-detail-view.tsx @@ -8,6 +8,7 @@ import { ServiceDot } from "@maple/ui/components/service-dot" import { Spinner } from "@maple/ui/components/ui/spinner" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { formatDuration, formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { errorRateLevel } from "@maple/ui/lib/error-rate" import { cn } from "@maple/ui/lib/utils" import { useLocalServiceOperations, @@ -114,7 +115,7 @@ export function ServiceDetailView({ serviceName, backLabel, onBack }: ServiceDet 0.05} + danger={errorRateLevel(stats.errorRate) === "crit"} /> 0 && "text-destructive")}> {formatNumber(entry.errorCount)} - 0.05 && "text-destructive")} - > - {formatErrorRate(entry.errorRate)} + + diff --git a/apps/local-ui/src/views/session-detail-view.tsx b/apps/local-ui/src/views/session-detail-view.tsx index 002683b261..cd0e85d22f 100644 --- a/apps/local-ui/src/views/session-detail-view.tsx +++ b/apps/local-ui/src/views/session-detail-view.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from "react" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { shortId } from "@maple/ui/lib/ids" import { Spinner } from "@maple/ui/components/ui/spinner" import { ArrowLeftIcon, @@ -52,9 +57,9 @@ export function SessionDetailView({ sessionId, backLabel, onBack }: SessionDetai {backLabel} - + {sessionId} - +
@@ -84,7 +89,7 @@ export function SessionDetailView({ sessionId, backLabel, onBack }: SessionDetai
- {sessionId.slice(0, 8)} + {session.browserName || "Unknown"} @@ -116,24 +121,42 @@ export function SessionDetailView({ sessionId, backLabel, onBack }: SessionDetai
-
- - - - - - - -
+ + + {label || "-"} + + + + {session.browserName || "-"} + + + + {session.osName || "-"} + + + + {session.deviceType || "-"} + + + + + {session.serviceName || "-"} + + + + + {hostFromUrl(session.urlInitial) || "-"} + + + + + {session.userAgent || "-"} + + +
@@ -164,7 +187,7 @@ export function SessionDetailView({ sessionId, backLabel, onBack }: SessionDetai {trace.rootSpanName || - trace.traceId.slice(0, 12)} + shortId(trace.traceId, "trace", { length: 12 })} {trace.rootServiceName || "unknown"} ·{" "} @@ -335,19 +358,19 @@ function TranscriptBody({ event }: { event: SessionTranscriptOutput }) { function StatusBadge({ active }: { active: boolean }) { if (active) { return ( - + Active - + ) } return ( - + Ended - + ) } @@ -376,23 +399,3 @@ function Card({ title, children }: { title: string; children: ReactNode }) { ) } -function Field({ - label, - value, - title, - className, -}: { - label: string - value: string - title?: string - className?: string -}) { - return ( -
-
{label}
-
- {value || "-"} -
-
- ) -} diff --git a/apps/local-ui/src/views/sessions-list-view.tsx b/apps/local-ui/src/views/sessions-list-view.tsx index cb98bac7e9..e783982fd7 100644 --- a/apps/local-ui/src/views/sessions-list-view.tsx +++ b/apps/local-ui/src/views/sessions-list-view.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react" -import { Spinner } from "@maple/ui/components/ui/spinner" +import { Badge } from "@maple/ui/components/ui/badge" +import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { shortId } from "@maple/ui/lib/ids" import { ClockIcon, CircleWarningIcon, @@ -9,7 +11,6 @@ import { MobileIcon, PulseIcon, } from "@maple/ui/components/icons" -import { Button } from "@maple/ui/components/ui/button" import { cn } from "@maple/ui/lib/utils" import type { SessionReplaysListOutput } from "@maple/query-engine/ch" import { useLocalSessions, useLocalSessionFacets } from "../hooks/use-local-sessions" @@ -154,18 +155,12 @@ export function SessionsListView() { /> ))}
- {hasNextPage ? ( -
- -
- ) : null} + fetchNextPage()} + className="pb-0" + />
)} @@ -194,7 +189,7 @@ function SessionCard({ session, href }: { session: SessionReplaysListOutput; hre {label} - {session.sessionId.slice(0, 8)} · {formatSessionDuration(session.durationMs)} + {shortId(session.sessionId, "session", { length: 8 })} · {formatSessionDuration(session.durationMs)}
@@ -220,15 +215,15 @@ function SessionCard({ session, href }: { session: SessionReplaysListOutput; hre title="page views" /> {session.traceCount > 0 && ( - + {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} - + )} {session.errorCount > 0 && ( - + {session.errorCount} - + )}
diff --git a/apps/local-ui/src/views/trace-detail-view.tsx b/apps/local-ui/src/views/trace-detail-view.tsx index e59020d87b..0a14ad0b44 100644 --- a/apps/local-ui/src/views/trace-detail-view.tsx +++ b/apps/local-ui/src/views/trace-detail-view.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react" import { isTraceView, TraceViewTabs } from "@maple/ui/components/traces/trace-view-tabs" import { Button } from "@maple/ui/components/ui/button" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Spinner } from "@maple/ui/components/ui/spinner" import { ArrowLeftIcon } from "@maple/ui/components/icons" import type { SpanNode } from "@maple/ui/lib/types" @@ -45,9 +46,9 @@ export function TraceDetailView({ traceId, backLabel, onBack }: TraceDetailViewP {backLabel} - + {traceId} - + diff --git a/apps/local-ui/src/views/trace-list-view.tsx b/apps/local-ui/src/views/trace-list-view.tsx index 163a74c888..04fff97c09 100644 --- a/apps/local-ui/src/views/trace-list-view.tsx +++ b/apps/local-ui/src/views/trace-list-view.tsx @@ -1,7 +1,7 @@ import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Spinner } from "@maple/ui/components/ui/spinner" +import { ListFooter } from "@maple/ui/components/ui/list-footer" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { formatDuration } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -228,18 +228,11 @@ export function TraceListView() {
- {traces.hasNextPage ? ( -
- -
- ) : null} + traces.fetchNextPage()} + /> )} @@ -307,12 +300,12 @@ function TraceListRow({ row, query }: { row: TraceRow; query: URLSearchParams })
{row.services.slice(0, 3).map((svc) => ( - + {svc} ))} {row.services.length > 3 ? ( - + +{row.services.length - 3} ) : null} diff --git a/apps/web/src/components/account/active-sessions-section.tsx b/apps/web/src/components/account/active-sessions-section.tsx index c6f998e980..9b9d9f73aa 100644 --- a/apps/web/src/components/account/active-sessions-section.tsx +++ b/apps/web/src/components/account/active-sessions-section.tsx @@ -6,20 +6,11 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" -import { AlertWarningIcon, ComputerIcon, MobileIcon } from "@/components/icons" +import { ComputerIcon, MobileIcon } from "@/components/icons" +import { RelativeTime } from "@/components/common/relative-time" import { useMountEffect } from "@/hooks/use-mount-effect" import { toastAccountError } from "@/components/account/account-errors" @@ -28,18 +19,6 @@ type LoadState = | { status: "error"; message: string } | { status: "ready"; sessions: ReadonlyArray } -const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) - -/** "3 minutes ago" from a timestamp, coarsening upwards so long-idle sessions read as days. */ -function formatLastActive(date: Date): string { - const seconds = Math.round((date.getTime() - Date.now()) / 1000) - const magnitude = Math.abs(seconds) - if (magnitude < 60) return relative.format(seconds, "second") - if (magnitude < 3600) return relative.format(Math.round(seconds / 60), "minute") - if (magnitude < 86_400) return relative.format(Math.round(seconds / 3600), "hour") - return relative.format(Math.round(seconds / 86_400), "day") -} - function describeDevice(session: SessionWithActivities): string { const { browserName, browserVersion, deviceType } = session.latestActivity const browser = [browserName, browserVersion].filter(Boolean).join(" ") @@ -108,10 +87,7 @@ export function ActiveSessionsSection() { {!isLoaded || state.status === "loading" ? ( -
- - -
+ ) : state.status === "error" ? (

{state.message}

@@ -156,7 +132,7 @@ export function ActiveSessionsSection() { {describeLocation(session)} - {formatLastActive(session.lastActiveAt)} + {/* Revoking the current session would sign the user out from @@ -180,31 +156,17 @@ export function ActiveSessionsSection() { - { if (!open) setPendingRevoke(null) }} - > - - - - - - Sign out this device? - - {pendingRevoke ? describeDevice(pendingRevoke) : "This session"} will need to sign - in again to reach Maple. - - - - Cancel - - {isRevoking ? "Signing out..." : "Sign out"} - - - - + title="Sign out this device?" + description={`${pendingRevoke ? describeDevice(pendingRevoke) : "This session"} will need to sign in again to reach Maple.`} + confirmLabel="Sign out" + pending={isRevoking} + onConfirm={() => void handleRevoke()} + />
) } diff --git a/apps/web/src/components/account/connected-accounts-section.tsx b/apps/web/src/components/account/connected-accounts-section.tsx index 9853c789e5..f2fae82dff 100644 --- a/apps/web/src/components/account/connected-accounts-section.tsx +++ b/apps/web/src/components/account/connected-accounts-section.tsx @@ -6,19 +6,9 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" -import { cn } from "@maple/ui/lib/utils" -import { AlertWarningIcon, GithubIcon, GoogleIcon, type IconComponent } from "@/components/icons" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { SettingRow } from "@maple/ui/components/ui/setting-row" +import { GithubIcon, GoogleIcon, type IconComponent } from "@/components/icons" import { toastAccountError } from "@/components/account/account-errors" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" @@ -124,106 +114,83 @@ export function ConnectedAccountsSection() { const isBusy = busyProvider === provider.id return ( -
-
+ framed + active={isVerified} + icon={
-
-
-

{provider.label}

- {account && !isVerified && ( - + {provider.label} + {account && !isVerified && ( + + Incomplete + + )} + + } + description={ + + {account + ? account.emailAddress || account.username || "Connected" + : `Connect your ${provider.label} account`} + + } + control={ + account ? ( + <> + {!isVerified && ( + )} -
-

- {account - ? account.emailAddress || account.username || "Connected" - : `Connect your ${provider.label} account`} -

-
-
- {account ? ( -
- {!isVerified && ( - )} + + ) : ( -
- ) : ( - - )} -
+ ) + } + /> ) })}
- { if (!open) setPendingRemoval(null) }} - > - - - - - - Disconnect {pendingRemoval?.label}? - - You will no longer be able to sign in to Maple with {pendingRemoval?.label}. Make - sure you still have a password, a passkey or another connected account first. - - - - Cancel - - Disconnect - - - - + title={`Disconnect ${pendingRemoval?.label}?`} + description={`You will no longer be able to sign in to Maple with ${pendingRemoval?.label}. Make sure you still have a password, a passkey or another connected account first.`} + confirmLabel="Disconnect" + pending={busyProvider !== null} + onConfirm={() => void handleDisconnect()} + /> ) } diff --git a/apps/web/src/components/account/email-addresses-section.tsx b/apps/web/src/components/account/email-addresses-section.tsx index 58299b62e2..ca9be70b5c 100644 --- a/apps/web/src/components/account/email-addresses-section.tsx +++ b/apps/web/src/components/account/email-addresses-section.tsx @@ -5,7 +5,15 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@maple/ui/components/ui/card" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { @@ -17,17 +25,6 @@ import { DialogPanel, DialogTitle, } from "@maple/ui/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" import { DropdownMenu, DropdownMenuContent, @@ -36,7 +33,6 @@ import { } from "@maple/ui/components/ui/dropdown-menu" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { - AlertWarningIcon, CircleCheckIcon, DotsVerticalIcon, EnvelopeIcon, @@ -150,18 +146,18 @@ export function EmailAddressesSection() { return (
- -
- Email Addresses - - Your primary address receives sign-in codes, alerts and digests. Others can be - used to sign in. - -
- + + Email Addresses + + Your primary address receives sign-in codes, alerts and digests. Others can be used + to sign in. + + + + @@ -336,31 +332,17 @@ export function EmailAddressesSection() { - { if (!open) setPendingRemoval(null) }} - > - - - - - - Remove email address? - - {pendingRemoval?.emailAddress} can no longer be used to sign in or receive - notifications from Maple. - - - - Cancel - - {isBusy ? "Removing..." : "Remove"} - - - - + title="Remove email address?" + description={`${pendingRemoval?.emailAddress} can no longer be used to sign in or receive notifications from Maple.`} + confirmLabel="Remove" + pending={isBusy} + onConfirm={() => void handleRemove()} + /> ) } diff --git a/apps/web/src/components/account/passkeys-section.tsx b/apps/web/src/components/account/passkeys-section.tsx index 139aca05da..c8440ee650 100644 --- a/apps/web/src/components/account/passkeys-section.tsx +++ b/apps/web/src/components/account/passkeys-section.tsx @@ -4,7 +4,15 @@ import type { Passkey } from "@/components/account/account-types" import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@maple/ui/components/ui/card" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { @@ -16,17 +24,6 @@ import { DialogPanel, DialogTitle, } from "@maple/ui/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" import { DropdownMenu, DropdownMenuContent, @@ -36,7 +33,6 @@ import { import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { - AlertWarningIcon, DotsVerticalIcon, FingerprintIcon, PencilIcon, @@ -116,18 +112,17 @@ export function PasskeysSection() { return (
- -
- Passkeys - - Sign in with Touch ID, Windows Hello, a phone or a hardware key instead of a - password. - -
- + + Passkeys + + Sign in with Touch ID, Windows Hello, a phone or a hardware key instead of a password. + + + + {!supported ? ( @@ -269,31 +264,17 @@ export function PasskeysSection() { - { if (!open) setPendingRemoval(null) }} - > - - - - - - Remove passkey? - - {pendingRemoval?.name ?? "This passkey"} can no longer be used to sign in. The - credential stays on your device until you delete it there too. - - - - Cancel - - {isBusy ? "Removing..." : "Remove"} - - - - + title="Remove passkey?" + description={`${pendingRemoval?.name ?? "This passkey"} can no longer be used to sign in. The credential stays on your device until you delete it there too.`} + confirmLabel="Remove" + pending={isBusy} + onConfirm={() => void handleRemove()} + />
) } diff --git a/apps/web/src/components/account/password-section.tsx b/apps/web/src/components/account/password-section.tsx index fe3bb11237..d6d930860c 100644 --- a/apps/web/src/components/account/password-section.tsx +++ b/apps/web/src/components/account/password-section.tsx @@ -6,6 +6,7 @@ import { Button } from "@maple/ui/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { toastAccountError } from "@/components/account/account-errors" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" @@ -114,19 +115,18 @@ export function PasswordSection() { /> {mismatch &&

Passwords do not match.

} -
-
-

Sign out of other devices

-

- End every session except this one. -

-
- -
+ + } + />
) } diff --git a/apps/web/src/components/account/two-factor-section.tsx b/apps/web/src/components/account/two-factor-section.tsx index 606a8d5af5..af808eb1da 100644 --- a/apps/web/src/components/account/two-factor-section.tsx +++ b/apps/web/src/components/account/two-factor-section.tsx @@ -16,18 +16,8 @@ import { DialogPanel, DialogTitle, } from "@maple/ui/components/ui/dialog" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" -import { AlertWarningIcon, CircleCheckIcon, ShieldIcon } from "@/components/icons" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CircleCheckIcon, ShieldIcon } from "@/components/icons" import { toastAccountError } from "@/components/account/account-errors" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { CodeField } from "@/components/account/code-field" @@ -322,26 +312,15 @@ export function TwoFactorSection() { - - - - - - - Remove two-factor authentication? - - Your account will be protected by your password alone, and your backup codes stop - working. - - - - Cancel - - {isBusy ? "Removing..." : "Remove"} - - - - + void handleDisable()} + /> ) } diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 42788885d2..2336e1d9f9 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -12,7 +12,9 @@ import type { AiSessionSortDir, AiSessionSortKey } from "@maple/domain/http" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Spinner } from "@maple/ui/components/ui/spinner" +import { badgeVariants } from "@maple/ui/components/ui/badge" +import { LoadingMoreRow } from "@maple/ui/components/ui/list-footer" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { TableSkeleton } from "@maple/ui/components/ui/table-skeleton" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" @@ -111,11 +113,6 @@ const TABLE_FEATURES = tableFeatures({ columnSizingFeature }) /** Two lines — the name over the id — at text-sm and text-xs, plus the cell padding. */ const ROW_HEIGHT = 53 -// No wrap: a two-word label ("LLM calls") breaking onto a second line would -// make the whole header row taller. -const HEADER_CELL_CLASS = - "h-10 whitespace-nowrap px-2 text-left align-middle font-medium text-muted-foreground" - /** * Column layout, shared by the real table and the loading skeleton so the two can't drift apart. * @@ -474,12 +471,12 @@ export function AgentSessionsList({ * table-fixed makes the declared column widths authoritative: the sized columns stay * pinned and Session, the only column that should flex, takes the remainder. */} -
- +
+ {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( - + ))} - - + + {firstItem && ( {row.getAllCells().map((cell) => ( - + ))} - + ) })} {lastItem && ( @@ -559,15 +553,12 @@ export function AgentSessionsList({ {loadingMore && ( )} - -
+ ))} -
{flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- - Loading more sessions… -
+
+ +
{hasMore && } @@ -884,10 +875,7 @@ function ErrorChip({ }) { return ( {Icon ? ( diff --git a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx index acadf6d200..c8c79011c5 100644 --- a/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/payload-view.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { cn } from "@maple/ui/lib/utils" import { tryParseJson } from "@/components/attributes" @@ -57,51 +58,31 @@ export function ViewSwitch({ className?: string }) { return ( - { + // Clicking the pressed segment empties the group; the view stays put. + if (next.length > 0) onRawChange(next[0] === "raw") + }} + className={cn("shrink-0 self-center", className)} > - onRawChange(false)}> + {rendered} - - onRawChange(true)}> + + raw - - + + ) } -/** One segment of the two-state switch. */ -function ViewSegment({ - active, - onSelect, - children, -}: { - active: boolean - onSelect: () => void - children: string -}) { - return ( - - ) -} +const SEGMENT_CLASS = "font-mono text-[10px] uppercase tracking-[0.08em] sm:text-[10px]" + +// The switch sits inside clickable rows; a segment click must not toggle the row. +const stopPropagation = (event: { stopPropagation: () => void }) => event.stopPropagation() /** * Whether a keyed disclosure is open, given the default its section opens with. diff --git a/apps/web/src/components/agent-sessions/session-detail/pill.tsx b/apps/web/src/components/agent-sessions/session-detail/pill.tsx deleted file mode 100644 index 6ceb9ec2d5..0000000000 --- a/apps/web/src/components/agent-sessions/session-detail/pill.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactNode } from "react" - -import { cn } from "@maple/ui/lib/utils" - -/** The session page's badge tones. One vocabulary, so an error reads the same - * on a waterfall row, a turn header and a transcript block. */ -export const PILL_TONE = { - error: "bg-destructive/12 text-destructive", - warn: "bg-severity-warn/12 text-severity-warn", - outline: "border border-border text-muted-foreground", -} satisfies Record - -/** - * A small status badge. - * - * The default shape is the waterfall's — uppercase, tracked, rounded-full — - * which is right for a one-word state. `className` is the escape hatch for a - * badge whose content is a raw attribute value (`error.type - * context_length_exceeded`), where upper-casing a wire string reads as shouting. - */ -export function Pill({ - tone, - className, - children, -}: { - tone: keyof typeof PILL_TONE - className?: string - children: ReactNode -}) { - return ( - - {children} - - ) -} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-header.tsx b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx index 1b60543f9e..9baef4288b 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-header.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx @@ -2,11 +2,12 @@ import type { ReactNode } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { shortId } from "@maple/ui/lib/ids" import { traceSessionTraceId } from "@maple/domain/gen-ai" import { CopyableValue } from "@/components/attributes" +import { RelativeTime } from "@/components/common/relative-time" import { CopyIcon } from "@/components/icons" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { type SessionSummary } from "@maple/agent-sessions" @@ -48,8 +49,8 @@ export function SessionHeader({ sessionId, summary }: { sessionId: string; summa
- - {formatRelativeTimeOrDate(summary.startMs)} + + {summary.serviceNames.length > 0 && ( @@ -62,7 +63,7 @@ export function SessionHeader({ sessionId, summary }: { sessionId: string; summa label={traceId === undefined ? "Session ID" : "Trace ID"} className="inline-flex min-w-0 max-w-full items-center gap-1 font-mono" > - {shortId(traceId ?? sessionId)} + {shortId(traceId ?? sessionId, "generic", { length: ID_PREFIX_LENGTH, ellipsis: true })} @@ -95,10 +96,6 @@ export function sessionIdentity(summary: Pick ID_PREFIX_LENGTH ? `${id.slice(0, ID_PREFIX_LENGTH)}…` : id -} - function Fact({ label, title, diff --git a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx index 40bcc7a681..c1e9ebc2a3 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-overview.tsx @@ -5,6 +5,7 @@ import type { GetAiSessionSummaryResponse } from "@maple/domain/http" import { ArrowRightIcon, ChevronRightIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Meter, SegmentedBar } from "@maple/ui/components/ui/meter" import { Separator } from "@maple/ui/components/ui/separator" import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" @@ -179,15 +180,15 @@ function TimeComposition({ summary }: { summary: SessionSummary }) { -
- {bands.map((band) => ( -
- ))} -
+ ({ + key: band.kind, + value: band.ms, + className: AGENT_TIME_FILL[band.kind], + }))} + total={total} + className="h-2 w-full gap-px rounded-xs bg-muted" + /> {legend.map((band) => { const Icon = AGENT_TIME_ICON[band.kind] @@ -250,14 +251,13 @@ function Rail({ summary }: { summary: SessionSummary }) {
{model.cost !== undefined && topModelCost > 0 && ( -
-
-
+ )}
)) @@ -284,17 +284,15 @@ function Rail({ summary }: { summary: SessionSummary }) {

no token usage reported

) : ( <> -
- {tokenBuckets.map((bucket) => ( -
- ))} -
+ ({ + key: bucket.key, + value: summary.tokens[bucket.key], + className: bucket.fill, + }))} + total={summary.tokens.total} + className="h-2 w-full gap-px rounded-xs bg-muted" + /> {tokenBuckets.map((bucket) => (
diff --git a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx index 94f79acd63..fa2afadba2 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx @@ -1,4 +1,5 @@ import { memo, useDeferredValue, useEffect, useMemo, useRef, type ReactNode } from "react" +import { shortId } from "@maple/ui/lib/ids" import { useVirtualizer } from "@tanstack/react-virtual" import type { AiSessionSpan } from "@maple/domain/http" @@ -9,6 +10,8 @@ import { Spinner } from "@maple/ui/components/ui/spinner" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { formatBytes, formatDuration } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { BranchForkIcon, @@ -45,7 +48,6 @@ import { import { formatClockInTimezone } from "@/lib/timezone-format" import { ClampedText, firstLine } from "./clamped-text" import { disclosed, MessageBody, useJsonPayload, useMessageBody, ViewSwitch } from "./payload-view" -import { Pill } from "./pill" import { ToolIo, ToolIoSummary } from "./tool-io" /** @@ -434,7 +436,11 @@ function TurnChapter({ {turnOrdinal(turn)} - {turn.failed && Failed} + {turn.failed && ( + + Failed + + )} {collapsed && ( <> {/* The label is the first prose line of a captured message, not a @@ -703,7 +709,11 @@ function AssistantBlock({ className="flex min-w-0 grow cursor-pointer items-center gap-2 text-left" > {(!continued || row.failed) && Assistant} - {row.failed && Failed} + {row.failed && ( + + Failed + + )} {clockOf(row.startMs, timeZone)} {row.span.genAi.errorType !== undefined && ( @@ -1009,9 +1019,9 @@ function PayloadSection({ {/* Emitter truncation, not the view's clamping — there is no "show full" that can recover what was never recorded. */} {payload.truncatedByEmitter && ( - + truncated by the emitter - + )} {/* Copies what is displayed: the pretty-printed JSON, or the raw text. The switch only appears where the two differ. */} @@ -1086,7 +1096,7 @@ function LaneOpen({ ? row.parentAgentName !== undefined ? `subagent of ${row.parentAgentName}` : "subagent" - : `agent · trace ${row.span.traceId.slice(0, 8)}`}{" "} + : `agent · trace ${shortId(row.span.traceId, "trace")}`}{" "} · {row.spanCount} spans · {formatDuration(row.span.durationMs)}
@@ -1420,7 +1430,7 @@ function structureMeta(span: AiSessionSpan, category: string): { shown: string; if (category === "tool") { return { shown: `${span.serviceName} · payloads not captured`, onHover: "" } } - if (category === "agent") return { shown: `trace ${span.traceId.slice(0, 8)}`, onHover: "" } + if (category === "agent") return { shown: `trace ${shortId(span.traceId, "trace")}`, onHover: "" } // The model already leads the label; the rest of the call's facts follow, so // the parts are taken as parts rather than sliced back out of a joined line. // What the call cost is the turn header's, the same as on a captured call, diff --git a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx index 5b3b81ed26..ff5183362c 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef } from "react" +import { shortId } from "@maple/ui/lib/ids" import { Link } from "@tanstack/react-router" import { useVirtualizer } from "@tanstack/react-virtual" @@ -6,9 +7,12 @@ import type { AiSessionSpan } from "@maple/domain/http" import { ChevronDownIcon, ChevronRightIcon, CircleXmarkIcon } from "@/components/icons" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { SegmentedBar } from "@maple/ui/components/ui/meter" import { formatDuration, formatNumber } from "@maple/ui/lib/format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { useListNavigation } from "@/hooks/use-list-navigation" import { usePageScrollMargin } from "@/hooks/use-page-scroll-margin" @@ -35,7 +39,6 @@ import { filterSpans, isDelegation } from "@/lib/agent-sessions/span-filters" import { useDetectedModels, type DetectedModel } from "@/hooks/use-detected-models" import { TOKEN_BUCKETS } from "@/lib/agent-sessions/token-buckets" import { ModelLabel } from "../model-label" -import { Pill } from "./pill" import type { SpanDetailTab } from "./span-expansion" import { SpanPopover } from "./span-popover" import { CATEGORY_FILL, CATEGORY_ICON, CATEGORY_TEXT } from "./span-visuals" @@ -511,11 +514,15 @@ function TurnHeader({ turn.label )} - {turn.failed && Failed} + {turn.failed && ( + + Failed + + )} {collapsed && ( - + {row.visibleCount} spans - + )} {traceId !== undefined && ( @@ -554,7 +561,7 @@ function TraceLink({ traceId, timestamp }: { traceId: string; timestamp: string "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", )} > - Trace {traceId.slice(0, 8)} + Trace {shortId(traceId, "trace")} ) } @@ -640,8 +647,16 @@ function SpanRow({ {span.statusMessage !== "" && ( {span.statusMessage} )} - {errored && {span.genAi.errorType ?? "Error"}} - {isDelegation(span, spansById) && Subagent} + {errored && ( + + {span.genAi.errorType ?? "Error"} + + )} + {isDelegation(span, spansById) && ( + + Subagent + + )} - - {drawn.map((bucket) => ( - - ))} - + ({ + key: bucket.key, + value: tokens[bucket.key], + className: bucket.fill, + }))} + total={tokens.total} + className="flex-1 gap-px rounded-xs bg-muted" + /> diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index cd3603f89f..eb7be5f190 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -9,8 +9,12 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Button } from "@maple/ui/components/ui/button" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { shortId } from "@maple/ui/lib/ids" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { ChevronDownIcon, ChevronRightIcon, CircleWarningIcon, ExternalLinkIcon } from "@/components/icons" import { @@ -43,7 +47,6 @@ import { } from "@maple/agent-sessions" import { ClampedText, type ClampLines, firstLine } from "./clamped-text" import { toggled, useJsonPayload, useMessageBody, ViewSwitch } from "./payload-view" -import { Pill } from "./pill" import { ToolIo } from "./tool-io" /** @@ -52,7 +55,8 @@ import { ToolIo } from "./tool-io" * body through `header`. */ -export type SpanDetailTab = "details" | "messages" | "tools" | "logs" +const SPAN_DETAIL_TABS = ["details", "messages", "tools", "logs"] as const +export type SpanDetailTab = (typeof SPAN_DETAIL_TABS)[number] /** The overlay is a reading surface, not a peek, so a payload gets twice the * transcript's twelve lines before it asks to be expanded. */ @@ -93,28 +97,26 @@ export function SpanExpansion({ : "details") const tabs = ( -
- onTabChange("details")}> - Details - - onTabChange("messages")} - count={messages.length} - > + { + const picked = SPAN_DETAIL_TABS.find((tab) => tab === next[0]) + if (picked) onTabChange(picked) + }} + > + Details + Messages - - onTabChange("tools")} - count={toolCalls.length} - > + + + Tool calls - - onTabChange("logs")}> - Logs - -
+ + + Logs + ) return ( @@ -143,36 +145,9 @@ export function SpanExpansion({ ) } -function TabButton({ - active, - onClick, - count, - children, -}: { - active: boolean - onClick: () => void - count?: number - children: string -}) { - return ( - - ) +function TabCount({ count }: { count: number }) { + if (count === 0) return null + return {count} } function CopySpanJsonButton({ span }: { span: AiSessionSpan }) { @@ -255,7 +230,7 @@ function MetaStrip({ span }: { span: AiSessionSpan }) { · - trace {span.traceId.slice(0, 8)}…{span.traceId.slice(-4)} + trace {shortId(span.traceId, "trace")}…{span.traceId.slice(-4)}
@@ -704,23 +679,23 @@ function FailureBanner({ span }: { span: AiSessionSpan }) { This call failed {span.statusCode === "Error" && ( - + span status Error - + )} {errorType !== undefined && errorType !== "" && ( - + error.type {errorType} - + )} {/* Only where it is the evidence: with a status or an error type above, restating the response status would just be noise beside them. */} {span.statusCode !== "Error" && (errorType === undefined || errorType === "") && responseStatus !== undefined && ( - + response.status {responseStatus} - + )}

diff --git a/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx b/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx index f646be7e36..3e8364965b 100644 --- a/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/tool-io.tsx @@ -1,11 +1,12 @@ import { CopyButton } from "@maple/ui/components/ui/copy-button" import { formatBytes } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { ArrowDownIcon, ArrowUpIcon, CircleQuestionIcon } from "@/components/icons" import { ClampedText, type ClampLines } from "./clamped-text" import { disclosed, useJsonPayload, ViewSwitch } from "./payload-view" -import { Pill } from "./pill" /** * One invocation, both halves, joined by a spine. @@ -197,9 +198,9 @@ function IoHalf({ {/* Emitter truncation, not the view's clamping — there is no "show full" that can recover what was never recorded. */} {payload.truncatedByEmitter && ( - + truncated by the emitter - + )} {/* Copies what is displayed: the pretty-printed JSON, or the raw text. The switch only appears where the two differ. */} diff --git a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx index 1b49c6fe79..f36870e41e 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-breakdown-tables.tsx @@ -2,12 +2,13 @@ import { useMemo, type ReactNode } from "react" import { Link } from "@tanstack/react-router" import { cn } from "@maple/ui/lib/utils" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Meter } from "@maple/ui/components/ui/meter" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { ERROR_RATE_FILL, ERROR_RATE_TEXT, errorRateLevel, formatErrorRate } from "@maple/ui/lib/error-rate" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { ErrorState } from "@/components/common/error-state" import { useTableSort, type SortDir } from "@/components/common/data-table" -import { relativeRatio } from "@/components/infra/primitives/share-bar" import { ArrowUpDownIcon, ChevronRightIcon } from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import type { ToolDetailLinkSearch } from "@/lib/agent-sessions/tool-search" @@ -158,19 +159,13 @@ export function TableFooter({ subject, detail }: { subject: string; detail: stri ) } -/** - * Error rate → severity tone. Thresholds rather than a gradient because the - * question is triage-shaped: under 1% is background noise for a tool that runs - * thousands of times, over 10% is something a person should look at today. - */ -export function errorTone(rate: number): { bar: string; text: string } { - if (rate >= 0.1) return { bar: "bg-[var(--severity-error)]", text: "text-[var(--severity-error)]" } - if (rate >= 0.01) return { bar: "bg-[var(--severity-warn)]", text: "text-[var(--severity-warn)]" } - return { bar: "bg-[var(--severity-info)]", text: "text-muted-foreground" } -} - -export function formatRate(rate: number): string { - return rate === 0 ? "0%" : `${(rate * 100).toFixed(rate < 0.01 ? 2 : 1)}%` +/** Error rate → severity tone, on the app-wide thresholds (lib/error-rate). */ +function errorTone(rate: number): { bar: string; text: string } { + const level = errorRateLevel(rate) + return { + bar: ERROR_RATE_FILL[level], + text: level === "neutral" ? "text-muted-foreground" : ERROR_RATE_TEXT[level], + } } /** @@ -193,15 +188,15 @@ export function ShareCell({ label: string tone: { bar: string; text: string } }) { - const width = relativeRatio(ratio, max) return ( - - 0 ? 3 : 0)}%` }} - /> - + {label} @@ -405,11 +400,7 @@ export function ToolsTable({ {failure !== undefined ? ( ) : loading ? ( -

- - - -
+ ) : sorted.length === 0 ? ( No tool calls in the selected window. ) : ( @@ -459,7 +450,7 @@ export function ToolsTable({ ) : loading ? ( -
- - - -
+ ) : rows.length === 0 ? ( No sessions called {tool} in the selected window. diff --git a/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx b/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx index a76b1a8640..a860ab5d5b 100644 --- a/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx +++ b/apps/web/src/components/agent-sessions/tools/tool-metric-strip.tsx @@ -1,5 +1,6 @@ import { cn } from "@maple/ui/lib/utils" import { formatPercent } from "@maple/ui/lib/format" +import { Delta } from "@maple/ui/components/ui/delta" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" @@ -87,7 +88,7 @@ export function ToolMetricStrip({ ) : delta === null ? null : ( - + vs prev {windowLabel} ) @@ -156,7 +157,7 @@ function DurationTile({ subline={ delta === null ? null : ( - + vs prev {windowLabel} ) @@ -192,30 +193,19 @@ function DurationTile({ } /** - * Change against the previous window, in the unit the metric is read in — - * a percentage for the counts, points for a rate, a duration for a latency. - * - * Colour follows *improvement*, not direction — a falling error rate is green — - * and the arrow keeps pointing the way the number actually moved, so colour is - * never the only thing carrying the meaning. Same rule and same tokens as the - * web analytics strip. + * Change against the previous window, in the unit the metric is read in: a + * percentage for the counts, points for a rate, a duration for a latency. + * `toolDelta` grades the move, so the sign and `invert` here only carry its + * direction and verdict into the shared Delta, and `format` prints its text. */ -function Delta({ delta }: { delta: ToolDelta }) { +function ToolDeltaValue({ delta }: { delta: ToolDelta }) { + const sign = delta.direction === "flat" ? 0 : delta.direction === "up" ? 1 : -1 return ( - - {delta.direction === "flat" ? "→" : delta.direction === "up" ? "↑" : "↓"} - {delta.text} - + delta.text} + /> ) } diff --git a/apps/web/src/components/ai-elements/inline/inline-error.tsx b/apps/web/src/components/ai-elements/inline/inline-error.tsx index 52d002285a..7789cbe3e9 100644 --- a/apps/web/src/components/ai-elements/inline/inline-error.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-error.tsx @@ -1,5 +1,6 @@ import type { InlineErrorData } from "@maple/domain/chat-annotations" import { formatNumber } from "@maple/ui/lib/format" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { CircleWarningIcon } from "@/components/icons" import { INLINE_CARD_META, @@ -21,9 +22,9 @@ export function InlineError({ data }: { data: InlineErrorData }) { {/* Truncation is the browser's job. Cutting the string at 80 characters put an ellipsis mid-word regardless of how much room the card actually had. */} - + {data.errorType} - + {data.count != null && ( {formatNumber(data.count)} diff --git a/apps/web/src/components/ai-elements/inline/inline-log.tsx b/apps/web/src/components/ai-elements/inline/inline-log.tsx index fe5efb16cd..19674525b3 100644 --- a/apps/web/src/components/ai-elements/inline/inline-log.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-log.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import type { InlineLogData } from "@maple/domain/chat-annotations" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { normalizeTimestampInput } from "@/lib/timezone-format" import { INLINE_CARD_ROW, inlineCardClass } from "./inline-card" @@ -19,9 +20,9 @@ export function InlineLog({ data }: { data: InlineLogData }) { {data.serviceName}
)} - + {data.body} - + {data.timestamp && ( {new Date(normalizeTimestampInput(data.timestamp)).toLocaleTimeString()} diff --git a/apps/web/src/components/ai-elements/inline/inline-service.tsx b/apps/web/src/components/ai-elements/inline/inline-service.tsx index 3a7d3f4942..40a02cc631 100644 --- a/apps/web/src/components/ai-elements/inline/inline-service.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-service.tsx @@ -2,17 +2,12 @@ import { Link } from "@tanstack/react-router" import type { InlineServiceData } from "@maple/domain/chat-annotations" import { cn } from "@maple/ui/lib/utils" import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { errorRateClass } from "@maple/ui/lib/error-rate" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { LatencyValue } from "@maple/ui/components/latency-value" import { ServerIcon } from "@/components/icons" import { INLINE_CARD_ROW, InlineCardChevron, InlineMetric, inlineCardClass } from "./inline-card" -/** Same thresholds the services list tones by: under 1% is noise, 5% is an outage. */ -function errorTone(rate: number): string { - if (rate >= 5) return "text-severity-error" - if (rate >= 1) return "text-severity-warn" - return "text-muted-foreground" -} - export function InlineService({ data }: { data: InlineServiceData }) { return (
- - {data.name} - + {data.name} {/* The card is its own container: in a 420px side panel the service name matters more than its throughput, so the least diagnostic lane goes first. */} {data.throughputRpm != null && ( @@ -43,7 +33,7 @@ export function InlineService({ data }: { data: InlineServiceData }) { )} {data.errorRate != null && ( - + {formatErrorRate(data.errorRate / 100)} )} diff --git a/apps/web/src/components/ai-elements/inline/inline-trace.tsx b/apps/web/src/components/ai-elements/inline/inline-trace.tsx index 21f6349774..6f1a7e512b 100644 --- a/apps/web/src/components/ai-elements/inline/inline-trace.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-trace.tsx @@ -1,3 +1,4 @@ +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { Link } from "@tanstack/react-router" import type { InlineTraceData } from "@maple/domain/chat-annotations" import { cn } from "@maple/ui/lib/utils" @@ -53,9 +54,12 @@ export function InlineTrace({ data }: { data: InlineTraceData }) {
{/* The id is evidence rather than a label — it is what a follow-up question names — so it sits under the span name instead of competing with it. */} - - {data.id.slice(0, 12)} - +
diff --git a/apps/web/src/components/ai-elements/markdown-table.tsx b/apps/web/src/components/ai-elements/markdown-table.tsx index 95d5356610..0f9e792cd8 100644 --- a/apps/web/src/components/ai-elements/markdown-table.tsx +++ b/apps/web/src/components/ai-elements/markdown-table.tsx @@ -1,6 +1,8 @@ import { Children, createContext, isValidElement, use, useRef, type ReactNode } from "react" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { httpStatusTone } from "@maple/ui/lib/http" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { LATENCY_TEXT_TONE, latencyLevel } from "@maple/ui/lib/latency-tone" import { SEVERITY_COLORS } from "@maple/ui/lib/severity" import { cn } from "@maple/ui/lib/utils" @@ -169,14 +171,9 @@ function CellValueView({ value }: { value: Exclude {value.text} ) - case "status": { - const tone = - value.code >= 500 - ? "text-severity-error" - : value.code >= 400 - ? "text-severity-warn" - : "text-muted-foreground" - return {value.text} - } + case "status": + return ( + {value.text} + ) } } diff --git a/apps/web/src/components/ai-elements/renderers/components/data-table.tsx b/apps/web/src/components/ai-elements/renderers/components/data-table.tsx index 31ddf7a83e..24a0ed95cf 100644 --- a/apps/web/src/components/ai-elements/renderers/components/data-table.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/data-table.tsx @@ -1,3 +1,4 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import type { RendererComponentProps } from "./types" interface DataTableProps { @@ -22,28 +23,26 @@ export function DataTable({ props }: RendererComponentProps) {
{title &&

{title}

}
- - - +
+ + {headers.map((h) => ( - + {h} ))} - - - + + + {rows.map((row, i) => ( - + {row.map((cell, j) => ( - + ))} - + ))} - -
- {h} -
+ {maybeFormatNumber(cell)} -
+ +
) diff --git a/apps/web/src/components/ai-elements/renderers/components/error-list.tsx b/apps/web/src/components/ai-elements/renderers/components/error-list.tsx index c2b6a49d10..7b8dd3082f 100644 --- a/apps/web/src/components/ai-elements/renderers/components/error-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/error-list.tsx @@ -1,4 +1,7 @@ import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { Badge } from "@maple/ui/components/ui/badge" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { TONE_SOFT } from "@maple/ui/lib/tone" import type { RendererComponentProps } from "./types" interface ErrorListProps { @@ -22,20 +25,15 @@ export function ErrorList({ props }: RendererComponentProps) { key={err.errorType} className="flex items-start gap-2 rounded p-1 text-[11px] hover:bg-muted/50" > - - {err.errorType.length > 60 ? `${err.errorType.slice(0, 60)}...` : err.errorType} - - + {err.errorType} + {err.count} - +
{err.affectedServices.slice(0, 2).map((svc) => ( - + {svc} - + ))} {err.affectedServices.length > 2 && ( diff --git a/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx b/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx index 2922ba300c..db2a274a3f 100644 --- a/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/metrics-list.tsx @@ -1,5 +1,6 @@ import type { RendererComponentProps } from "./types" import { Badge } from "@maple/ui/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { formatNumber } from "@maple/ui/lib/format" interface MetricsListProps { @@ -26,7 +27,7 @@ export function MetricsList({ props }: RendererComponentProps)
{summary.map((s) => (
- + {s.metricType} @@ -37,37 +38,34 @@ export function MetricsList({ props }: RendererComponentProps)
)}
- - - - - - - - - - - +
NameTypeServiceUnitData Points
+ + + Name + Type + Service + Unit + Data Points + + + {metrics.map((m) => ( - - - - - - - + + ))} - -
{m.metricName} - + + {m.metricName} + + {m.metricType} - {m.serviceName}{m.metricUnit || "-"} + + {m.serviceName} + {m.metricUnit || "-"} + {formatNumber(m.dataPointCount)} -
+ +
) diff --git a/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx b/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx index 5694fe3339..57c41b5fba 100644 --- a/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/span-tree.tsx @@ -1,3 +1,4 @@ +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import type { RendererComponentProps } from "./types" import { cn } from "@maple/ui/lib/utils" import { formatDuration } from "@maple/ui/lib/format" @@ -65,7 +66,7 @@ export function SpanTree({ props }: RendererComponentProps) { rel="noreferrer" className="font-mono text-primary hover:underline" > - {traceId.slice(0, 12)} +
diff --git a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx index 956f20d310..f4f39ccd99 100644 --- a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx @@ -4,6 +4,9 @@ import { LatencyValue } from "@maple/ui/components/latency-value" import { formatDuration } from "@maple/ui/lib/format" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Badge } from "@maple/ui/components/ui/badge" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" interface TraceListProps { traces: ReadonlyArray<{ @@ -42,56 +45,53 @@ export function TraceList({ props }: RendererComponentProps) {
)}
- - - - - - - - - - - +
Trace IDRoot SpanDurationSpansServices
+ + + Trace ID + Root Span + Duration + Spans + Services + + + {traces.map((trace) => ( - - - - - - - + + ))} - -
+ + - {trace.traceId.slice(0, 12)} + {trace.hasError && ( )} - + + - + 1000 && "text-severity-warn", trace.durationMs > 5000 && "text-severity-error", )} > {formatDuration(trace.durationMs)} - + + {trace.spanCount ?? ""} - + +
{trace.services.slice(0, 3).map((svc) => ( - + {svc} - + ))} {trace.services.length > 3 && ( @@ -99,11 +99,11 @@ export function TraceList({ props }: RendererComponentProps) { )}
-
+ +
) diff --git a/apps/web/src/components/alerts/alert-severity-badge.tsx b/apps/web/src/components/alerts/alert-severity-badge.tsx index eb0c18334f..4f076a6cb7 100644 --- a/apps/web/src/components/alerts/alert-severity-badge.tsx +++ b/apps/web/src/components/alerts/alert-severity-badge.tsx @@ -1,11 +1,12 @@ import type { AlertSeverity } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_BORDER, TONE_SOFT, type Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -const toneBySeverity: Record = { - warning: "border-severity-warn/30 bg-severity-warn/10 text-severity-warn", - critical: "border-destructive/30 bg-destructive/10 text-destructive", -} satisfies Record +const toneBySeverity: Record = { + warning: "warn", + critical: "crit", +} satisfies Record const labelBySeverity: Record = { warning: "Warning", @@ -14,7 +15,7 @@ const labelBySeverity: Record = { export function AlertSeverityBadge({ severity, className }: { severity: AlertSeverity; className?: string }) { return ( - + {labelBySeverity[severity]} ) diff --git a/apps/web/src/components/alerts/destination-card.tsx b/apps/web/src/components/alerts/destination-card.tsx index 4e3e4e0f45..cc372b3fef 100644 --- a/apps/web/src/components/alerts/destination-card.tsx +++ b/apps/web/src/components/alerts/destination-card.tsx @@ -3,6 +3,7 @@ import type { AlertDestinationDocument } from "@maple/domain/http" import { destinationProvider, ProviderLogo } from "@/components/alerts/destination-provider" import { AlertWarningIcon, CheckIcon, DotsVerticalIcon, PencilIcon, TrashIcon } from "@/components/icons" import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" import { @@ -99,10 +100,12 @@ export function DestinationCard({ {destination.lastTestError && ( -
- - {destination.lastTestError} -
+ + + + {destination.lastTestError} + + )} diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx index 4f27d751c0..bd2ec5e34a 100644 --- a/apps/web/src/components/alerts/destination-dialog.tsx +++ b/apps/web/src/components/alerts/destination-dialog.tsx @@ -68,6 +68,8 @@ import { ComboboxList, ComboboxStatus, } from "@maple/ui/components/ui/combobox" +import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { Avatar, AvatarFallback, AvatarImage } from "@maple/ui/components/ui/avatar" import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" @@ -236,14 +238,11 @@ function EmailMemberPicker({ emptyMessage={isLoaded ? "No members found in this workspace." : "Loading members…"} footer={ memberships?.hasNextPage ? ( - + /> ) : undefined } onChange={(memberUserIds) => onFormChange((current) => ({ ...current, memberUserIds }))} @@ -1226,21 +1225,20 @@ export function DestinationDialog({ Delivery -
-
-
Enabled
-
- Disabled destinations stay attached to rules but won't receive - notifications. -
-
- - onFormChange((current) => ({ ...current, enabled })) - } - /> -
+ + onFormChange((current) => ({ ...current, enabled })) + } + /> + } + /> diff --git a/apps/web/src/components/alerts/notifications-section.tsx b/apps/web/src/components/alerts/notifications-section.tsx index d0710c8950..3578756e7b 100644 --- a/apps/web/src/components/alerts/notifications-section.tsx +++ b/apps/web/src/components/alerts/notifications-section.tsx @@ -4,6 +4,7 @@ import { useState, type Dispatch, type SetStateAction } from "react" import { Link } from "@tanstack/react-router" import { ALERT_TEMPLATE_VARIABLES, type AlertDestinationDocument } from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" import { Input } from "@maple/ui/components/ui/input" @@ -204,15 +205,18 @@ export function NotificationsSection({ Insert a variable:
{ALERT_TEMPLATE_VARIABLES.map((variable) => ( - + ))}
diff --git a/apps/web/src/components/analytics/ai/ai-sections.tsx b/apps/web/src/components/analytics/ai/ai-sections.tsx index 3b9089c7eb..fd48800005 100644 --- a/apps/web/src/components/analytics/ai/ai-sections.tsx +++ b/apps/web/src/components/analytics/ai/ai-sections.tsx @@ -27,6 +27,7 @@ import { } from "@/components/icons" import type { WebAnalyticsAiCrawler, WebAnalyticsAiCrawlerFormat } from "@/api/warehouse/web-analytics" import { AiProductIcon } from "./ai-product-icon" +import { Panel, PanelHeader, PanelTitle } from "@maple/ui/components/ui/panel" import { productForCrawler, purposeForCrawler, @@ -68,14 +69,17 @@ export function AiPanel({ }) { const [expanded, setExpanded] = useState(false) return ( -
-
+ + {aside}
: undefined} + >
-
{title}
+ {title} {hint ?
{hint}
: null}
- {aside ?
{aside}
: null} -
+ {children} {expand ? ( <> @@ -105,7 +109,7 @@ export function AiPanel({ ) : null} - + ) } diff --git a/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx b/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx index a053005168..5bbf22132f 100644 --- a/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx +++ b/apps/web/src/components/analytics/ai/analytics-ai-tab.tsx @@ -14,7 +14,7 @@ import { webAnalyticsSummaryResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" import { AnalyticsBreakdownPanel } from "../analytics-breakdown-panel" -import { Delta } from "../analytics-metric-strip" +import { Delta } from "@maple/ui/components/ui/delta" import { DEFAULT_TRAFFIC, type AnalyticsFilterKey, type AnalyticsFilters } from "../filters" import { previousWindow } from "../previous-window" import { Favicon } from "../row-icon" @@ -228,7 +228,7 @@ function AiKpis({ eyebrow="AI visits" value={referrals ? formatNumber(visits) : "—"} spark={spark} - delta={delta === null ? undefined : } + delta={delta === null ? undefined : } subline={ referrals ? products > 0 diff --git a/apps/web/src/components/analytics/analytics-metric-strip.tsx b/apps/web/src/components/analytics/analytics-metric-strip.tsx index 63e8ddf3da..5c772b87a5 100644 --- a/apps/web/src/components/analytics/analytics-metric-strip.tsx +++ b/apps/web/src/components/analytics/analytics-metric-strip.tsx @@ -1,6 +1,6 @@ import { cn } from "@maple/ui/lib/utils" -import { formatPercent } from "@maple/ui/lib/format" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Delta } from "@maple/ui/components/ui/delta" import { StatRailItem } from "@/components/common/stat-rail" import { @@ -98,7 +98,7 @@ function MetricTile({ eyebrow={metric.label} value={available && value !== null ? metric.format(value) : "—"} spark={available ? metric.series(source).map((point) => point.value) : undefined} - delta={delta === null ? undefined : } + delta={delta === null ? undefined : } subline={ available ? metric.subline(source) @@ -115,40 +115,6 @@ function MetricTile({ ) } -/** - * Change against the previous window. - * - * Colour follows *improvement*, not direction: a falling bounce rate is green. - * The arrow keeps pointing the way the number actually moved, so colour is never - * the only thing carrying the meaning. - * - * Plain tinted text rather than a filled chip — the rails elsewhere set their - * deltas in muted mono and colour a word inline when it matters, and a pill here - * would be the loudest thing on a page whose accent is already doing the - * selection rail, the sparklines and the row tints. - */ -export function Delta({ delta, invert }: { delta: number; invert?: boolean }) { - const rose = delta > 0 - const flat = Math.abs(delta) < 0.001 - const good = invert ? !rose : rose - - return ( - - {flat ? "→" : rose ? "↑" : "↓"} - {formatPercent(Math.abs(delta))} - - ) -} - export function AnalyticsMetricStripLoading() { return (
diff --git a/apps/web/src/components/analytics/product-event-trace-samples.tsx b/apps/web/src/components/analytics/product-event-trace-samples.tsx index eebe6948ea..235f5efafe 100644 --- a/apps/web/src/components/analytics/product-event-trace-samples.tsx +++ b/apps/web/src/components/analytics/product-event-trace-samples.tsx @@ -4,6 +4,8 @@ import { productEventTraceSamplesResultAtom } from "@/lib/services/atoms/warehou import { formatTimestampInTimezone } from "@/lib/timezone-format" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { ChartBarTrendUpIcon } from "@/components/icons" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" /** * Recent traces behind one product event. Renders nothing when empty (browser @@ -28,11 +30,11 @@ export function ProductEventTraceSamples({ .onSuccess((response) => { if (response.data.length === 0) return null return ( -
-
+ +

Traces behind “{eventName}”

-
+
    {/* Index included: at-least-once ingest can duplicate a row, and one trace can fire the event from several spans. */} @@ -47,9 +49,7 @@ export function ProductEventTraceSamples({ search={{ spanId: sample.spanId, t: sample.timestamp }} className="flex items-center gap-2 px-3 py-2 text-xs hover:bg-muted focus-visible:bg-muted focus-visible:outline-none" > - - {sample.traceId.slice(0, 8)} - + {sample.serviceName === "" ? null : {sample.serviceName}} {sample.userId || sample.visitorId ? ( @@ -65,20 +65,20 @@ export function ProductEventTraceSamples({ ))}
-
+ ) }) .onError(() => ( -
-
+ +

Traces behind “{eventName}”

-
+

Could not load traces for this event. This is a query failure, not an empty result — reload to try again.

-
+ )) .orElse(() => null) } diff --git a/apps/web/src/components/anomalies/anomaly-format.ts b/apps/web/src/components/anomalies/anomaly-format.ts index 397075f25e..ba75f44720 100644 --- a/apps/web/src/components/anomalies/anomaly-format.ts +++ b/apps/web/src/components/anomalies/anomaly-format.ts @@ -4,6 +4,7 @@ import type { AnomalySignalType, AnomalyTriageStatus, } from "@maple/domain/http" +import { TONE_FILL, TONE_SOFT, TONE_TEXT, type Tone } from "@maple/ui/lib/tone" import { BoltIcon, ChartLineIcon, CircleWarningIcon, FileIcon, PulseIcon } from "@/components/icons" export const SIGNAL_LABEL: Record = { @@ -132,21 +133,19 @@ export interface SeverityTone { readonly text: string } +const severityToneOf = (tone: Tone): SeverityTone => ({ + badge: TONE_SOFT[tone], + accent: TONE_FILL[tone], + text: TONE_TEXT[tone], +}) + export const SEVERITY_TONE: Record<"critical" | "warning" | "resolved", SeverityTone> = { - critical: { - badge: "bg-destructive/10 text-destructive", - accent: "bg-destructive", - text: "text-destructive", - }, - warning: { - badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400", - accent: "bg-amber-500", - text: "text-amber-600 dark:text-amber-400", - }, + critical: severityToneOf("crit"), + warning: severityToneOf("warn"), resolved: { - badge: "bg-muted text-muted-foreground", + badge: TONE_SOFT.neutral, accent: "bg-border/60", - text: "text-muted-foreground", + text: TONE_TEXT.neutral, }, } satisfies Record<"critical" | "warning" | "resolved", SeverityTone> @@ -165,7 +164,10 @@ export const RESOLVE_REASON_LABEL: Record = { export const TRIAGE_STATUS_CHIP: Record = { none: null, - pending: { label: "triaging…", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" }, + pending: { label: "triaging…", tone: TONE_SOFT.warn }, completed: { label: "triaged", tone: "bg-success/10 text-success" }, - skipped: { label: "triage skipped", tone: "bg-muted text-muted-foreground" }, + skipped: { label: "triage skipped", tone: TONE_SOFT.neutral }, } satisfies Record + +/** Keeps the 20px / 11px meta-pill size on anomaly rows and cards (Badge `size="sm"` shrinks on sm+). */ +export const META_CHIP_CLASS = "h-5 gap-1.5 font-normal text-[11px] sm:h-5 sm:text-[11px]" diff --git a/apps/web/src/components/anomalies/anomaly-hero.tsx b/apps/web/src/components/anomalies/anomaly-hero.tsx index 94886a03fc..718c24810d 100644 --- a/apps/web/src/components/anomalies/anomaly-hero.tsx +++ b/apps/web/src/components/anomalies/anomaly-hero.tsx @@ -2,6 +2,8 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { AnomalyIncidentDocument } from "@maple/domain/http" import { cn } from "@maple/ui/lib/utils" +import { PageHero } from "@/components/common/page-hero" + import { formatRelativeTime } from "@maple/ui/lib/time-format" import { deviation, @@ -28,36 +30,42 @@ export function AnomalyHero({ return (
Anomaly -

- {SIGNAL_LABEL[incident.signalType]} - · - {incident.serviceName} -

-

- {isStale ? "Last recorded" : "Observed"}{" "} - - {observed} - {" "} - against a {baseline} 7-day baseline —{" "} - - {dev.label} - - {dev.kind === "sigma" ? " above median" : dev.kind === "percent" ? " vs baseline" : ""}, - threshold {threshold}. - {isStale - ? ` Last triggered ${formatRelativeTime(incident.lastTriggeredAt)}; this stale detector state does not represent current service health.` - : ""} -

+ + {SIGNAL_LABEL[incident.signalType]} + · + {incident.serviceName} + + } + description={ + <> + {isStale ? "Last recorded" : "Observed"}{" "} + + {observed} + {" "} + against a {baseline} 7-day baseline —{" "} + + {dev.label} + + {dev.kind === "sigma" ? " above median" : dev.kind === "percent" ? " vs baseline" : ""}, + threshold {threshold}. + {isStale + ? ` Last triggered ${formatRelativeTime(incident.lastTriggeredAt)}; this stale detector state does not represent current service health.` + : ""} + + } + />
) } diff --git a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx index 977f313f91..8b8bbb28e7 100644 --- a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx +++ b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx @@ -1,5 +1,6 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { MetaChip } from "./meta-chip" +import { Badge } from "@maple/ui/components/ui/badge" +import { META_CHIP_CLASS } from "./anomaly-format" import { Link } from "@tanstack/react-router" import { Result, useAtomValue } from "@/lib/effect-atom" import type { AnomalyIncidentDocument, ErrorIssueId } from "@maple/domain/http" @@ -111,7 +112,7 @@ function LinkedIssueBody({ {shortIssueId(issue.id)} - + {issue.serviceName} - +

{issue.exceptionType || "Unknown error"} diff --git a/apps/web/src/components/anomalies/anomaly-row.tsx b/apps/web/src/components/anomalies/anomaly-row.tsx index f36a40b298..7f57135ce8 100644 --- a/apps/web/src/components/anomalies/anomaly-row.tsx +++ b/apps/web/src/components/anomalies/anomaly-row.tsx @@ -1,4 +1,4 @@ -import { MetaChip } from "./meta-chip" +import { Badge } from "@maple/ui/components/ui/badge" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Link } from "@tanstack/react-router" import type { AnomalyIncidentDocument } from "@maple/domain/http" @@ -6,11 +6,12 @@ import { cn } from "@maple/ui/lib/utils" import { shortIssueId } from "@/components/errors/issue-id" import { LinkIcon } from "@/components/icons" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { deviation, formatSignalValue, isStaleOpenIncident, + META_CHIP_CLASS, SEVERITY_TONE, SIGNAL_ICON, SIGNAL_LABEL, @@ -100,13 +101,16 @@ export function AnomalyRow({ incident, focused = false, onFocus, variant = "defa {!compact ? ( - + ) : null} @@ -116,62 +120,68 @@ export function AnomalyRow({ incident, focused = false, onFocus, variant = "defa {activeFingerprints > 1 ? ( - + ) : null} {incident.reopenCount > 0 ? ( - + ) : null} {triageChip ? ( - + ) : null} {!compact && incident.errorIssueId !== null ? ( - event.stopPropagation()} + event.stopPropagation()} + /> + } className={cn( - "relative z-10 hidden h-5 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-background px-2 text-[11px] text-muted-foreground sm:inline-flex", - "hover:border-border hover:text-foreground", + META_CHIP_CLASS, + "z-10 hidden gap-1 sm:inline-flex hover:border-border hover:text-foreground", )} title="Open linked issue" > {shortIssueId(incident.errorIssueId)} - + ) : null} - - {isStale - ? `last seen ${formatRelativeTime(incident.lastTriggeredAt)}` - : formatRelativeTime(incident.firstTriggeredAt)} - + /> ) } diff --git a/apps/web/src/components/anomalies/anomaly-sidebar.tsx b/apps/web/src/components/anomalies/anomaly-sidebar.tsx index 7b21366290..73be7607b6 100644 --- a/apps/web/src/components/anomalies/anomaly-sidebar.tsx +++ b/apps/web/src/components/anomalies/anomaly-sidebar.tsx @@ -1,9 +1,12 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Link } from "@tanstack/react-router" import type { AnomalyIncidentDocument } from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { cn } from "@maple/ui/lib/utils" +import { RelativeTime } from "@/components/common/relative-time" import { LinkIcon } from "@/components/icons" import { shortIssueId } from "@/components/errors/issue-id" import { formatRelativeTime } from "@maple/ui/lib/time-format" @@ -11,6 +14,7 @@ import { deviation, formatSignalValue, isStaleOpenIncident, + META_CHIP_CLASS, RESOLVE_REASON_LABEL, SEVERITY_TONE, SIGNAL_LABEL, @@ -189,9 +193,11 @@ export function AnomalySidebar({ {shortIssueId(fingerprint.errorIssueId)} ) : ( - - {fingerprint.fingerprintHash.slice(0, 10)} - + )} @@ -203,38 +209,35 @@ export function AnomalySidebar({ ) : null} - - - {formatRelativeTime(incident.firstTriggeredAt)} - + + {incident.reopenCount > 0 && incident.lastReopenedAt !== null ? ( - + - {formatRelativeTime(incident.lastReopenedAt)} + {incident.reopenCount > 1 ? ` (×${incident.reopenCount})` : ""} ) : null} - - - {formatRelativeTime(incident.lastTriggeredAt)} - + + {incident.resolvedAt !== null ? ( - - - {formatRelativeTime(incident.resolvedAt)} - + + ) : null} {incident.resolveReason !== null ? ( @@ -248,14 +251,13 @@ export function AnomalySidebar({ {triageChip ? ( - {triageChip.label} - + ) : (

No AI triage has run for this incident.

)} diff --git a/apps/web/src/components/anomalies/meta-chip.tsx b/apps/web/src/components/anomalies/meta-chip.tsx deleted file mode 100644 index bf800646da..0000000000 --- a/apps/web/src/components/anomalies/meta-chip.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type React from "react" -import { cn } from "@maple/ui/lib/utils" - -/** Neutral bordered meta pill (service, counts, reopen markers) on anomaly rows and cards. */ -export function MetaChip({ className, ...props }: React.ComponentProps<"span">) { - return ( - - ) -} diff --git a/apps/web/src/components/chat/approval-card.tsx b/apps/web/src/components/chat/approval-card.tsx index 01073b781b..48bdbe98db 100644 --- a/apps/web/src/components/chat/approval-card.tsx +++ b/apps/web/src/components/chat/approval-card.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import { Button } from "@maple/ui/components/ui/button" +import { Item, ItemContent, ItemMedia, ItemTitle } from "@maple/ui/components/ui/item" import { ChevronDownIcon, ChevronRightIcon, @@ -36,10 +37,14 @@ export function ApprovalCard({ toolName, input, resolved, onApprove, onDeny }: A return (
-
- - Approval required: {label} -
+ + + + + + Approval required: {label} + +
@@ -73,17 +78,24 @@ export function ApprovalCard({ toolName, input, resolved, onApprove, onDeny }: A
) : (
-
)} diff --git a/apps/web/src/components/chat/approval-renderers/dashboard.tsx b/apps/web/src/components/chat/approval-renderers/dashboard.tsx index 9d554d5bb3..fa49e3f498 100644 --- a/apps/web/src/components/chat/approval-renderers/dashboard.tsx +++ b/apps/web/src/components/chat/approval-renderers/dashboard.tsx @@ -59,7 +59,7 @@ function WidgetRow({ widget }: { widget: NormalizedWidget }) { {widget.source} ) : null} {widget.groupBy ? ( - + {widget.groupBy} ) : null} @@ -183,7 +183,7 @@ export function UpdateDashboardSummary({ input }: ApprovalRendererProps) {
Update dashboard - + {dashboardId}
@@ -221,7 +221,7 @@ export function UpdateDashboardSummary({ input }: ApprovalRendererProps) { } >
- + Replaces entire dashboard
@@ -273,7 +273,7 @@ export function AddDashboardWidgetSummary({ input }: ApprovalRendererProps) {
Add widget - + {dashboardId}
@@ -399,7 +399,7 @@ export function ReorderDashboardWidgetsSummary({ input }: ApprovalRendererProps) Reorder {ids.length || ""} widget{ids.length === 1 ? "" : "s"} - + {dashboardId}
diff --git a/apps/web/src/components/chat/approval-renderers/index.tsx b/apps/web/src/components/chat/approval-renderers/index.tsx index 0e4c9a894a..f2256372ad 100644 --- a/apps/web/src/components/chat/approval-renderers/index.tsx +++ b/apps/web/src/components/chat/approval-renderers/index.tsx @@ -1,3 +1,4 @@ +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { asRecord } from "./parse" import { AddDashboardWidgetSummary, @@ -68,25 +69,20 @@ function KeyValueFallback({ input }: { input: unknown }) { } return ( -
+ {entries.map(([key, value]) => { const formatted = formatValue(value) return ( -
-
{humanize(key)}
-
- {formatted.kind === "scalar" ? ( - {formatted.text} - ) : ( - - JSON · {formatted.chars} chars - - )} -
-
+ + {formatted.kind === "scalar" ? ( + {formatted.text} + ) : ( + JSON · {formatted.chars} chars + )} + ) })} -
+ ) } diff --git a/apps/web/src/components/chat/auto-contexts.ts b/apps/web/src/components/chat/auto-contexts.ts index ff1c4ed7f7..5bc1888e18 100644 --- a/apps/web/src/components/chat/auto-contexts.ts +++ b/apps/web/src/components/chat/auto-contexts.ts @@ -1,3 +1,4 @@ +import { shortId } from "@maple/ui/lib/ids" export type AutoContext = | { kind: "service"; id: string; serviceName: string } | { kind: "trace"; id: string; traceId: string } @@ -32,20 +33,20 @@ export function autoContextDisplay(ctx: AutoContext): AutoContextDisplay { case "service": return { kind: "Service", subject: ctx.serviceName } case "trace": - return { kind: "Trace", subject: `${ctx.traceId.slice(0, 8)}…` } + return { kind: "Trace", subject: shortId(ctx.traceId, "trace", { ellipsis: true }) } case "dashboard": return ctx.widgetId ? { kind: "Dashboard widget", - subject: `${ctx.dashboardId.slice(0, 8)}…/${ctx.widgetId.slice(0, 6)}…`, + subject: `${shortId(ctx.dashboardId, "generic", { length: 8, ellipsis: true })}/${shortId(ctx.widgetId, "generic", { length: 6, ellipsis: true })}`, } - : { kind: "Dashboard", subject: `${ctx.dashboardId.slice(0, 8)}…` } + : { kind: "Dashboard", subject: shortId(ctx.dashboardId, "generic", { length: 8, ellipsis: true }) } case "error_type": return { kind: "Error type", subject: ctx.errorType } case "error_issue": - return { kind: "Error issue", subject: `${ctx.issueId.slice(0, 8)}…` } + return { kind: "Error issue", subject: shortId(ctx.issueId, "generic", { length: 8, ellipsis: true }) } case "alert_rule": - return { kind: "Alert rule", subject: `${ctx.ruleId.slice(0, 8)}…` } + return { kind: "Alert rule", subject: shortId(ctx.ruleId, "generic", { length: 8, ellipsis: true }) } case "host": return { kind: "Host", subject: ctx.hostName } case "container": diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index 12b06e0754..8d19122406 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -33,10 +33,9 @@ import { } from "@/components/ai-elements/prompt-input" import { Suggestions, Suggestion } from "@/components/ai-elements/suggestion" import { DotLoader } from "@/components/ai-elements/dot-loader" -import { Button } from "@maple/ui/components/ui/button" import { trackProduct } from "@/lib/analytics" import { ChatApplyRequest, type AiTriageResult } from "@maple/domain/http" -import { TurnFailureNotice } from "./turn-failure-notice" +import { ChatFailureNotice, TurnFailureNotice } from "./turn-failure-notice" import { ChatEmptyState } from "./chat-empty-state" const DEFAULT_SUGGESTIONS = [ @@ -388,14 +387,10 @@ function InvestigationLead({ ctx }: { ctx: InvestigationContext }) { */ function FailedSendNotice({ failed, onRetry }: { failed: FailedSend; onRetry: (text: string) => void }) { return ( -
- - Message not sent — {failed.error.message} - - -
+ onRetry(failed.message)}> + Message not sent: {failed.error.message} + + ) } diff --git a/apps/web/src/components/chat/chat-page.tsx b/apps/web/src/components/chat/chat-page.tsx index b8d150b88f..5c5b5d2060 100644 --- a/apps/web/src/components/chat/chat-page.tsx +++ b/apps/web/src/components/chat/chat-page.tsx @@ -1,6 +1,7 @@ import { Suspense, useCallback, useEffect, useState } from "react" import { Link } from "@tanstack/react-router" import { useCopy } from "@maple/ui/hooks/use-copy" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { AppSidebar } from "@/components/dashboard/app-sidebar" import { SidebarInset, SidebarProvider, SidebarTrigger } from "@maple/ui/components/ui/sidebar" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -310,9 +311,9 @@ function SharedChatView({
- + {heading} - + Read-only · shared diff --git a/apps/web/src/components/chat/chat-sidebar.tsx b/apps/web/src/components/chat/chat-sidebar.tsx index 4fbf63a0c8..d8eedf180c 100644 --- a/apps/web/src/components/chat/chat-sidebar.tsx +++ b/apps/web/src/components/chat/chat-sidebar.tsx @@ -3,6 +3,7 @@ import type { ChatTab } from "@/hooks/use-chat-tabs" import { cn } from "@maple/ui/lib/utils" import { Button } from "@maple/ui/components/ui/button" import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { DropdownMenu, DropdownMenuContent, @@ -257,9 +258,9 @@ function ChatSidebarRow({ className="min-w-0 flex-1 bg-transparent text-sm outline-none ring-1 ring-ring/50 rounded-sm px-1 -mx-1" /> ) : ( - + {tab.title} - + )} {!isRenaming && ( diff --git a/apps/web/src/components/chat/investigation-attachment-card.tsx b/apps/web/src/components/chat/investigation-attachment-card.tsx index 274faaaf62..c5c0c2d20b 100644 --- a/apps/web/src/components/chat/investigation-attachment-card.tsx +++ b/apps/web/src/components/chat/investigation-attachment-card.tsx @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { shortId as truncateId } from "@maple/ui/lib/ids" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { StatusDot } from "@maple/ui/components/ui/status-dot" import type { InvestigationContext, InvestigationKind } from "./investigation-context" @@ -26,7 +27,7 @@ const STATUS_TONE: Record = { const shortId = (id: string): string => { const segments = id.split("-") - return segments.length > 1 ? segments[segments.length - 1]!.slice(0, 8) : id.slice(0, 8) + return truncateId(segments.length > 1 ? segments[segments.length - 1]! : id, "generic", { length: 8 }) } /** Pinned card above the chat thread — the investigation subject, any kind. */ diff --git a/apps/web/src/components/chat/task-card.tsx b/apps/web/src/components/chat/task-card.tsx index f91331ca8e..7d68a394aa 100644 --- a/apps/web/src/components/chat/task-card.tsx +++ b/apps/web/src/components/chat/task-card.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import { cn } from "@maple/ui/lib/utils" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Sheet, @@ -128,10 +129,13 @@ export function TaskCard({ {budgetExhausted === true ? ( -

- Answered from what it had: the run spent its budget before it finished - searching. -

+ + + Answered from what it had: the run spent its budget before it finished + searching. + + + ) : null}
diff --git a/apps/web/src/components/chat/turn-failure-notice.tsx b/apps/web/src/components/chat/turn-failure-notice.tsx index 5135546579..80c1e11364 100644 --- a/apps/web/src/components/chat/turn-failure-notice.tsx +++ b/apps/web/src/components/chat/turn-failure-notice.tsx @@ -1,5 +1,35 @@ +import type { ReactNode } from "react" +import { cn } from "@maple/ui/lib/utils" +import { Alert, AlertAction, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" +/** The destructive notice above the composer: what failed, and one way to try again. */ +export function ChatFailureNotice({ + children, + actionLabel, + onAction, + truncate = false, +}: { + children: ReactNode + actionLabel: string + onAction: () => void + /** One line, for messages that can run long. */ + truncate?: boolean +}) { + return ( + + + {children} + + + + + + ) +} + interface TurnFailureNoticeProps { readonly error: Error readonly onContinue: () => void @@ -8,14 +38,8 @@ interface TurnFailureNoticeProps { /** An admitted chat turn failed after the user's message reached the server. */ export function TurnFailureNotice({ error, onContinue }: TurnFailureNoticeProps) { return ( -
- Response failed — {error.message} - -
+ + Response failed: {error.message} + ) } diff --git a/apps/web/src/components/chat/widget-fix-attachment-card.tsx b/apps/web/src/components/chat/widget-fix-attachment-card.tsx index 60b44e4338..195bee1c20 100644 --- a/apps/web/src/components/chat/widget-fix-attachment-card.tsx +++ b/apps/web/src/components/chat/widget-fix-attachment-card.tsx @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { shortId as truncateId } from "@maple/ui/lib/ids" import type { WidgetFixContext } from "./widget-fix-context" interface WidgetFixAttachmentCardProps { @@ -9,7 +10,7 @@ interface WidgetFixAttachmentCardProps { const shortId = (id: string): string => { const segments = id.split("-") const last = segments[segments.length - 1] ?? id - return last.slice(0, 8) + return truncateId(last, "generic", { length: 8 }) } export function WidgetFixAttachmentCard({ ctx, className }: WidgetFixAttachmentCardProps) { diff --git a/apps/web/src/components/code-review/code-review-analytics.tsx b/apps/web/src/components/code-review/code-review-analytics.tsx index ceba27f892..24e3a9573b 100644 --- a/apps/web/src/components/code-review/code-review-analytics.tsx +++ b/apps/web/src/components/code-review/code-review-analytics.tsx @@ -5,22 +5,19 @@ import { QueryBuilderBarChart } from "@maple/ui/components/charts" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { cn } from "@maple/ui/lib/utils" +import { Delta, relativeChange } from "@maple/ui/components/ui/delta" +import { Meter } from "@maple/ui/components/ui/meter" +import { Panel, PanelBody, PanelHeader } from "@maple/ui/components/ui/panel" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" -import { ArrowRightIcon, ArrowTrendDownIcon, ArrowTrendUpIcon, GithubIcon } from "@/components/icons" +import { ArrowRightIcon, GithubIcon } from "@/components/icons" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { pickTimeRangeSearch } from "@/components/time-range-picker/search" import { AuthorLabel } from "./author-avatar" import type { CodeReviewSearch } from "./code-review-search" -import { - CATEGORY_LABELS, - bucketIso, - deltaOf, - formatCount, - formatDelta, - formatSpan, - type Delta, -} from "./code-review-format" +import { CATEGORY_LABELS, bucketIso, formatCount, formatSpan } from "./code-review-format" + type VolumeMetric = "pullRequests" | "reviews" type SeverityFilter = "all" | PrReviewSeverity @@ -86,6 +83,12 @@ export function CodeReviewAnalyticsSkeleton() { /* ---------------------------------------------------------------------------------------------- */ +/** Change against the previous window; nothing when there is no baseline to compare with. */ +function changeOf(current: number | null, previous: number | null, invert = false) { + const ratio = current === null || previous === null ? null : relativeChange(current, previous) + return ratio === null ? undefined : +} + function HeadlineStrip({ current, previous, @@ -95,115 +98,66 @@ function HeadlineStrip({ previous: CodeReviewTotals windowLabel: string }) { + const versus = `vs previous ${windowLabel}` return ( -
- + - - - + +
+ 0 ? `in ${current.repositoriesWithFindings} ${current.repositoriesWithFindings === 1 ? "repo" : "repos"}` : undefined } - windowLabel={windowLabel} /> -
+ ) } -function Headline({ - label, - value, - suffix, - delta, - windowLabel, - hint, -}: { - label: string - value: string - suffix?: string - delta?: Delta | null - windowLabel: string - hint?: string -}) { - return ( -
- {label} - - {value} - {suffix ? {suffix} : null} - {delta ? : null} - - - {delta ? `vs previous ${windowLabel}` : null} - -
- ) -} - -/** Colour follows improvement, the arrow follows the number, so colour never carries it alone. */ -function DeltaBadge({ delta }: { delta: Delta }) { - const Icon = delta.change >= 0 ? ArrowTrendUpIcon : ArrowTrendDownIcon - return ( - - - {formatDelta(delta)} - - ) -} /* ---------------------------------------------------------------------------------------------- */ -function Panel({ +function AnalyticsPanel({ title, control, children, footer, - className, }: { title: string control?: ReactNode children: ReactNode footer?: ReactNode - className?: string }) { return ( -
-
-

{title}

- {control} -
-
{children}
- {footer ?
{footer}
: null} -
+ + + {children} + {footer ?
{footer}
: null} +
) } @@ -230,7 +184,7 @@ function VolumeCard({ analytics }: { analytics: CodeReviewAnalytics }) { .slice(0, 5) return ( - - + ) } @@ -289,7 +243,7 @@ function IssuesCard({ analytics, search }: { analytics: CodeReviewAnalytics; sea .slice(0, 5) return ( - - + ) } @@ -388,12 +342,13 @@ function BarList({ {row.label} {formatCount(row.value)}
- - - + + ))} @@ -402,7 +357,7 @@ function BarList({ function CategoriesCard({ analytics }: { analytics: CodeReviewAnalytics }) { return ( - + ({ @@ -411,13 +366,13 @@ function CategoriesCard({ analytics }: { analytics: CodeReviewAnalytics }) { value: row.findings, }))} /> - + ) } function OutcomesCard({ current, analytics }: { current: CodeReviewTotals; analytics: CodeReviewAnalytics }) { return ( - + row.value > 0)} /> - + ) } function AuthorsCard({ analytics }: { analytics: CodeReviewAnalytics }) { return ( - + {analytics.authors.length === 0 ? ( No authors in this window ) : ( - - - - - - - - - +
AuthorPRsIssues
+ + + Author + PRs + Issues + + + {analytics.authors.map((row) => ( - - - - - + + ))} - -
+ + - + + {formatCount(row.pullRequests)} - + + {formatCount(row.findings)} -
+ + + )} -
+ ) } @@ -536,13 +492,19 @@ function SecondaryStats({ current }: { current: CodeReviewTotals }) { }, ] return ( -
+ {stats.map((stat) => ( -
-
{stat.label}
-
{stat.value}
+
+
))} -
+ + ) } diff --git a/apps/web/src/components/code-review/code-review-format.ts b/apps/web/src/components/code-review/code-review-format.ts index 8cff7b3ba7..6e0e4693d7 100644 --- a/apps/web/src/components/code-review/code-review-format.ts +++ b/apps/web/src/components/code-review/code-review-format.ts @@ -5,6 +5,7 @@ import type { PrReviewSeverity, PrReviewSkipReason, } from "@maple/domain/http" +import type { Tone } from "@maple/ui/lib/tone" export const CATEGORY_LABELS = { correctness: "Correctness", @@ -22,12 +23,12 @@ export const SEVERITY_LABELS = { info: "Note", } satisfies Record -/** Text tones for a severity, from the shared severity tokens. */ -export const SEVERITY_TONES = { - critical: "text-[var(--severity-error)]", - warn: "text-[var(--severity-warn)]", - info: "text-muted-foreground", -} satisfies Record +/** Severity onto the shared tone scale; notes stay muted. */ +export const SEVERITY_TONE = { + critical: "crit", + warn: "warn", + info: "neutral", +} satisfies Record export const FINDING_STATUS_LABELS = { open: "Open", @@ -63,31 +64,6 @@ export function formatSpan(seconds: number | null): string { return `${(seconds / 86_400).toFixed(1)}d` } -export interface Delta { - /** Signed fraction, e.g. -0.6 for "60% fewer". */ - readonly change: number - /** Whether the move is the good direction for this number. */ - readonly good: boolean -} - -/** - * Change against the previous window. Null with nothing to compare against: a jump from zero is - * not a percentage anyone can read. - */ -export function deltaOf( - current: number | null, - previous: number | null, - higherIsBetter: boolean, -): Delta | null { - if (current === null || previous === null || previous === 0) return null - const change = (current - previous) / previous - if (!Number.isFinite(change)) return null - return { change, good: change === 0 || change > 0 === higherIsBetter } -} - -export const formatDelta = (delta: Delta) => - `${delta.change > 0 ? "+" : ""}${Math.round(delta.change * 100)}%` - export interface ReviewOutcome { readonly label: string readonly tone: string diff --git a/apps/web/src/components/code-review/code-review-layout.tsx b/apps/web/src/components/code-review/code-review-layout.tsx index bfbe322e7d..11a48693ff 100644 --- a/apps/web/src/components/code-review/code-review-layout.tsx +++ b/apps/web/src/components/code-review/code-review-layout.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" +import { underlineTabClass } from "@/components/common/underline-link-tabs" import { BranchForkIcon, ChartBarIcon, CircleWarningIcon, GearIcon } from "@/components/icons" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" @@ -49,10 +50,9 @@ function CodeReviewTabs({ active, search }: { active: CodeReviewTab; search: Cod search={tab === "settings" ? {} : carried} aria-current={active === tab ? "page" : undefined} className={cn( - "-mb-px flex h-9 items-center gap-[7px] border-b-2 px-3 text-sm transition-colors first:pl-0.5", - active === tab - ? "border-primary font-medium text-foreground [&_svg]:text-primary" - : "border-transparent text-muted-foreground hover:text-foreground [&_svg]:text-muted-foreground", + underlineTabClass(active === tab), + "h-9 gap-[7px] px-3 first:pl-0.5", + active === tab ? "[&_svg]:text-primary" : "[&_svg]:text-muted-foreground", )} > diff --git a/apps/web/src/components/code-review/code-review-settings.tsx b/apps/web/src/components/code-review/code-review-settings.tsx index ff2779f932..144a18ee4b 100644 --- a/apps/web/src/components/code-review/code-review-settings.tsx +++ b/apps/web/src/components/code-review/code-review-settings.tsx @@ -21,7 +21,9 @@ import { DialogPanel, DialogTitle, } from "@maple/ui/components/ui/dialog" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Switch } from "@maple/ui/components/ui/switch" import { toastManager } from "@maple/ui/components/ui/toast" @@ -134,7 +136,7 @@ function ModelSetting({ settings }: { settings: PrReviewOrgSettings }) { ? settings.model : DEFAULT_MODEL - async function handleChange(value: unknown) { + async function handleChange(value: string) { if (value === current) return const model = isModel(value) ? value : undefined setSaving(true) @@ -154,35 +156,60 @@ function ModelSetting({ settings }: { settings: PrReviewOrgSettings }) { } return ( -
-
-
Review model
-
Runs pull request reviews and replies.
-
- { + if (typeof value === "string") onChange(value) + }} + disabled={disabled} + > + - - - - - {DEFAULT_MODEL_LABEL} - {MODEL_OPTIONS.map((model) => ( - - {model.label} - - ))} - - -
+ + + + {DEFAULT_MODEL_LABEL} + {MODEL_OPTIONS.map((model) => ( + + {model.label} + + ))} + + ) } @@ -241,22 +268,26 @@ function RepositoriesSection({ inherited }: { inherited: PrReviewRepositoryConfi ) const enabled = repositories.filter((repo) => repo.prReviewEnabled).length return ( -
-
+ + + Manage GitHub access + + } + > {enabled} of {repositories.length}{" "} {repositories.length === 1 ? "repository" : "repositories"} reviewed - - Manage GitHub access - -
+
    {repositories.map((repo) => ( ))}
-
+ ) }) .render() diff --git a/apps/web/src/components/code-review/review-detail-sheet.tsx b/apps/web/src/components/code-review/review-detail-sheet.tsx index fd809da77e..35c00ebd6d 100644 --- a/apps/web/src/components/code-review/review-detail-sheet.tsx +++ b/apps/web/src/components/code-review/review-detail-sheet.tsx @@ -5,6 +5,7 @@ import { type PrReviewFinding, type PrReviewId, } from "@maple/domain/http" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { @@ -16,11 +17,14 @@ import { SheetTitle, } from "@maple/ui/components/ui/sheet" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { formatRelativeFrom } from "@maple/ui/lib/time-format" import { MessageResponse } from "@/components/ai-elements/message-response" import { ErrorState } from "@/components/common/error-state" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { ExternalLinkIcon } from "@/components/icons" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" @@ -30,7 +34,7 @@ import { CATEGORY_LABELS, FINDING_STATUS_LABELS, SEVERITY_LABELS, - SEVERITY_TONES, + SEVERITY_TONE, SKIP_LABELS, formatCount, formatSpan, @@ -122,7 +126,7 @@ function ReviewDetailContent({ ) : null}{" "} - · head {review.headSha.slice(0, 7)} + · head
Pull request @@ -134,8 +138,8 @@ function ReviewDetailContent({
-
- {outcome.label}} /> + + -
+ {review.status === "skipped" && review.skipReason !== null ? (

@@ -157,9 +161,9 @@ function ReviewDetailContent({

) : null} {problem !== null ? ( -

- {problem} -

+ + {problem} + ) : null} {report !== null ? ( @@ -228,9 +232,11 @@ function ReviewDetailContent({ current ? "bg-muted" : "hover:bg-muted/60", )} > - - {entry.headSha.slice(0, 7)} - + {entryOutcome.label} @@ -262,7 +268,7 @@ function FindingCard({ return (
  • - + {SEVERITY_LABELS[finding.severity]} {CATEGORY_LABELS[finding.category]} @@ -290,12 +296,16 @@ function FindingCard({ const modelLabel = (model: string | null) => model === null ? "–" : (PR_REVIEW_MODELS.find((entry) => entry.id === model)?.label ?? model) -function Stat({ label, value }: { label: string; value: React.ReactNode }) { +/** A dense StatRail tile: sheet-sized value that truncates instead of overflowing. */ +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { return ( -
    -
    {label}
    -
    {value}
    -
    + ) } diff --git a/apps/web/src/components/code-review/review-rules-form.tsx b/apps/web/src/components/code-review/review-rules-form.tsx index a2ec569258..d883c40beb 100644 --- a/apps/web/src/components/code-review/review-rules-form.tsx +++ b/apps/web/src/components/code-review/review-rules-form.tsx @@ -7,12 +7,12 @@ import { PrReviewSeverity, } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" -import { Spinner } from "@maple/ui/components/ui/spinner" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Checkbox } from "@maple/ui/components/ui/checkbox" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { Textarea } from "@maple/ui/components/ui/textarea" @@ -443,19 +443,17 @@ export function ReviewRulesForm({
    ) : ( - + update({ reviewDrafts: checked ? "on" : "off" })} + /> + } + /> )} {error !== null ? ( @@ -482,8 +480,11 @@ export function ReviewRulesForm({ > Reset - diff --git a/apps/web/src/components/common/data-table.tsx b/apps/web/src/components/common/data-table.tsx index 67b49fab49..d92fe55d37 100644 --- a/apps/web/src/components/common/data-table.tsx +++ b/apps/web/src/components/common/data-table.tsx @@ -135,6 +135,8 @@ interface DataTableRootProps { maxHeight?: number | string /** Surface the pinned header sits on. Defaults to the page background; pass `bg-card` inside a card. */ stickySurfaceClass?: string + /** Frame overrides, e.g. `border-y-0` inside a card that already draws borders. */ + className?: string children: React.ReactNode } @@ -143,6 +145,7 @@ function DataTableRoot({ waiting, maxHeight, stickySurfaceClass = "bg-background", + className, children, }: DataTableRootProps) { const scrolls = maxHeight !== undefined @@ -151,7 +154,7 @@ function DataTableRoot({ return (
    - {title} + {title} {adornment} {description} diff --git a/apps/web/src/components/common/stat-rail.tsx b/apps/web/src/components/common/stat-rail.tsx index fcedb35be0..2da71ceb8e 100644 --- a/apps/web/src/components/common/stat-rail.tsx +++ b/apps/web/src/components/common/stat-rail.tsx @@ -31,12 +31,15 @@ export function StatRail({ children, columns = 4, className }: StatRailProps) { interface StatRailItemProps { eyebrow: string - value: string + /** Usually a formatted string; a node for a value with a unit or inline link. */ + value: React.ReactNode tone?: Tone delta?: React.ReactNode /** Top-right slot, e.g. a link out. Takes precedence over `delta`. */ action?: React.ReactNode spark?: ReadonlyArray + /** Overrides the tone-derived sparkline colour (raw CSS colour). */ + sparkColor?: string subline?: React.ReactNode delay?: number /** Drop the reserved sparkline slot so the value spans full width (dense grids with no sparks). */ @@ -78,6 +81,7 @@ export function StatRailItem({ delta, action, spark, + sparkColor, subline, delay, compact, @@ -122,7 +126,7 @@ export function StatRailItem({ {spark && spark.length > 1 ? ( ) : compact ? null : ( diff --git a/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx b/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx index 1002b0f831..d08f53f07a 100644 --- a/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/list-config-panel.tsx @@ -12,7 +12,7 @@ import type { ValueUnit } from "@/components/dashboard-builder/types" import { Switch } from "@maple/ui/components/ui/switch" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { getListPerformanceHints } from "@/lib/query-builder/performance-hints" -import { CircleWarningIcon, GripDotsIcon } from "@/components/icons" +import { GripDotsIcon } from "@/components/icons" import { listWhereClauseWarnings } from "@/lib/query-builder/widget-builder-shared" import { @@ -22,6 +22,8 @@ import { type ListColumnDraft, type ListDataSource, } from "@/lib/query-builder/list-widget-config" +import { WarningList } from "./warning-list" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" // Props interface removed — ListConfigPanel now reads from context @@ -406,16 +408,7 @@ export function ListConfigPanel() { textareaClassName="min-h-[32px] resize-y text-xs" ariaLabel="List filter" /> - {filterWarnings.length > 0 && ( -
    - -
      - {filterWarnings.map((warning) => ( -
    • {warning}
    • - ))} -
    -
    - )} + {(() => { if (listDataSource !== "traces") return null const parsedLimit = Number.parseInt(limit, 10) @@ -430,22 +423,22 @@ export function ListConfigPanel() { return (
    {slow.length > 0 && ( -
    - {slow.map((h) => ( -

    - {h.reason} -

    - ))} -
    + + + {slow.map((h) => ( +

    {h.reason}

    + ))} +
    +
    )} {fast.length > 0 && ( -
    - {fast.map((h) => ( -

    - {h.reason} -

    - ))} -
    + + + {fast.map((h) => ( +

    {h.reason}

    + ))} +
    +
    )}
    ) diff --git a/apps/web/src/components/dashboard-builder/config/query-panel.tsx b/apps/web/src/components/dashboard-builder/config/query-panel.tsx index 04db23fc9c..d24eeadf02 100644 --- a/apps/web/src/components/dashboard-builder/config/query-panel.tsx +++ b/apps/web/src/components/dashboard-builder/config/query-panel.tsx @@ -23,7 +23,6 @@ import { DocsLink } from "@/components/common/docs-link" import { useSignalPresence } from "@/hooks/use-signal-presence" import { WhereClauseEditor } from "@/components/query-builder/where-clause-editor" import { useMetricScopedAutocomplete } from "@/hooks/use-metric-scoped-autocomplete" -import { CircleWarningIcon } from "@/components/icons" import type { WhereClauseAutocompleteValues } from "@/lib/query-builder/where-clause-autocomplete" import { AGGREGATIONS_BY_SOURCE, @@ -35,6 +34,7 @@ import { type QueryBuilderMetricType, type QueryBuilderQueryDraft, } from "@maple/query-engine/query-builder" +import { WarningList } from "./warning-list" // Types @@ -190,16 +190,7 @@ export function QueryPanel({ {query.dataSource === "product_events" && } - {warnings.length > 0 && ( -
    - -
      - {warnings.map((warning) => ( -
    • {warning}
    • - ))} -
    -
    - )} + {/* Add-on toggle bar */} (
    -
    -
    - + + + ${draft.name || "…"} {TYPE_LABELS[draft.type]} - - + + {errors[index] !== null && editingIndex !== index ? ( {errors[index]} ) : ( sourceSummary(draft) )} - -
    - - - - -
    + + + + + + + + + {editingIndex === index && (
    -
    - Include "All" option - - All drops the filter in query builders and expands to every value in SQL. - -
    - { - const { includeAll: _includeAll, ...rest } = variable - onChange(checked ? { ...rest, includeAll: true } : rest) - }} - /> -
    + Include "All" option} + description="All drops the filter in query builders and expands to every value in SQL." + control={ + { + const { includeAll: _includeAll, ...rest } = variable + onChange(checked ? { ...rest, includeAll: true } : rest) + }} + /> + } + /> )}
    ) diff --git a/apps/web/src/components/dashboard-builder/config/warning-list.tsx b/apps/web/src/components/dashboard-builder/config/warning-list.tsx new file mode 100644 index 0000000000..1e9863f171 --- /dev/null +++ b/apps/web/src/components/dashboard-builder/config/warning-list.tsx @@ -0,0 +1,19 @@ +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { CircleWarningIcon } from "@/components/icons" + +/** Compact warning callout for query and filter validation messages. */ +export function WarningList({ warnings }: { warnings: ReadonlyArray }) { + if (warnings.length === 0) return null + return ( + + + +
      + {warnings.map((warning) => ( +
    • {warning}
    • + ))} +
    +
    +
    + ) +} diff --git a/apps/web/src/components/dashboard-builder/history/preview-banner.tsx b/apps/web/src/components/dashboard-builder/history/preview-banner.tsx index 7ac5f861b1..1b1164c151 100644 --- a/apps/web/src/components/dashboard-builder/history/preview-banner.tsx +++ b/apps/web/src/components/dashboard-builder/history/preview-banner.tsx @@ -86,8 +86,8 @@ export function PreviewBanner({ dashboardId, preview, onCancel, onRestored }: Pr }>Cancel - diff --git a/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx b/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx index cd50535092..964e943df2 100644 --- a/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx +++ b/apps/web/src/components/dashboard-builder/list/dashboard-list.tsx @@ -8,17 +8,8 @@ import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" + import { DropdownMenu, DropdownMenuContent, @@ -513,37 +504,26 @@ export function DashboardList({ }} /> - { if (!open) setPendingDelete(null) }} - > - - - - - - Delete “{pendingDelete?.name}”? - - This dashboard belongs to the whole org — deleting it removes it for everyone, not - just you. It can’t be undone. Export the JSON first if you might want it back. - - - - Cancel - { - if (pendingDelete) onDelete(pendingDelete.id) - setPendingDelete(null) - }} - > - Delete for everyone - - - - + icon={} + title={<>Delete “{pendingDelete?.name}”?} + description={ + <> + This dashboard belongs to the whole org: deleting it removes it for everyone, not + just you. It can’t be undone. Export the JSON first if you might want it back. + + } + confirmLabel="Delete for everyone" + onConfirm={() => { + if (pendingDelete) onDelete(pendingDelete.id) + setPendingDelete(null) + }} + /> +
    ) } diff --git a/apps/web/src/components/dashboard-builder/sections/section-dialogs.tsx b/apps/web/src/components/dashboard-builder/sections/section-dialogs.tsx index c004fd9685..80d9acc42a 100644 --- a/apps/web/src/components/dashboard-builder/sections/section-dialogs.tsx +++ b/apps/web/src/components/dashboard-builder/sections/section-dialogs.tsx @@ -1,14 +1,4 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { TrashIcon } from "@/components/icons" // Both destructive section actions offer the same shape of choice: keep the @@ -38,32 +28,22 @@ export function DeleteSectionDialog({ } return ( - - - - - - - Delete “{sectionTitle}”? - - {widgetCount === 0 - ? "This group is empty, so nothing else will be removed." - : `This group holds ${widgetCount === 1 ? "1 widget" : `${widgetCount} widgets`}. Keep them on the dashboard, or delete them with the group.`} - - - - Cancel - {widgetCount > 0 && ( - confirm("ungroup")}> - Keep widgets - - )} - confirm("delete")}> - {widgetCount === 0 ? "Delete group" : "Delete group & widgets"} - - - - + } + title={<>Delete “{sectionTitle}”?} + description={ + widgetCount === 0 + ? "This group is empty, so nothing else will be removed." + : `This group holds ${widgetCount === 1 ? "1 widget" : `${widgetCount} widgets`}. Keep them on the dashboard, or delete them with the group.` + } + secondaryAction={ + widgetCount > 0 ? { label: "Keep widgets", onClick: () => confirm("ungroup") } : undefined + } + confirmLabel={widgetCount === 0 ? "Delete group" : "Delete group & widgets"} + onConfirm={() => confirm("delete")} + /> ) } @@ -91,31 +71,23 @@ export function DeleteTabDialog({ } return ( - - - - - - - Delete tab “{tabTitle}”? - - {widgetCount === 0 - ? "This tab is empty, so nothing else will be removed." - : `This tab holds ${widgetCount === 1 ? "1 widget" : `${widgetCount} widgets`}.`} - - - - Cancel - {widgetCount > 0 && ( - confirm("move")}> - Move to “{destinationTitle}” - - )} - confirm("delete")}> - {widgetCount === 0 ? "Delete tab" : "Delete tab & widgets"} - - - - + } + title={<>Delete tab “{tabTitle}”?} + description={ + widgetCount === 0 + ? "This tab is empty, so nothing else will be removed." + : `This tab holds ${widgetCount === 1 ? "1 widget" : `${widgetCount} widgets`}.` + } + secondaryAction={ + widgetCount > 0 + ? { label: <>Move to “{destinationTitle}”, onClick: () => confirm("move") } + : undefined + } + confirmLabel={widgetCount === 0 ? "Delete tab" : "Delete tab & widgets"} + onConfirm={() => confirm("delete")} + /> ) } diff --git a/apps/web/src/components/dashboard-builder/templates/template-list.tsx b/apps/web/src/components/dashboard-builder/templates/template-list.tsx index b1c6b89e52..21e21912bc 100644 --- a/apps/web/src/components/dashboard-builder/templates/template-list.tsx +++ b/apps/web/src/components/dashboard-builder/templates/template-list.tsx @@ -3,7 +3,7 @@ import type { V2DashboardTemplate } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" import { ToolbarSearch } from "@maple/ui/components/toolbar" import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { cn } from "@maple/ui/lib/utils" import { @@ -188,11 +188,7 @@ export function TemplateList({
    {loading ? ( -
    - {Array.from({ length: 8 }, (_, index) => ( - - ))} -
    + ) : nothingMatched && !searching ? ( diff --git a/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx b/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx index ef765c18e5..ea5576c3c5 100644 --- a/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx +++ b/apps/web/src/components/dashboard-builder/toolbar/share-dashboard-dialog.tsx @@ -24,6 +24,7 @@ import { LockIcon, ShieldIcon, } from "@/components/icons" +import { Alert } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { Dialog, @@ -344,12 +345,13 @@ export function ShareLinkRow({
    {confirmingReplace ? ( -
    -

    + +

    Replace this link?{" "} {replaceWarning} This can't be undone.

    + @@ -364,7 +366,7 @@ export function ShareLinkRow({ Replace link
    -
    + ) : null} {copyBlocked ? (

    diff --git a/apps/web/src/components/dashboard-builder/widgets/list-widget.tsx b/apps/web/src/components/dashboard-builder/widgets/list-widget.tsx index a73220fa5b..1ce39105c8 100644 --- a/apps/web/src/components/dashboard-builder/widgets/list-widget.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/list-widget.tsx @@ -1,6 +1,7 @@ import { memo } from "react" import { Link } from "@tanstack/react-router" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { shortId } from "@maple/ui/lib/ids" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { cn } from "@maple/ui/lib/utils" import { WidgetEmptyState, WidgetFrame } from "@/components/dashboard-builder/widgets/widget-shell" @@ -51,11 +52,7 @@ export const ListWidget = memo(function ListWidget({ dataState, display, mode }: mode={mode} contentClassName="flex-1 min-h-0 overflow-auto p-0" loadingSkeleton={ -

    - {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
    + } > {rows.length === 0 ? ( @@ -89,7 +86,7 @@ export const ListWidget = memo(function ListWidget({ dataState, display, mode }: let content: React.ReactNode = displayValue if (col.field === "traceId" && typeof value === "string" && value) { - const truncated = value.length > 8 ? value.slice(0, 8) : value + const truncated = shortId(value, "trace") const tsRaw = row.timestamp ?? row.startTime const t = typeof tsRaw === "string" ? tsRaw : undefined content = ( diff --git a/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx b/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx index 150b389288..93d05a64fa 100644 --- a/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/table-widget.tsx @@ -1,6 +1,6 @@ import { memo } from "react" import { cn } from "@maple/ui/lib/utils" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { WidgetEmptyState, WidgetFrame } from "@/components/dashboard-builder/widgets/widget-shell" import type { WidgetDataState, WidgetDisplayConfig, WidgetMode } from "@/components/dashboard-builder/types" @@ -166,11 +166,7 @@ export const TableWidget = memo(function TableWidget({ contentClassName="flex-1 min-h-0 overflow-auto p-0" footer={truncated ? `Top ${rowLimit} rows` : undefined} loadingSkeleton={ -
    - {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
    + } > {rows.length === 0 ? ( diff --git a/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx b/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx index 3da719b1e2..58cb3d1225 100644 --- a/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/types/preset-preview.tsx @@ -1,4 +1,5 @@ import { getChartById } from "@maple/ui/components/charts/registry" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { ChartPreview } from "@/components/dashboard-builder/widgets/chart-preview" import { formatCellValue } from "@/components/dashboard-builder/widgets/table-widget" @@ -51,37 +52,37 @@ export function rowsPresetPreview(sampleRows: Record - - - +
    + + {columns.map((column) => ( - + ))} - - - + + + {rows.map((row, index) => ( // eslint-disable-next-line react/no-array-index-key -- fixed sample data - + {columns.map((column) => ( - + ))} - + ))} - -
    {column.header} -
    {formatCellValue(row[column.field], column.unit)} -
    + + ) } diff --git a/apps/web/src/components/dashboard/service-health-section.tsx b/apps/web/src/components/dashboard/service-health-section.tsx index 8392253bc3..6f80839c8a 100644 --- a/apps/web/src/components/dashboard/service-health-section.tsx +++ b/apps/web/src/components/dashboard/service-health-section.tsx @@ -19,9 +19,10 @@ import type { AlertIncidentDocument, AnomalySignalType } from "@maple/domain/htt import { Card } from "@maple/ui/components/ui/card" import { Badge } from "@maple/ui/components/ui/badge" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" import { latencyToneClass } from "@maple/ui/lib/latency-tone" +import { TONE_FILL } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { @@ -29,6 +30,7 @@ import { anomalyDirection, healthRank, primaryServiceHealthCause, + HEALTH_TONE, type ServiceHealthCause, type ServiceHealth, } from "./service-health" @@ -93,12 +95,6 @@ const ANOMALY_METRIC: Partial> -const HEALTH_DOT_COLOR: Record = { - healthy: "var(--severity-info)", - degraded: "var(--severity-warn)", - unhealthy: "var(--severity-error)", -} satisfies Record - function metricTone(cause: ServiceHealthCause | undefined): "ok" | "warn" | "crit" { return cause === undefined ? "ok" : cause.severity === "critical" ? "crit" : "warn" } @@ -312,11 +308,7 @@ export function ServiceHealthList(props: ServiceHealthProps) {
    {header} -
    - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
    +
    )) @@ -395,7 +387,7 @@ function ServiceHealthRow({ search={{ ...detailSearch, environments: [service.environment] }} className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" > - +
    diff --git a/apps/web/src/components/dashboard/service-health.ts b/apps/web/src/components/dashboard/service-health.ts index 470d67cf22..30822fcee1 100644 --- a/apps/web/src/components/dashboard/service-health.ts +++ b/apps/web/src/components/dashboard/service-health.ts @@ -1,9 +1,17 @@ import type { ServiceLatencyBaseline } from "@/api/warehouse/services" import type { AnomalySignalType } from "@maple/domain/http" +import type { Tone } from "@maple/ui/lib/tone" /** Health rollup for a single service. */ export type ServiceHealth = "healthy" | "degraded" | "unhealthy" +/** The one health-to-colour mapping: dots, tints and legends key off this. */ +export const HEALTH_TONE: Record = { + healthy: "ok", + degraded: "warn", + unhealthy: "crit", +} satisfies Record + export interface ServiceHealthCause { severity: "warning" | "critical" label: string diff --git a/apps/web/src/components/dashboard/service-usage-cards.tsx b/apps/web/src/components/dashboard/service-usage-cards.tsx index 44e83dbaa1..d2e17e71fb 100644 --- a/apps/web/src/components/dashboard/service-usage-cards.tsx +++ b/apps/web/src/components/dashboard/service-usage-cards.tsx @@ -1,28 +1,26 @@ import { formatNumber, formatStorageBytes } from "@maple/ui/lib/format" import { Result } from "@/lib/effect-atom" -import { FileIcon, GridSquareCirclePlusIcon, ChartLineIcon, DatabaseIcon } from "@/components/icons" - -import { Card, CardContent, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { Delta } from "@maple/ui/components/ui/delta" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { getServiceUsageResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import type { ServiceUsageResponse, ServiceUsageTotals } from "@/api/warehouse/service-usage" import { normalizeTimestampInput } from "@/lib/timezone-format" import { formatWarehouseDateTime } from "@maple/query-engine" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" + type CardKey = "logs" | "traces" | "metrics" | "dataSize" const cardConfig: Array<{ title: string key: CardKey - icon: typeof FileIcon format: (n: number) => string }> = [ - { title: "Total Logs", key: "logs", icon: FileIcon, format: formatNumber }, - { title: "Total Traces", key: "traces", icon: GridSquareCirclePlusIcon, format: formatNumber }, - { title: "Total Metrics", key: "metrics", icon: ChartLineIcon, format: formatNumber }, - { title: "Data Size", key: "dataSize", icon: DatabaseIcon, format: formatStorageBytes }, + { title: "Total Logs", key: "logs", format: formatNumber }, + { title: "Total Traces", key: "traces", format: formatNumber }, + { title: "Total Metrics", key: "metrics", format: formatNumber }, + { title: "Data Size", key: "dataSize", format: formatStorageBytes }, ] interface ServiceUsageCardsProps { @@ -58,32 +56,6 @@ function shiftRangeBack(startTime?: string, endTime?: string) { } } -function DeltaChip({ current, previous }: { current: number; previous: number }) { - if (previous <= 0 || !Number.isFinite(previous)) { - return – - } - const pct = ((current - previous) / previous) * 100 - if (!Number.isFinite(pct)) { - return – - } - const rounded = Math.abs(pct) < 0.05 ? 0 : pct - const up = rounded > 0 - const neutral = rounded === 0 - const arrow = neutral ? "·" : up ? "↑" : "↓" - const color = neutral - ? "text-muted-foreground/70" - : up - ? "text-[color:var(--severity-info)]" - : "text-[color:var(--severity-warn)]" - return ( - - {arrow} - {Math.abs(rounded).toFixed(rounded === 0 ? 0 : 1)}% - vs prev - - ) -} - export function ServiceUsageCards({ startTime, endTime }: ServiceUsageCardsProps = {}) { // Current + previous totals come back in ONE request (sumIf over the union // window) instead of two separate per-period queries. @@ -99,83 +71,40 @@ export function ServiceUsageCards({ startTime, endTime }: ServiceUsageCardsProps .orElse(() => null as null | ServiceUsageTotals) return Result.builder(responseResult) - .onInitial(() => ( -
    - {cardConfig.map((card) => ( - - - - - - - - - - ))} -
    - )) + .onInitial(() => ) .onError(() => ( - // Quiet absence, not four more error blocks — the chart panels below - // already carry the full message. Keep the loaded state's card rhythm - // with an em-dash in the value slot. -
    + // Quiet absence, not four more error blocks: the chart panels below + // already carry the full message. + {cardConfig.map((card) => ( - - - }> - {card.title} - - - - -
    - — -
    -
    - Couldn't load -
    -
    -
    + ))} -
    + )) .onSuccess((response) => { const totals = sumTotals(response) - return ( -
    + {cardConfig.map((card) => { const current = totals[card.key] const previous = previousTotals?.[card.key] return ( - - - }> - {card.title} - - - - -
    -
    - {card.format(current)} -
    -
    -
    - {previous !== undefined ? ( - - ) : ( - - )} -
    -
    -
    + + ) : ( + + ) + } + /> ) })} -
    + ) }) .render() diff --git a/apps/web/src/components/errors/errors-hub-view.tsx b/apps/web/src/components/errors/errors-hub-view.tsx index 18366fee75..645d1a50c3 100644 --- a/apps/web/src/components/errors/errors-hub-view.tsx +++ b/apps/web/src/components/errors/errors-hub-view.tsx @@ -4,6 +4,8 @@ import { useNavigate } from "@tanstack/react-router" import type { ErrorIssueId, WorkflowState } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" +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" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { cn } from "@maple/ui/lib/utils" @@ -358,11 +360,11 @@ export function ErrorsHubView({ known, it explains what is loading, and keeping it means the rows land in the columns the eye is already resting on. */} -
    - {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( - - ))} -
    + } + className="gap-0 divide-y divide-border/40" + />
    ) } @@ -630,11 +632,11 @@ function HubPagingFooter({ paging }: { paging: HubPaging }) { if (paging.state === "loading") { return ( -
    - {Array.from({ length: LOAD_MORE_SKELETON_ROWS }).map((_, index) => ( - - ))} -
    + } + className="gap-0 divide-y divide-border/40 border-t border-border/40" + /> ) } @@ -643,9 +645,10 @@ function HubPagingFooter({ paging }: { paging: HubPaging }) { {paging.state === "failed" ? ( More errors could not be loaded. ) : null} - +
    ) } diff --git a/apps/web/src/components/errors/issue-detail-skeleton.tsx b/apps/web/src/components/errors/issue-detail-skeleton.tsx index 6be3622a6c..e20e7fd91b 100644 --- a/apps/web/src/components/errors/issue-detail-skeleton.tsx +++ b/apps/web/src/components/errors/issue-detail-skeleton.tsx @@ -3,7 +3,7 @@ import { IssueField } from "./issue-field" import type { ErrorIssueId } from "@maple/domain/http" import { Button } from "@maple/ui/components/ui/button" import { DetailRail } from "@maple/ui/components/detail-rail" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { PulseIcon } from "@/components/icons" import { DashboardLayout } from "@/components/layout/dashboard-layout" @@ -147,11 +147,7 @@ function ListSkeleton() { return (
    -
    - {Array.from({ length: 6 }, (_, index) => ( - - ))} -
    +
    ) } diff --git a/apps/web/src/components/errors/issue-fact-strip.tsx b/apps/web/src/components/errors/issue-fact-strip.tsx index 20d2cc1b7e..047557e85e 100644 --- a/apps/web/src/components/errors/issue-fact-strip.tsx +++ b/apps/web/src/components/errors/issue-fact-strip.tsx @@ -3,8 +3,7 @@ import type { ErrorIssueDocument } from "@maple/domain/http" import { formatNumber } from "@maple/ui/lib/format" import { formatRelativeTime } from "@maple/ui/lib/time-format" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" +import { RelativeTime } from "@/components/common/relative-time" /** * The issue's numbers, in named lanes, at the top of the page. @@ -91,14 +90,5 @@ function Count({ value }: { value: number }) { } function Stamp({ iso }: { iso: string }) { - const { effectiveTimezone } = useTimezonePreference() - - return ( - - {formatRelativeTime(iso)} - - ) + return } diff --git a/apps/web/src/components/errors/issue-incidents-table.tsx b/apps/web/src/components/errors/issue-incidents-table.tsx index 3978a63c98..286ca52436 100644 --- a/apps/web/src/components/errors/issue-incidents-table.tsx +++ b/apps/web/src/components/errors/issue-incidents-table.tsx @@ -4,10 +4,8 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/comp import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Tooltip, TooltipPopup, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeTime } from "@maple/ui/lib/time-format" -import { formatTimestampInTimezone } from "@/lib/timezone-format" import { DocsLink } from "@/components/common/docs-link" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { RelativeTime } from "@/components/common/relative-time" interface IssueIncidentsTableProps { incidents: ReadonlyArray @@ -28,8 +26,6 @@ const STATUS_EXPLANATION = { } satisfies Record export function IssueIncidentsTable({ incidents }: IssueIncidentsTableProps) { - const { effectiveTimezone } = useTimezonePreference() - if (incidents.length === 0) { return ( @@ -97,23 +93,11 @@ export function IssueIncidentsTable({ incidents }: IssueIncidentsTableProps) { {REASON_LABEL[incident.reason]} - - {formatRelativeTime(incident.firstTriggeredAt)} + + - - {formatRelativeTime(incident.lastTriggeredAt)} + + {incident.occurrenceCount.toLocaleString()} diff --git a/apps/web/src/components/errors/issue-notes-callout.tsx b/apps/web/src/components/errors/issue-notes-callout.tsx index d708329ef0..15b1f3d1c7 100644 --- a/apps/web/src/components/errors/issue-notes-callout.tsx +++ b/apps/web/src/components/errors/issue-notes-callout.tsx @@ -1,3 +1,4 @@ +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { cn } from "@maple/ui/lib/utils" @@ -8,11 +9,11 @@ interface IssueNotesCalloutProps { export function IssueNotesCallout({ notes, className }: IssueNotesCalloutProps) { return ( -
    - + + Notes -
    {notes}
    -
    + {notes} + ) } diff --git a/apps/web/src/components/errors/issue-occurrences-table.tsx b/apps/web/src/components/errors/issue-occurrences-table.tsx index cfe5c5297e..1d0fb4212a 100644 --- a/apps/web/src/components/errors/issue-occurrences-table.tsx +++ b/apps/web/src/components/errors/issue-occurrences-table.tsx @@ -3,9 +3,8 @@ import type { ErrorIssueSampleTrace } from "@maple/domain/http" import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeTime } from "@maple/ui/lib/time-format" -import { formatTimestampInTimezone } from "@/lib/timezone-format" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { RelativeTime } from "@/components/common/relative-time" import { ServiceDot } from "@maple/ui/components/service-dot" interface IssueOccurrencesTableProps { @@ -13,8 +12,6 @@ interface IssueOccurrencesTableProps { } export function IssueOccurrencesTable({ traces }: IssueOccurrencesTableProps) { - const { effectiveTimezone } = useTimezonePreference() - if (traces.length === 0) { return ( @@ -42,14 +39,8 @@ export function IssueOccurrencesTable({ traces }: IssueOccurrencesTableProps) { {traces.map((trace) => ( - - {formatRelativeTime(trace.timestamp)} + + @@ -69,7 +60,7 @@ export function IssueOccurrencesTable({ traces }: IssueOccurrencesTableProps) { "transition-colors hover:border-primary/40 hover:bg-primary/10 hover:text-primary", )} > - {trace.traceId.slice(0, 12)}… + diff --git a/apps/web/src/components/errors/issue-pull-requests-panel.tsx b/apps/web/src/components/errors/issue-pull-requests-panel.tsx index 2b156c5439..cc1d980ea1 100644 --- a/apps/web/src/components/errors/issue-pull-requests-panel.tsx +++ b/apps/web/src/components/errors/issue-pull-requests-panel.tsx @@ -1,6 +1,8 @@ import type { ErrorIssuePullRequestDocument } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { Item, ItemContent, ItemDescription, ItemMedia } from "@maple/ui/components/ui/item" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { cn } from "@maple/ui/lib/utils" import { DocsLink } from "@/components/common/docs-link" @@ -48,20 +50,22 @@ export function IssuePullRequestsPanel({ onOpenChange: (open: boolean) => void }) { return ( -
    -
    -

    Pull requests

    - -
    + + onOpenChange(true)} + disabled={busy} + > + + Attach + + } + /> {pullRequests.length === 0 ? (
    @@ -76,9 +80,17 @@ export function IssuePullRequestsPanel({ {pullRequests.map((pr) => { const hint = SOURCE_HINT[pr.linkSource] return ( -
  • - -
    + } + variant="flush" + size="lg" + className="group flex-nowrap items-start" + > + + + + {pr.title ? ( -

    - {pr.title} -

    + {pr.title} ) : null} {hint ? ( -

    {hint}

    + {hint} ) : null} -
    + -
  • + ) })} @@ -127,6 +137,6 @@ export function IssuePullRequestsPanel({ onAttach={onLink} busy={busy} /> -
    + ) } diff --git a/apps/web/src/components/errors/issue-timeline.tsx b/apps/web/src/components/errors/issue-timeline.tsx index e38f83d32d..a69e96b6e8 100644 --- a/apps/web/src/components/errors/issue-timeline.tsx +++ b/apps/web/src/components/errors/issue-timeline.tsx @@ -6,6 +6,7 @@ import type { } from "@maple/domain/http" import { formatRelativeTime } from "@maple/ui/lib/time-format" import type { ReactNode } from "react" +import { Badge } from "@maple/ui/components/ui/badge" import { cn } from "@maple/ui/lib/utils" import { MessageResponse } from "@/components/ai-elements/message-response" @@ -444,12 +445,15 @@ function MessageRow({ {identity?.name ?? "Unknown"} {isAgent ? ( - {identity?.detail ?? "agent"} - + ) : null} {verb ? {verb} : null} diff --git a/apps/web/src/components/errors/lease-hud.tsx b/apps/web/src/components/errors/lease-hud.tsx index 465e6f4b92..f364043b8b 100644 --- a/apps/web/src/components/errors/lease-hud.tsx +++ b/apps/web/src/components/errors/lease-hud.tsx @@ -2,6 +2,7 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { StatusDot } from "@maple/ui/components/ui/status-dot" import * as React from "react" import type { ActorDocument } from "@maple/domain/http" +import { formatCountdown } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { normalizeTimestampInput } from "@/lib/timezone-format" import { ActorChip } from "./actor-chip" @@ -121,15 +122,3 @@ export function LeaseHud({ leaseExpiresAt, claimedAt, leaseHolder, className }: ) } - -function formatCountdown(ms: number): string { - if (ms <= 0) return "0:00" - const totalSeconds = Math.ceil(ms / 1000) - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` - } - return `${minutes}:${String(seconds).padStart(2, "0")}` -} diff --git a/apps/web/src/components/errors/signal-state-chip.tsx b/apps/web/src/components/errors/signal-state-chip.tsx index 69a9a2f543..4b38dc1e03 100644 --- a/apps/web/src/components/errors/signal-state-chip.tsx +++ b/apps/web/src/components/errors/signal-state-chip.tsx @@ -134,7 +134,7 @@ export function InvestigationChip({ : `Maple ${label.toLowerCase()}` } > - + {label} {withConfidence && investigation.confidence && investigation.status === "diagnosed" ? ( · {investigation.confidence} diff --git a/apps/web/src/components/header/onboarding-checklist-button.tsx b/apps/web/src/components/header/onboarding-checklist-button.tsx index 5cbe30dbd2..c16a3639c2 100644 --- a/apps/web/src/components/header/onboarding-checklist-button.tsx +++ b/apps/web/src/components/header/onboarding-checklist-button.tsx @@ -2,8 +2,12 @@ import { useState } from "react" import { Link } from "@tanstack/react-router" import type { V2OnboardingChecklist, V2OnboardingChecklistStep } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" +import { Meter } from "@maple/ui/components/ui/meter" +import { Badge } from "@maple/ui/components/ui/badge" import { Popover, PopoverPopup, PopoverTrigger } from "@maple/ui/components/ui/popover" +import { formatCountdown as formatRemaining } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" + import { ArrowRightIcon, CircleCheckIcon, @@ -17,17 +21,12 @@ import { useOnboardingChecklist } from "@/hooks/use-onboarding-checklist" import { parseSearchFromHref } from "@/lib/href" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" -const pad = (value: number) => value.toString().padStart(2, "0") - -/** `23:41:07` — the pill's live countdown; clamps at `00:00:00`. */ +/** `23:41:07`, the pill's live countdown; clamps at `00:00:00`. */ export function formatCountdown(deadlineMs: number, nowMs: number): string { - const remaining = Math.max(0, Math.floor((deadlineMs - nowMs) / 1000)) - const hours = Math.floor(remaining / 3600) - const minutes = Math.floor((remaining % 3600) / 60) - const seconds = remaining % 60 - return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + return formatRemaining(deadlineMs - nowMs, { fixedHours: true }) } + const formatCredits = (checklist: Pick) => `$${checklist.reward_amount_usd}` @@ -131,14 +130,17 @@ function OnboardingChecklistPill() { /** `2/5` as a small chip: the count is the whole message, so it is written out. */ function ProgressChip({ completed, total }: { completed: number; total: number }) { return ( - {completed}/{total} - + ) } @@ -239,12 +241,7 @@ export function OnboardingChecklistPanel({

    -
    -
    -
    +
      @@ -272,8 +269,8 @@ export function OnboardingChecklistPanel({ > {claimError ?? "Every step is done."} - ) : ( diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx index e1432fc096..49ff3f825a 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-breakdown-panel.tsx @@ -42,12 +42,15 @@ import { CompactFilterInput } from "./compact-filter-input" import { ColumnHead, DataTable, useTableSort } from "@/components/common/data-table" import { SegmentPivot } from "../primitives/segment-pivot" import { shareBar } from "../primitives/share-bar" +import { Badge } from "@maple/ui/components/ui/badge" +import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { Panel, PanelHeader, PanelTitle } from "@maple/ui/components/ui/panel" +import { errorRateClass } from "@maple/ui/lib/error-rate" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { StackedBreakdownChart } from "./cloudflare-zone-detail-charts" import { CACHE_STATUS_COLORS, CACHE_STATUS_ORDER, - errorRateClass, STATUS_CLASS_COLORS, STATUS_CLASS_ORDER, } from "./constants" @@ -118,9 +121,6 @@ const DIMENSIONS: ReadonlyArray = [ const ROW_CLASS = "flex items-center gap-4 border-b border-border/40 px-4 py-3 last:border-0 hover:bg-muted/40" -const CHIP_CLASS = - "inline-flex items-center rounded-sm border border-border/70 bg-background/60 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground" - /** ISO-8601 UTC → "Jul 28". */ const formatCollectedFrom = (iso: string, timeZone: string) => { const date = new Date(iso) @@ -188,19 +188,32 @@ export function CloudflareBreakdownPanel({ } return ( -
      -
      + + + ariaLabel="Breakdown dimension" + options={DIMENSIONS.map((d) => ({ value: d.id, label: d.tab }))} + value={dimensionId} + onChange={selectDimension} + /> + } + >
      - Breakdown + Breakdown {live ? ( - + ) : ( )}
      - - ariaLabel="Breakdown dimension" - options={DIMENSIONS.map((d) => ({ value: d.id, label: d.tab }))} - value={dimensionId} - onChange={selectDimension} - /> -
      + {live ? ( setLiveQuery(search.trim())} /> )} -
      + ) } @@ -505,20 +512,22 @@ function Footer({ onSearchLive?: () => void }) { if (total === 0 && onSearchLive === undefined) return null - const parts = [ - shown === total ? `${total} ${dimension.noun}` : `Showing ${shown} of ${total} ${dimension.noun}`, - collectedFrom ? `collected from ${collectedFrom}` : null, - ].filter((part): part is string => part !== null) return ( -
      -

      {parts.join(" · ")}

      + + {collectedFrom ? collected from {collectedFrom} : null} {onSearchLive ? ( - + Search all {dimension.noun} in Cloudflare ) : null} -
      + ) } diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-kpi-cards.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-kpi-cards.tsx index 822274ed74..14b8af0fe5 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-kpi-cards.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-kpi-cards.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react" import type { CloudflareZoneRow, CloudflareZoneTimeseriesRow } from "@/api/warehouse/cloudflare-infra" +import { errorRateLevel } from "@maple/ui/lib/error-rate" import { formatNumber } from "@maple/ui/lib/format" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" @@ -66,7 +67,7 @@ export function CloudflareKpiCards({ zones, buckets }: CloudflareKpiCardsProps) = 0.05 ? "crit" : totals.errorRate >= 0.01 ? "warn" : "neutral"} + tone={errorRateLevel(totals.errorRate)} spark={sparks?.errorRate} /> -
      - {METRIC_LABELS[metric]} -
      + {data.length === 0 ? ( {CHART_EMPTY_MESSAGE} ) : ( @@ -242,6 +241,6 @@ export function CloudflareZoneChart({ /> )} - + ) } diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx index b5bac9c649..a923231e34 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx @@ -7,7 +7,7 @@ import type { CloudflareZoneRow } from "@/api/warehouse/cloudflare-infra" import { formatLatency, formatNumber } from "@maple/ui/lib/format" import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" import { formatBytes, formatPercent } from "@maple/ui/lib/format" -import { errorRateClass } from "./constants" +import { errorRateClass } from "@maple/ui/lib/error-rate" // Zone latency percentiles are plan-dependent (the poller only gets quantiles // on zones whose Cloudflare plan exposes them); 0 means "not available", not diff --git a/apps/web/src/components/infra/cloudflare/constants.ts b/apps/web/src/components/infra/cloudflare/constants.ts index d2c8cbbcd7..2bac651908 100644 --- a/apps/web/src/components/infra/cloudflare/constants.ts +++ b/apps/web/src/components/infra/cloudflare/constants.ts @@ -3,24 +3,6 @@ // palette is shared by the detail breakdown chart and the edge-share band so // the same status never renders in two hues on one page. -import { VALUE_TONE } from "../severity-tokens" - -/** - * Shared 5% / 1% thresholds for tinting 5xx and Worker error rates — one - * source for the tables and the detail stat rail. - */ -export function errorRateTone(rate: number): "crit" | "warn" | "neutral" { - if (rate >= 0.05) return "crit" - if (rate >= 0.01) return "warn" - return "neutral" -} - -/** Error-rate cell tint for the tables: canonical severity tokens above the thresholds, quiet otherwise. */ -export function errorRateClass(rate: number): string { - const tone = errorRateTone(rate) - return tone === "neutral" ? "text-foreground/80" : VALUE_TONE[tone] -} - /** * The list charts plot at most {@link MAX_ZONE_SERIES} zones; the remainder * pools into one "Other zones" series in this muted color (same ramp as the diff --git a/apps/web/src/components/infra/container-summary-band.tsx b/apps/web/src/components/infra/container-summary-band.tsx index f3ff0fe2c7..44bcc01e17 100644 --- a/apps/web/src/components/infra/container-summary-band.tsx +++ b/apps/web/src/components/infra/container-summary-band.tsx @@ -3,6 +3,7 @@ // it isn't a signal there. import { FleetBand, FleetBandLoading } from "./primitives/fleet-band" +import { TONE_FILL } from "@maple/ui/lib/tone" export interface ContainerScopeCounts { readonly totalContainers: number @@ -39,8 +40,8 @@ export function ContainerSummaryBand({ caption="share of the fleet by peak utilization" segments={[ { key: "healthy", count: healthy, className: "bg-muted-foreground/35" }, - { key: "elevated", count: elevatedContainers, className: "bg-[var(--severity-warn)]" }, - { key: "saturated", count: saturatedContainers, className: "bg-[var(--severity-error)]" }, + { key: "elevated", count: elevatedContainers, className: TONE_FILL.warn }, + { key: "saturated", count: saturatedContainers, className: TONE_FILL.crit }, ]} cells={[ { diff --git a/apps/web/src/components/infra/container-table.tsx b/apps/web/src/components/infra/container-table.tsx index 25bda4f061..bbe70c9da7 100644 --- a/apps/web/src/components/infra/container-table.tsx +++ b/apps/web/src/components/infra/container-table.tsx @@ -1,7 +1,6 @@ import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import type { ListContainersResponse } from "@maple/domain/http" import type { ContainerSortKey, SortDirection } from "@/api/warehouse/infra" @@ -10,7 +9,7 @@ import { HostStatusBadge } from "./status-badge" import { ColumnHead, DataTable, ROW_LINK_CLASS } from "@/components/common/data-table" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" export type ContainerRow = ListContainersResponse["data"][number] @@ -173,15 +172,11 @@ export function ContainerTable({ />
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(container.lastSeen)} - - {container.lastSeen} - +
      ))} diff --git a/apps/web/src/components/infra/host-detail-header.tsx b/apps/web/src/components/infra/host-detail-header.tsx index a8424fac96..b36b7515a2 100644 --- a/apps/web/src/components/infra/host-detail-header.tsx +++ b/apps/web/src/components/infra/host-detail-header.tsx @@ -10,7 +10,7 @@ import { HeroChip, PageHero } from "@/components/common/page-hero" import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { severityLevel } from "./format" import { formatLoad, formatPercent } from "@maple/ui/lib/format" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" interface HostDetailHeaderProps { summary: HostDetailSummaryResponse["data"] @@ -59,9 +59,11 @@ export function HostDetailHeader({ summary, hostName, onWidenRange }: HostDetail )} {summary.cloudRegion && region {summary.cloudRegion}} - - last reported {formatRelativeTime(summary.lastSeen)} - + ) diff --git a/apps/web/src/components/infra/host-summary-band.tsx b/apps/web/src/components/infra/host-summary-band.tsx index 6a38e8d794..ab2659b011 100644 --- a/apps/web/src/components/infra/host-summary-band.tsx +++ b/apps/web/src/components/infra/host-summary-band.tsx @@ -4,6 +4,7 @@ import { deriveHostStatus, severityLevel } from "./format" import type { HostRow } from "./host-table" import { FleetBand, FleetBandLoading } from "./primitives/fleet-band" +import { TONE_FILL } from "@maple/ui/lib/tone" export type HostScope = "saturated" | "elevated" | "stale" @@ -59,8 +60,8 @@ export function HostSummaryBand({ caption="share of the fleet by its busiest of CPU, memory and disk" segments={[ { key: "healthy", count: healthy, className: "bg-muted-foreground/35" }, - { key: "elevated", count: elevated, className: "bg-[var(--severity-warn)]" }, - { key: "saturated", count: saturated, className: "bg-[var(--severity-error)]" }, + { key: "elevated", count: elevated, className: TONE_FILL.warn }, + { key: "saturated", count: saturated, className: TONE_FILL.crit }, ]} cells={[ { scope: "saturated", label: "Saturated", hint: "≥90%", value: saturated, tone: "crit" }, diff --git a/apps/web/src/components/infra/host-table.tsx b/apps/web/src/components/infra/host-table.tsx index c29edca312..9c87d31a92 100644 --- a/apps/web/src/components/infra/host-table.tsx +++ b/apps/web/src/components/infra/host-table.tsx @@ -1,7 +1,6 @@ import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import type { ListHostsResponse } from "@maple/domain/http" @@ -17,7 +16,7 @@ import { useTableSort, } from "@/components/common/data-table" import { formatLoad } from "@maple/ui/lib/format" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" export type HostRow = ListHostsResponse["data"][number] @@ -125,15 +124,11 @@ export function HostTable({ hosts, waiting }: HostTableProps) { {formatLoad(host.load15)}
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(host.lastSeen)} - - {host.lastSeen} - +
      ))} diff --git a/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx b/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx index bda13e9aaf..28e12ae339 100644 --- a/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx +++ b/apps/web/src/components/infra/kubernetes/pod-peek-sheet.tsx @@ -1,4 +1,3 @@ -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react" import { Link } from "@tanstack/react-router" @@ -9,15 +8,11 @@ import { Kbd } from "@maple/ui/components/ui/kbd" import { Sheet, SheetContent, - SheetDescription, SheetFooter, - SheetHeader, SheetPanel, - SheetTitle, } from "@maple/ui/components/ui/sheet" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatPercent } from "@maple/ui/lib/format" -import { formatRelativeTime } from "@maple/ui/lib/time-format" import type { PodInfraMetric } from "@/api/warehouse/infra" import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon } from "@/components/icons" @@ -27,6 +22,8 @@ import { severityLevel } from "@/components/infra/format" import { PodDetailChart } from "@/components/infra/k8s-detail-chart" import { podKey, type PodRow } from "@/components/infra/pod-table" import { HeroChip } from "@/components/common/page-hero" +import { RelativeTime } from "@/components/common/relative-time" +import { SheetDetailHeader } from "@/components/common/sheet-detail-header" import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { HostStatusBadge } from "@/components/infra/status-badge" import type { TimeRangeSearch } from "@/components/time-range-picker/search" @@ -169,32 +166,31 @@ export function PodPeekSheet({ {pod ? ( <> - - Pod - - {pod.podName} - - - - Peak utilization and metrics for {pod.podName} - -
      - {pod.namespace && ns {pod.namespace}} - {pod.deploymentName && deploy {pod.deploymentName}} - {pod.statefulsetName && sts {pod.statefulsetName}} - {pod.daemonsetName && ds {pod.daemonsetName}} - {pod.jobName && job {pod.jobName}} - {pod.nodeName && node {pod.nodeName}} - {pod.qosClass && qos {pod.qosClass}} - - seen {formatRelativeTime(pod.lastSeen)} - -
      -
      + + } + description={`Peak utilization and metrics for ${pod.podName}`} + meta={ + <> + {pod.namespace && ns {pod.namespace}} + {pod.deploymentName && deploy {pod.deploymentName}} + {pod.statefulsetName && sts {pod.statefulsetName}} + {pod.daemonsetName && ds {pod.daemonsetName}} + {pod.jobName && job {pod.jobName}} + {pod.nodeName && node {pod.nodeName}} + {pod.qosClass && qos {pod.qosClass}} + + + } + /> {neighbors.map((neighbor) => (
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(node.lastSeen)} - - {node.lastSeen} - +
      ))} diff --git a/apps/web/src/components/infra/overview/infra-overview.tsx b/apps/web/src/components/infra/overview/infra-overview.tsx index 81bdc996b3..c3d59358b7 100644 --- a/apps/web/src/components/infra/overview/infra-overview.tsx +++ b/apps/web/src/components/infra/overview/infra-overview.tsx @@ -2,7 +2,9 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" import type { ReactNode } from "react" import { Link } from "@tanstack/react-router" +import { SegmentedBar } from "@maple/ui/components/ui/meter" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { @@ -169,8 +171,8 @@ function SourceData({ id, window, render }: { id: SourceId; window: OverviewWind * ----------------------------------------------------------------------------------------------*/ const FINDING_DOT: Record = { - crit: "bg-[var(--severity-error)]", - warn: "bg-[var(--severity-warn)]", + crit: TONE_FILL.crit, + warn: TONE_FILL.warn, stale: "border-[1.5px] border-muted-foreground bg-transparent", } satisfies Record @@ -380,8 +382,8 @@ export function NeedsAttention({ const SEGMENT_CLASS: Record = { ok: "bg-muted-foreground/35", - elevated: "bg-[var(--severity-warn)]", - saturated: "bg-[var(--severity-error)]", + elevated: TONE_FILL.warn, + saturated: TONE_FILL.crit, // Hatched, not a fill: there is no limit to measure against, and a plain // `bg-muted` vanished into the track. unbounded: @@ -397,24 +399,23 @@ const SEGMENT_LABEL: Record = { const HEADLINE_TONE: Record = { neutral: "text-foreground", - warn: "text-[var(--severity-warn)]", - crit: "text-[var(--severity-error)]", + warn: TONE_TEXT.warn, + crit: TONE_TEXT.crit, } satisfies Record function HealthBar({ segments }: { segments: ReadonlyArray }) { - const total = segments.reduce((sum, segment) => sum + segment.count, 0) const drawn = segments.filter((segment) => segment.count > 0) return (
      -
      - {drawn.map((segment) => ( -
      - ))} -
      + ({ + key: segment.key, + value: segment.count, + className: SEGMENT_CLASS[segment.key], + }))} + /> {drawn.map((segment) => ( diff --git a/apps/web/src/components/infra/overview/summaries.ts b/apps/web/src/components/infra/overview/summaries.ts index 32ae0b9fda..235880fd84 100644 --- a/apps/web/src/components/infra/overview/summaries.ts +++ b/apps/web/src/components/infra/overview/summaries.ts @@ -7,7 +7,7 @@ import type { RailwayServiceRow } from "@/api/warehouse/railway-infra" import type { PlanetScaleDatabaseStat } from "@/api/warehouse/service-map" import { formatPercent } from "@maple/ui/lib/format" -import { errorRateTone } from "../cloudflare/constants" +import { errorRateLevel } from "@maple/ui/lib/error-rate" import type { ContainerScopeCounts } from "../container-summary-band" import { HOST_LIST_LIMIT, countHostScopes, hostPeak } from "../host-summary-band" import type { HostRow } from "../host-table" @@ -214,14 +214,14 @@ const compactCount = (n: number) => export function summarizeCloudflare(zones: ReadonlyArray): SourceSummary { const erroring = zones - .filter((zone) => zone.requests >= MIN_ZONE_REQUESTS && errorRateTone(zone.errorRate) !== "neutral") + .filter((zone) => zone.requests >= MIN_ZONE_REQUESTS && errorRateLevel(zone.errorRate) !== "neutral") .sort((a, b) => b.errorRate - a.errorRate) - const crit = erroring.filter((zone) => errorRateTone(zone.errorRate) === "crit").length + const crit = erroring.filter((zone) => errorRateLevel(zone.errorRate) === "crit").length const findings: Finding[] = erroring.slice(0, MAX_FINDINGS_PER_SOURCE).map((zone) => ({ key: `zone:${zone.zoneName}`, source: "cloudflare", - tone: errorRateTone(zone.errorRate) === "crit" ? "crit" : "warn", + tone: errorRateLevel(zone.errorRate) === "crit" ? "crit" : "warn", title: `${zone.zoneName} returning ${formatPercent(zone.errorRate)} 5xx`, detail: `${compactCount(zone.requests)} requests, origin p99 ${Math.round(zone.originP99Ms)}ms`, target: { kind: "zone", zoneName: zone.zoneName }, diff --git a/apps/web/src/components/infra/planetscale/planetscale-activity-feed.tsx b/apps/web/src/components/infra/planetscale/planetscale-activity-feed.tsx index 3b149163fa..14bd5da3e5 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-activity-feed.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-activity-feed.tsx @@ -1,7 +1,8 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" import type { PlanetScaleEventEntry } from "@/api/warehouse/planetscale-infra" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { TONE_FILL } from "@maple/ui/lib/tone" +import { RelativeTime } from "@/components/common/relative-time" import { cn } from "@maple/ui/lib/utils" import { ExternalLinkIcon } from "@/components/icons" @@ -76,14 +77,7 @@ export function PlanetScaleActivityRow({ which is a different vocabulary from event severity. */} {title} {branchName !== "" ? ( @@ -115,9 +109,10 @@ export function PlanetScaleActivityRow({
      ) : null} - - {formatRelativeTime(new Date(occurredAt).toISOString())} - + ) } diff --git a/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx b/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx index 52fa380fdd..262bc17055 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-branch-breakdown-panel.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -170,16 +171,19 @@ export function PlanetScaleBranchBreakdownPanel({ }) return ( -
      -
      - Branches - - ariaLabel="Measure" - options={MEASURE_ORDER.map((key) => ({ value: key, label: MEASURES[key].label }))} - value={measure} - onChange={setMeasure} - /> -
      + + + ariaLabel="Measure" + options={MEASURE_ORDER.map((key) => ({ value: key, label: MEASURES[key].label }))} + value={measure} + onChange={setMeasure} + /> + } + /> @@ -273,21 +277,17 @@ export function PlanetScaleBranchBreakdownPanel({ : // Not "% of database": these are maxima, and they do not sum. `Bars are relative to the worst branch (${spec.format(max)}). Peaks don't sum, so there is no database total.`}
      -
      + ) } export function PlanetScaleBranchBreakdownPanelLoading() { return ( -
      -
      + + -
      -
      - {Array.from({ length: 5 }, (_, index) => ( - - ))} -
      -
      + + + ) } diff --git a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx index d59515be08..6c2376dd9f 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx @@ -1,7 +1,7 @@ import { EmptyMessage } from "@maple/ui/components/ui/empty" import { useMemo } from "react" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" import { LatencyValue } from "@maple/ui/components/latency-value" @@ -9,8 +9,9 @@ import { Result } from "@/lib/effect-atom" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { ErrorState } from "@/components/common/error-state" import { planetscaleQueryInsightsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" import { formatNumber } from "@maple/ui/lib/format" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" /** Bordered like the query rows, so an explanation keeps the section's rhythm. */ function InsightsNotice({ children, className }: { children: React.ReactNode; className?: string }) { @@ -62,11 +63,7 @@ export function PlanetScaleTopQueries({ if (Result.isInitial(result)) { // Row-shaped, so the section doesn't resize when the rows arrive. return ( -
      - {[0, 1, 2].map((i) => ( - - ))} -
      + ) } if (Result.isFailure(result)) { @@ -110,11 +107,9 @@ export function PlanetScaleTopQueries({ 0.05 - ? "text-severity-error" - : row.errorRate > 0.01 - ? "text-severity-warn" - : "text-muted-foreground", + errorRateLevel(row.errorRate) === "neutral" + ? "text-muted-foreground" + : errorRateClass(row.errorRate), )} > {(row.errorRate * 100).toFixed(1)}% @@ -133,9 +128,12 @@ export function PlanetScaleTopQueries({ {row.statementType ? {row.statementType} : null} {row.lastRunAt !== null ? ( - - last run {formatRelativeTime(new Date(row.lastRunAt).toISOString())} - + ) : null}
      diff --git a/apps/web/src/components/infra/pod-table.tsx b/apps/web/src/components/infra/pod-table.tsx index a9f7998fa5..07e4bc8703 100644 --- a/apps/web/src/components/infra/pod-table.tsx +++ b/apps/web/src/components/infra/pod-table.tsx @@ -3,7 +3,6 @@ import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import type { ListPodsResponse } from "@maple/domain/http" import type { PodSortKey, SortDirection } from "@/api/warehouse/infra" @@ -13,7 +12,8 @@ import { HostStatusBadge } from "./status-badge" import { ColumnHead, DataTable, ROW_LINK_CLASS } from "@/components/common/data-table" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" +import { TONE_TEXT } from "@maple/ui/lib/tone" export type PodRow = ListPodsResponse["data"][number] @@ -224,7 +224,7 @@ export function PodTable({ pod.computeType === "fargate" && ( // Keyed because it sits in an array literal, even though // `MetaLine` wraps each item in a keyed span of its own. - + fargate ), @@ -250,15 +250,11 @@ export function PodTable({ />
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(pod.lastSeen)} - - {pod.lastSeen} - +
      ) diff --git a/apps/web/src/components/infra/primitives/fleet-band.tsx b/apps/web/src/components/infra/primitives/fleet-band.tsx index 309d341abb..3e886b9510 100644 --- a/apps/web/src/components/infra/primitives/fleet-band.tsx +++ b/apps/web/src/components/infra/primitives/fleet-band.tsx @@ -12,8 +12,10 @@ // measure) and this draws it. import { cn } from "@maple/ui/lib/utils" +import { SegmentedBar } from "@maple/ui/components/ui/meter" import { Skeleton } from "@maple/ui/components/ui/skeleton" import type { Tone } from "../severity-tokens" +import { TONE_TEXT } from "@maple/ui/lib/tone" /** A band that stands alone on a page body rather than flush under a shell's header. */ export const FLEET_BAND_BOXED = "overflow-hidden rounded-lg border" @@ -54,9 +56,9 @@ interface FleetBandProps { const CELL_VALUE_TONE: Record = { neutral: "text-foreground", ok: "text-foreground", - info: "text-[var(--severity-info)]", - warn: "text-[var(--severity-warn)]", - crit: "text-[var(--severity-error)]", + info: TONE_TEXT.info, + warn: TONE_TEXT.warn, + crit: TONE_TEXT.crit, } satisfies Record export function FleetBand({ @@ -90,22 +92,19 @@ export function FleetBand({
      {total > 0 ? ( -
      `${segment.count} ${segment.key}`).join(", ")} - > - {/* A single hot pod in a fleet of 600 is 0.2% of the width, which - rounds to nothing — so any non-zero segment gets a floor wide - enough to see. */} - {drawn.map((segment) => ( -
      - ))} -
      + // A single hot pod in a fleet of 600 is 0.2% of the width, which + // rounds to nothing, so any non-zero segment gets a 2% floor. + `${segment.count} ${segment.key}`).join(", ")} + segments={drawn.map((segment) => ({ + key: segment.key, + value: segment.count, + className: segment.className, + }))} + /> ) : (
      )} diff --git a/apps/web/src/components/infra/primitives/scope-chip.tsx b/apps/web/src/components/infra/primitives/scope-chip.tsx index bcdaf5537b..4625b0b0fa 100644 --- a/apps/web/src/components/infra/primitives/scope-chip.tsx +++ b/apps/web/src/components/infra/primitives/scope-chip.tsx @@ -9,12 +9,10 @@ // (Cloudflare's `PanelScope`) compose it. import type { ReactNode } from "react" +import { Badge } from "@maple/ui/components/ui/badge" import { cn } from "@maple/ui/lib/utils" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -export const SCOPE_CHIP_CLASS = - "inline-flex items-center rounded-sm border border-border/70 bg-background/60 px-1.5 py-0.5 font-mono text-[10px]" - /** * A scope marker. `tone="muted"` is for the markers that describe a *limit* of the * panel ("zone-wide", "partial", "all branches"); the default reads as an applied @@ -32,18 +30,25 @@ export function ScopeChip({ className?: string }) { const chipClass = cn( - SCOPE_CHIP_CLASS, + "bg-background/60 font-normal", tone === "muted" ? "text-muted-foreground/70" : "text-muted-foreground", className, ) if (explanation === undefined) { - return {children} + return ( + + {children} + + ) } return ( - } className={cn(chipClass, "cursor-default")}> + } + className={cn(chipClass, "cursor-default")} + > {children} {explanation} diff --git a/apps/web/src/components/infra/railway/railway-service-table.tsx b/apps/web/src/components/infra/railway/railway-service-table.tsx index 83cf2eba7b..1ace8fb10b 100644 --- a/apps/web/src/components/infra/railway/railway-service-table.tsx +++ b/apps/web/src/components/infra/railway/railway-service-table.tsx @@ -2,8 +2,7 @@ import { useMemo } from "react" import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import type { RailwayServiceRow } from "@/api/warehouse/railway-infra" @@ -18,6 +17,7 @@ import { import { FleetBand, FleetBandLoading } from "../primitives/fleet-band" import { MetaLine } from "../primitives/meta-line" import { MeterRows } from "../primitives/meter-rows" +import { TONE_FILL } from "@maple/ui/lib/tone" export type RailwayScope = "saturated" | "elevated" | "unbounded" @@ -78,8 +78,8 @@ export function RailwaySummaryBand({ count: Math.max(services.length - saturated - elevated - unbounded, 0), className: "bg-muted-foreground/35", }, - { key: "elevated", count: elevated, className: "bg-[var(--severity-warn)]" }, - { key: "saturated", count: saturated, className: "bg-[var(--severity-error)]" }, + { key: "elevated", count: elevated, className: TONE_FILL.warn }, + { key: "saturated", count: saturated, className: TONE_FILL.crit }, ]} cells={[ { scope: "saturated", label: "Saturated", hint: "≥90%", value: saturated, tone: "crit" }, @@ -196,15 +196,11 @@ export function RailwayServiceTable({ {row.replicas}
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(row.lastSeen)} - - {row.lastSeen} - +
      ))} diff --git a/apps/web/src/components/infra/service-lens/service-lens-rail.tsx b/apps/web/src/components/infra/service-lens/service-lens-rail.tsx index f14f8ffc95..ebeaa38587 100644 --- a/apps/web/src/components/infra/service-lens/service-lens-rail.tsx +++ b/apps/web/src/components/infra/service-lens/service-lens-rail.tsx @@ -1,7 +1,7 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Link } from "@tanstack/react-router" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" import { formatPercent } from "@maple/ui/lib/format" import { ServiceDot } from "@maple/ui/components/service-dot" @@ -139,10 +139,6 @@ function RailRow({ function RailLoading() { return ( -
      - {Array.from({ length: 6 }, (_, i) => ( - - ))} -
      + ) } diff --git a/apps/web/src/components/infra/severity-tokens.ts b/apps/web/src/components/infra/severity-tokens.ts index 024b106576..fef3df0bc3 100644 --- a/apps/web/src/components/infra/severity-tokens.ts +++ b/apps/web/src/components/infra/severity-tokens.ts @@ -2,6 +2,8 @@ // Keyed by the shared `SeverityLevel` / `HostStatus` unions from `./format`, so // the severity palette lives in one place instead of being re-encoded per file. +import { TONE_COLOR, TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" + import type { HostStatus, SeverityLevel } from "./format" export type Tone = SeverityLevel | "neutral" @@ -10,35 +12,35 @@ export type Tone = SeverityLevel | "neutral" export const VALUE_TONE: Record = { neutral: "text-foreground", ok: "text-foreground", - warn: "text-[var(--severity-warn)]", - crit: "text-[var(--severity-error)]", + warn: TONE_TEXT.warn, + crit: TONE_TEXT.crit, } satisfies Record /** Sparkline fill — a raw CSS var string (consumed as an SVG `fill`, not a class). */ export const SPARK_COLOR: Record = { neutral: "var(--primary)", - ok: "var(--severity-info)", - warn: "var(--severity-warn)", - crit: "var(--severity-error)", + ok: TONE_COLOR.ok, + warn: TONE_COLOR.warn, + crit: TONE_COLOR.crit, } satisfies Record /** Solid severity fill for inline meter bars. */ export const BAR_FILL: Record = { - ok: "bg-[var(--severity-info)]", - warn: "bg-[var(--severity-warn)]", - crit: "bg-[var(--severity-error)]", + ok: TONE_FILL.ok, + warn: TONE_FILL.warn, + crit: TONE_FILL.crit, } satisfies Record /** Dimmer value-text tone used inside compact bar widgets. */ export const BAR_VALUE_TONE: Record = { ok: "text-foreground/75", - warn: "text-[var(--severity-warn)]", - crit: "text-[var(--severity-error)]", + warn: TONE_TEXT.warn, + crit: TONE_TEXT.crit, } satisfies Record /** Status dot fill. */ export const STATUS_DOT: Record = { - active: "bg-[var(--severity-info)]", + active: TONE_FILL.info, idle: "bg-muted-foreground/60", ended: "bg-muted-foreground/40", } satisfies Record diff --git a/apps/web/src/components/infra/status-badge.tsx b/apps/web/src/components/infra/status-badge.tsx index 2a2cb961ef..2328d8e0e2 100644 --- a/apps/web/src/components/infra/status-badge.tsx +++ b/apps/web/src/components/infra/status-badge.tsx @@ -2,9 +2,10 @@ import { cn } from "@maple/ui/lib/utils" import { deriveHostStatus, type HostStatus } from "./format" import { SeverityDot } from "./primitives/severity-dot" import { statusLabel } from "./severity-tokens" +import { TONE_TEXT } from "@maple/ui/lib/tone" const STATUS_TEXT: Record = { - active: "text-[var(--severity-info)]", + active: TONE_TEXT.info, idle: "text-muted-foreground", ended: "text-muted-foreground", } satisfies Record diff --git a/apps/web/src/components/infra/workload-table.tsx b/apps/web/src/components/infra/workload-table.tsx index d8efd7f5ec..2d6e833239 100644 --- a/apps/web/src/components/infra/workload-table.tsx +++ b/apps/web/src/components/infra/workload-table.tsx @@ -1,7 +1,6 @@ import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import type { ListWorkloadsResponse } from "@maple/domain/http" import type { WorkloadKind } from "@/api/warehouse/infra" @@ -10,7 +9,7 @@ import { HostStatusBadge } from "./status-badge" import { MeterRows } from "./primitives/meter-rows" import { MetaLine } from "./primitives/meta-line" import { ColumnHead, DataTable, ROW_LINK_CLASS, useTableSort } from "@/components/common/data-table" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" export type WorkloadRow = ListWorkloadsResponse["data"][number] @@ -132,15 +131,11 @@ export function WorkloadTable({ workloads, kind, waiting, referenceTime }: Workl
      - - } - className="cursor-default font-mono text-[11px] text-muted-foreground" - > - {formatRelativeTime(wl.lastSeen)} - - {wl.lastSeen} - +
      ))} diff --git a/apps/web/src/components/ingest/connection-status.tsx b/apps/web/src/components/ingest/connection-status.tsx index 60b54b4202..450693a333 100644 --- a/apps/web/src/components/ingest/connection-status.tsx +++ b/apps/web/src/components/ingest/connection-status.tsx @@ -2,6 +2,7 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" import { useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { cn } from "@maple/ui/lib/utils" import { PaperPlaneIcon, PulseIcon } from "@/components/icons" @@ -14,9 +15,10 @@ import { sendTestEvent, type IngestConnection } from "./use-ingest-connection" export function ConnectionStatusPill({ connection }: { connection: IngestConnection }) { const connected = connection.status === "connected" return ( - ) : ( <> - + Waiting for telemetry )} - + ) } diff --git a/apps/web/src/components/integrations/chat-integration-card.tsx b/apps/web/src/components/integrations/chat-integration-card.tsx index 30427e0491..04e7a57fb1 100644 --- a/apps/web/src/components/integrations/chat-integration-card.tsx +++ b/apps/web/src/components/integrations/chat-integration-card.tsx @@ -5,25 +5,24 @@ import { Exit, Option } from "effect" import type { ChatConnectorId, ChatWorkspaceId } from "@maple/domain/primitives" import type { V2ChatConnector } from "@maple/domain/http/v2" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Input } from "@maple/ui/components/ui/input" +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@maple/ui/components/ui/item" import { Label } from "@maple/ui/components/ui/label" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { toastManager } from "@maple/ui/components/ui/toast" -import { formatRelativeTime } from "@maple/ui/lib/time-format" import { chatConnectorManifests } from "@maple/chat-platform/manifests" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" @@ -186,25 +185,27 @@ function ChatIdentityRow({ } return ( -
      -
      - Your {platform} account - + + + Your {platform} account + {identity === undefined ? `Link it so Maple knows it's you acting from ${platform}.` : `Linked as ${identity.display_name ?? identity.external_user_id}`} - -
      - -
      + + + + + + ) } @@ -343,12 +344,11 @@ export function ChatIntegrationCard({ connector }: { connector: ChatConnectorId return (
      {workspaces.map((workspace) => ( -
      - -
      + + + + +

      {workspace.name}

      @@ -360,9 +360,11 @@ export function ChatIntegrationCard({ connector }: { connector: ChatConnectorId Linked to your organization, for everyone in this workspace — no {manifest.name}{" "} account is tied to an individual Maple user.

      -
      - Connected {formatRelativeTime(workspace.created_at)} -
      + {/* Keyed by the stored settings so a save reseeds the form from what the server actually kept. */}
      -
      -
      + + ))} {status?.supports_identity === true ? ( @@ -402,28 +404,17 @@ export function ChatIntegrationCard({ connector }: { connector: ChatConnectorId

      ) : null} - !open && setConfirmId(null)}> - - - Disconnect workspace - - This workspace is unlinked from your organization immediately. Removing the bot - from the workspace itself is done in {manifest.name}. - - - - Cancel - confirmId !== null && handleDisconnect(confirmId)} - disabled={busy !== null} - > - {busy === confirmId ? : null} - Disconnect - - - - + { + if (!open) setConfirmId(null) + }} + title="Disconnect workspace" + description={`This workspace is unlinked from your organization immediately. Removing the bot from the workspace itself is done in ${manifest.name}.`} + confirmLabel="Disconnect" + onConfirm={() => (confirmId !== null ? handleDisconnect(confirmId) : undefined)} + pending={busy !== null && busy === confirmId} + />
      ) } diff --git a/apps/web/src/components/integrations/cloudflare-account-card.tsx b/apps/web/src/components/integrations/cloudflare-account-card.tsx index cbfc8d8cdf..cc8144aa87 100644 --- a/apps/web/src/components/integrations/cloudflare-account-card.tsx +++ b/apps/web/src/components/integrations/cloudflare-account-card.tsx @@ -4,6 +4,8 @@ import { Exit } from "effect" import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { Item, ItemActions, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { toastManager } from "@maple/ui/components/ui/toast" @@ -55,38 +57,36 @@ interface ConnectedAccountEntry { function CloudflareAccountsStrip({ accounts }: { readonly accounts: ReadonlyArray }) { if (accounts.length < 2) return null return ( -
      -
      -

      - Connected accounts ({accounts.length}) -

      -
      + +
        {accounts.map((account) => ( -
      • - -
        + }> + + + + {account.accountName ?? account.accountId} {account.accountId} -
        -
        + + {account.revoked ? ( Reconnect required ) : !account.analyticsCapable ? ( Needs updated access ) : null} -
        - - {account.zoneCount === 1 ? "1 zone" : `${account.zoneCount} zones`} - -
      • + + {account.zoneCount === 1 ? "1 zone" : `${account.zoneCount} zones`} + + + ))}
      -
      + ) } @@ -220,15 +220,17 @@ export function CloudflareAccountCard() { // over an account that may already be authorized. if (Result.isFailure(statusResult)) { return ( -
      - -
      + + + + +

      Cloudflare account

      Couldn't load the Cloudflare connection status — refresh the page to try again.

      -
      -
      + + ) } diff --git a/apps/web/src/components/integrations/cloudflare-stat-cards.tsx b/apps/web/src/components/integrations/cloudflare-stat-cards.tsx index 7bcc1c0e68..c1ce48797d 100644 --- a/apps/web/src/components/integrations/cloudflare-stat-cards.tsx +++ b/apps/web/src/components/integrations/cloudflare-stat-cards.tsx @@ -1,57 +1,15 @@ -import type { ReactNode } from "react" import { Link } from "@tanstack/react-router" import type { CloudflareServiceUsage, CloudflareUsageResponse } from "@maple/domain/http" -import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" +import { ArrowRightIcon } from "@/components/icons" + import { formatNumber } from "@maple/ui/lib/format" import { CLOUDFLARE_ACCENT } from "./integration-catalog" import { toRowUsage } from "./cloudflare-zone-board" -function StatCard({ - eyebrow, - value, - sparkline, - caption, - href, -}: { - eyebrow: string - value: ReactNode - sparkline?: ReactNode - caption: ReactNode - /** Makes the whole card navigate (e.g. the firewall card → the infra dashboard). */ - href?: "/infra/cloudflare" -}) { - const body = ( - <> - {eyebrow} - - {value} - {sparkline} - - {caption} - - ) - const frame = - "flex min-w-0 flex-1 flex-col gap-2.5 rounded-lg border border-border/60 bg-card px-4 py-3.5" - if (href) { - return ( - - {body} - - ) - } - return
      {body}
      -} - /** The "+12% vs previous 24h" caption — quiet unless traffic actually moved up. */ function TrafficDelta({ usage }: { usage: CloudflareUsageResponse }) { const previous = usage.previousTotalRequests @@ -87,9 +45,9 @@ export function CloudflareStatCards({ if (usage === null) { return (
      - - - + + +
      ) } @@ -100,45 +58,40 @@ export function CloudflareStatCards({ const workerPoints = workerInvocations > 0 ? toRowUsage(usage, workerServices).points : null return ( -
      - + - ) : null - } - caption={} + spark={totalPoints?.map((point) => point.v)} + sparkColor={CLOUDFLARE_ACCENT} + subline={} /> - - ) : null - } - caption={ + spark={workerPoints?.map((point) => point.v)} + sparkColor={CLOUDFLARE_ACCENT} + subline={ workerInvocations > 0 ? `${formatNumber(workerInvocations)} invocations · on service map` : "Scripts appear once they serve traffic" } /> - + Open + + + } /> -
      + ) } diff --git a/apps/web/src/components/integrations/cloudflare-zone-board.tsx b/apps/web/src/components/integrations/cloudflare-zone-board.tsx index 332e159e10..e19281bb14 100644 --- a/apps/web/src/components/integrations/cloudflare-zone-board.tsx +++ b/apps/web/src/components/integrations/cloudflare-zone-board.tsx @@ -5,7 +5,10 @@ import { Link } from "@tanstack/react-router" import type { CloudflareServiceUsage, CloudflareUsageResponse } from "@maple/domain/http" import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { SearchInput } from "@maple/ui/components/ui/search-input" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { cn } from "@maple/ui/lib/utils" import { ColumnHead, type SortDir } from "@/components/common/data-table" @@ -283,15 +286,11 @@ function ResourceRow({ <>
      - {name} - + {/* In a narrow card the status column is hidden, so surface the detail under the name. */}
      {status.detail}
      @@ -344,40 +343,18 @@ interface DecoratedZone { } /** One filter chip: state color dot (or none for "All") + label + count. */ -function ZoneChip({ - label, - count, - dot, - active, - onClick, -}: { - label: string - count: number - dot?: string - active: boolean - onClick: () => void -}) { +function ZoneChip({ value, label, count, dot }: { value: string; label: string; count: number; dot?: string }) { return ( - + ) } @@ -462,41 +439,34 @@ export function CloudflareZoneBoard({ } } - const activeChip = (kind: ZoneStatusKind | "all") => () => - setFilter((current) => (current === kind ? "all" : kind)) + // Pressing the active chip again unpresses it, which falls back to "All". + const handleFilter = (values: ReadonlyArray) => + setFilter(CHIP_ORDER.find((kind) => kind === values[0]) ?? "all") return ( -
      + {/* Title bar: the health rollup (chips double as a single-select filter) + a name search. */} -
      +

      Zones

      -
      - + {CHIP_ORDER.filter((kind) => counts[kind] > 0).map((kind) => ( ))} -
      + -
      + {banner ?
      {banner}
      : null}
      @@ -560,7 +530,7 @@ export function CloudflareZoneBoard({
      )}
      - + ) } @@ -593,21 +563,20 @@ export function CloudflareWorkersCard({ const visible = expanded ? scripts : scripts.slice(0, WORKERS_COLLAPSED_COUNT) return ( -
      -
      + + + View on map → + + } + >

      Workers

      - - View on map → - -
      + {aggregate.kind === "issue" || aggregate.kind === "no-data" ? (
      @@ -634,12 +603,9 @@ export function CloudflareWorkersCard({ service.totalRequests > 0 ? "bg-success" : "bg-muted-foreground/40" } /> - + {service.displayName} - + {service.totalRequests > 0 ? formatNumber(service.totalRequests) : "—"} @@ -659,6 +625,6 @@ export function CloudflareWorkersCard({ ) : null}
      )} -
      + ) } diff --git a/apps/web/src/components/integrations/github-integration-card.tsx b/apps/web/src/components/integrations/github-integration-card.tsx index cb8d8198bc..b379ce3e05 100644 --- a/apps/web/src/components/integrations/github-integration-card.tsx +++ b/apps/web/src/components/integrations/github-integration-card.tsx @@ -9,22 +9,16 @@ import { type GithubRepoSummary, type VcsRepoSyncStatus, } from "@maple/domain/http" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia } from "@maple/ui/components/ui/item" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" +import { SettingRow } from "@maple/ui/components/ui/setting-row" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { Popover, PopoverContent, PopoverTrigger } from "@maple/ui/components/ui/popover" import { SearchInput } from "@maple/ui/components/ui/search-input" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { formatRelativeFrom } from "@maple/ui/lib/time-format" import { toastManager } from "@maple/ui/components/ui/toast" import { @@ -39,6 +33,7 @@ import { LoaderIcon, TrashIcon, } from "@/components/icons" +import { RelativeTime } from "@/components/common/relative-time" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" @@ -229,65 +224,37 @@ export function GithubIntegrationCard() { )} - { - if (!open) setConfirmingDisconnect(false) + onOpenChange={setConfirmingDisconnect} + title="Disconnect GitHub" + description="This removes the Maple GitHub App connection and permanently deletes all synced repositories and their commit history from Maple. This cannot be undone. You can reconnect later, but everything will be re-synced from scratch." + confirmLabel="Disconnect" + onConfirm={() => { + setConfirmingDisconnect(false) + void handleDisconnect() }} - > - - - Disconnect GitHub - - This removes the Maple GitHub App connection and permanently deletes all synced - repositories and their commit history from Maple. This cannot be undone. You can - reconnect later, but everything will be re-synced from scratch. - - - - Cancel - { - setConfirmingDisconnect(false) - void handleDisconnect() - }} - > - Disconnect - - - - + /> - { if (!open) setRepoToDelete(null) }} - > - - - Delete repository from Maple - - This permanently removes{" "} - {repoToDelete?.fullName} and - all of its synced commits from Maple. This cannot be undone. If you re-enable - access in GitHub later, the repository will be re-synced from scratch. - - - - Cancel - { - if (repoToDelete) void handleDeleteRepository(repoToDelete) - }} - > - Delete - - - - + title="Delete repository from Maple" + description={ + <> + This permanently removes{" "} + {repoToDelete?.fullName} and all + of its synced commits from Maple. This cannot be undone. If you re-enable access in + GitHub later, the repository will be re-synced from scratch. + + } + confirmLabel="Delete" + onConfirm={() => { + if (repoToDelete) void handleDeleteRepository(repoToDelete) + }} + /> ) } @@ -473,39 +440,39 @@ function ConnectedView({ return (
      -
      -
      + + -
      -
      - Connected - {status.accountLogin ? ( - <> - {" "} - as{" "} - - @{status.accountLogin} - - - ) : null} -
      -
      - {status.accountType === "organization" - ? "Organization" - : status.accountType === "user" - ? "Personal account" - : "GitHub App"}{" "} - · {scopeLabel} -
      + + +
      + Connected + {status.accountLogin ? ( + <> + {" "} + as{" "} + + @{status.accountLogin} + + + ) : null}
      -
      - -
      + + {status.accountType === "organization" + ? "Organization" + : status.accountType === "user" + ? "Personal account" + : "GitHub App"}{" "} + · {scopeLabel} + + + + -
      -
      + + {prReviewRolledOut ? ( -
      -
      -
      Pull request reviews
      -
      + {activeRepos.filter((repo) => repo.prReviewEnabled).length} of{" "} {activeRepos.length} repositories reviewed. Rules, models and analytics live in Code Review. -
      -
      - -
      + + } + control={ + + } + /> ) : null} -
      -
      + + 0 ? ( +
      + {counts.synced > 0 ? ( + + + {counts.synced} synced + + ) : null} + {counts.syncing > 0 ? ( + + + {counts.syncing} syncing + + ) : null} + {counts.failed > 0 ? ( + + + {counts.failed} failed + + ) : null} +
      + ) : null + } + >

      Repositories {activeRepos.length}

      - {activeRepos.length > 0 ? ( -
      - {counts.synced > 0 ? ( - - - {counts.synced} synced - - ) : null} - {counts.syncing > 0 ? ( - - - {counts.syncing} syncing - - ) : null} - {counts.failed > 0 ? ( - - - {counts.failed} failed - - ) : null} -
      - ) : null} -
      + {activeRepos.length === 0 && removedRepos.length === 0 ? (
      @@ -583,22 +557,24 @@ function ConnectedView({ ))} )} -
      + {/* Repos GitHub revoked access to — kept (with history) until explicitly deleted. */} {removedRepos.length > 0 ? ( -
      -
      + +

      Needs attention

      -
      +

      @@ -651,7 +627,7 @@ function ConnectedView({ {" "} to resume syncing. Deleting removes their synced commits permanently.

      -
      + ) : null}
      ) @@ -669,12 +645,14 @@ function RepoRow({ const StatusIcon = presentation.Icon return ( -
    1. - - + {repo.prReviewEnabled ? ( ) : null} -
    2. + ) } @@ -846,36 +824,29 @@ function BranchSelector({ - { if (!o) setPending(null) }} - > - - - Change tracked branch - - This switches {repo.fullName}{" "} - to track {pending}. Maple - deletes this repo's currently synced commits and re-syncs the last 90 days - from {pending}. - - - - Cancel - { - const next = pending - setPending(null) - if (next) void commit(next) - }} - > - Track branch - - - - + title="Change tracked branch" + description={ + <> + This switches {repo.fullName} to + track {pending}. Maple deletes + this repo's currently synced commits and re-syncs the last 90 days from{" "} + {pending}. + + } + confirmLabel="Track branch" + tone="default" + icon={null} + onConfirm={() => { + const next = pending + setPending(null) + if (next) void commit(next) + }} + /> ) } diff --git a/apps/web/src/components/integrations/hazel-integration-card.tsx b/apps/web/src/components/integrations/hazel-integration-card.tsx index 5a339d3f23..032ad6c5bf 100644 --- a/apps/web/src/components/integrations/hazel-integration-card.tsx +++ b/apps/web/src/components/integrations/hazel-integration-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { Exit, Option } from "effect" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { Item, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { toastManager } from "@maple/ui/components/ui/toast" @@ -117,10 +118,12 @@ export function HazelIntegrationCard() { } return ( -
      - + + + + -
      +

      Hazel

      @@ -156,7 +159,7 @@ export function HazelIntegrationCard() { Disconnect
      -
      -
      + +
      ) } diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index 65748940fd..dd8ba804a4 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -777,15 +777,19 @@ export function IntegrationsSummary() { const attention = connected.filter((value) => value.health === "attention").length return (
      - + {connected.length} connected - + {attention > 0 && ( - + {attention} need attention - + )}
      ) diff --git a/apps/web/src/components/integrations/planetscale-integration-card.tsx b/apps/web/src/components/integrations/planetscale-integration-card.tsx index feda32cb08..edbf41c455 100644 --- a/apps/web/src/components/integrations/planetscale-integration-card.tsx +++ b/apps/web/src/components/integrations/planetscale-integration-card.tsx @@ -1,4 +1,5 @@ import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Spinner } from "@maple/ui/components/ui/spinner" import { useMemo, useState } from "react" import { Exit } from "effect" @@ -14,6 +15,8 @@ import { DialogTitle, } from "@maple/ui/components/ui/dialog" import { Input } from "@maple/ui/components/ui/input" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Item, ItemActions, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" import { Label } from "@maple/ui/components/ui/label" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { toastManager } from "@maple/ui/components/ui/toast" @@ -121,15 +124,17 @@ export function PlanetScaleIntegrationCard() { // over an org that may already be authorized. if (Result.isFailure(statusResult)) { return ( -
      - -
      + + + + +

      PlanetScale

      Couldn't load the PlanetScale connection status — refresh the page to try again.

      -
      -
      + + ) } @@ -137,9 +142,11 @@ export function PlanetScaleIntegrationCard() { if (!isConnected && pendingOrgSelection) { return (
      -
      - -
      + + + + +

      PlanetScale

      Authorized @@ -148,8 +155,8 @@ export function PlanetScaleIntegrationCard() { The authorization covers multiple PlanetScale organizations — choose which one to connect.

      -
      -
      + +
      refreshStatus()} @@ -202,44 +209,44 @@ export function PlanetScaleIntegrationCard() { return (
      -
      -
      + + -
      -
      -

      PlanetScale

      - {/* "Connected" while three of four steps are done overstates it — - the badge tracks the checklist. */} - {setup !== null && !setup.complete ? ( - - Step {setup.activeStepNumber} of {setup.steps.length} - - ) : ( - Connected - )} -
      -

      - {setup !== null && !setup.complete ? ( - <> - Connected to{" "} - - {status?.organization} - - {" — inventory, insights, and webhooks are live."} - - ) : ( - <> - Streaming branch metrics from{" "} - - {status?.organization} - - - )} -

      + + +
      +

      PlanetScale

      + {/* "Connected" while three of four steps are done overstates it — + the badge tracks the checklist. */} + {setup !== null && !setup.complete ? ( + + Step {setup.activeStepNumber} of {setup.steps.length} + + ) : ( + Connected + )}
      -
      +

      + {setup !== null && !setup.complete ? ( + <> + Connected to{" "} + + {status?.organization} + + {" — inventory, insights, and webhooks are live."} + + ) : ( + <> + Streaming branch metrics from{" "} + + {status?.organization} + + + )} +

      + -
      + -
      -
      + + {/* Setup is only shown while there is setup left. A finished connection collapses to the single health row it has always been. */} @@ -399,7 +406,7 @@ function FirstMetricsDetail({

      Still nothing after a few minutes. The most common cause is a token without the{" "} - read_metrics_endpoints permission — PlanetScale + read_metrics_endpoints permission — PlanetScale accepts the token and then serves no metrics.

      {/* The preview shares its glob implementation with the scraper @@ -590,20 +597,22 @@ function PlanetScaleWebhookSetup() { const [revealed, setRevealed] = useState(false) return (
      -
      -
      + +

      Webhooks

      Register this endpoint in each database's webhook settings on PlanetScale — OOM restarts, storage thresholds, and anomalies then open triage issues in Maple.

      -
      + {!revealed ? ( - + + + ) : null} -
      + {revealed ? : null}
      ) @@ -635,17 +644,17 @@ function PlanetScaleWebhookConfig() { } return (
      -
      - Webhook URL -

      {config.url}

      -
      -
      - Secret -

      {config.secret}

      -
      + + Webhook URL} mono wrap> + {config.url} + + Secret} mono wrap> + {config.secret} + +

      PlanetScale signs each delivery with this secret ( - X-PlanetScale-Signature); Maple rejects anything that + X-PlanetScale-Signature); Maple rejects anything that doesn't verify.

      diff --git a/apps/web/src/components/integrations/planetscale-metrics-health.tsx b/apps/web/src/components/integrations/planetscale-metrics-health.tsx index ce68fc00f8..e05ceb4e63 100644 --- a/apps/web/src/components/integrations/planetscale-metrics-health.tsx +++ b/apps/web/src/components/integrations/planetscale-metrics-health.tsx @@ -4,7 +4,7 @@ import { Link } from "@tanstack/react-router" import { StatusDot } from "@maple/ui/components/ui/status-dot" import type { V2PlanetScaleScrapeTarget } from "@maple/domain/http/v2" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { metricsHealthState, type MetricsAuth, type MetricsHealthState } from "./planetscale-setup-steps" /** @@ -33,7 +33,7 @@ export function PlanetScaleMetricsHealth({ // The token-setup step owns the missing-auth state — don't show two messages. if (state === "unconfigured") return null - const updatedAgo = target.last_scrape_at !== null ? formatRelativeTime(target.last_scrape_at) : null + const lastScrapeAt = target.last_scrape_at return (
      @@ -44,13 +44,14 @@ export function PlanetScaleMetricsHealth({ /> {HEADLINE[state]} - {state === "waiting" - ? "Branch metrics usually appear within a minute." - : state === "healthy" - ? `Updated ${updatedAgo}` - : updatedAgo !== null - ? `Last data ${updatedAgo}` - : null} + {state === "waiting" ? ( + "Branch metrics usually appear within a minute." + ) : lastScrapeAt !== null ? ( + + ) : null} {state === "healthy" && target.exclude_branches.length > 0 ? ( <> · excluding {target.exclude_branches.join(", ")} ) : null} diff --git a/apps/web/src/components/integrations/planetscale-metrics-token-form.tsx b/apps/web/src/components/integrations/planetscale-metrics-token-form.tsx index 2cfdbae7f4..eef6475db5 100644 --- a/apps/web/src/components/integrations/planetscale-metrics-token-form.tsx +++ b/apps/web/src/components/integrations/planetscale-metrics-token-form.tsx @@ -1,4 +1,5 @@ import { Spinner } from "@maple/ui/components/ui/spinner" +import { InlineCode } from "@maple/ui/components/ui/inline-code" import { useState } from "react" import { Exit } from "effect" @@ -114,7 +115,7 @@ export function PlanetScaleMetricsTokenForm({

      Create a service token with the single{" "} - read_metrics_endpoints permission — no other + read_metrics_endpoints permission — no other permission is needed, and Maple never uses it for anything else.

      @@ -185,7 +186,7 @@ export function PlanetScaleMetricsTokenForm({ {secretLooksWrong && error === null ? (

      That doesn't look like a service token secret — it usually starts with{" "} - {SECRET_PREFIX}. + {SECRET_PREFIX}.

      ) : null} diff --git a/apps/web/src/components/integrations/railway-integration-card.tsx b/apps/web/src/components/integrations/railway-integration-card.tsx index 792c6a5528..43788daf36 100644 --- a/apps/web/src/components/integrations/railway-integration-card.tsx +++ b/apps/web/src/components/integrations/railway-integration-card.tsx @@ -1,5 +1,3 @@ -import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Spinner } from "@maple/ui/components/ui/spinner" import { useState } from "react" import { Exit, Option, Schema } from "effect" @@ -8,12 +6,15 @@ import { RailwayConnectRequest, type RailwayIntegrationStatus } from "@maple/dom import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" +import { Item, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" import { Label } from "@maple/ui/components/ui/label" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { toastManager } from "@maple/ui/components/ui/toast" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { ColumnHead, DataTable } from "@/components/common/data-table" import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" import { ExternalLinkIcon, RailwayIcon } from "@/components/icons" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" @@ -261,13 +262,15 @@ export function RailwayIntegrationCard() { return (
      -
      - -
      + + + + +

      Railway

      {status.authFailed ? ( @@ -281,9 +284,12 @@ export function RailwayIntegrationCard() {

      {status.workspaceNames ? `${status.workspaceNames} · ` : ""} {plural(status.environments.length, "environment")} - {status.lastSyncedAt !== null - ? ` · synced ${formatRelativeTime(new Date(status.lastSyncedAt).toISOString())}` - : ""} + {status.lastSyncedAt !== null ? ( + <> + {" · "} + + + ) : null} {queued > 0 ? ` · ${queued} waiting for their first sync` : ""}

      {status.authFailed ? ( @@ -321,28 +327,24 @@ export function RailwayIntegrationCard() {
      )} -
      -
      + + -
      - - Environment - Services - Last sync - + + + + + + {status.environments.length === 0 ? ( - This token can't see any projects yet. + This token can't see any projects yet. ) : ( status.environments.map((environment) => (
      - + {environment.projectName} @@ -351,17 +353,17 @@ export function RailwayIntegrationCard() { {environment.lastError !== null ? ( - + {environment.lastError} - + ) : null} - + {environment.serviceCount} - + {environment.lastSyncedAt !== null ? ( - formatRelativeTime(new Date(environment.lastSyncedAt).toISOString()) + ) : environment.lastError !== null ? ( "Failed" ) : ( @@ -374,7 +376,7 @@ export function RailwayIntegrationCard() {
      )) )} -
      +
      ) } diff --git a/apps/web/src/components/investigations/action-detail-sheet.tsx b/apps/web/src/components/investigations/action-detail-sheet.tsx index 0bba3b4f22..f8588e7ca1 100644 --- a/apps/web/src/components/investigations/action-detail-sheet.tsx +++ b/apps/web/src/components/investigations/action-detail-sheet.tsx @@ -17,16 +17,11 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Link } from "@tanstack/react-router" import { Button } from "@maple/ui/components/ui/button" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetPanel, - SheetTitle, -} from "@maple/ui/components/ui/sheet" +import { Sheet, SheetContent, SheetHeader, SheetPanel, SheetTitle } from "@maple/ui/components/ui/sheet" +import { shortId } from "@maple/ui/lib/ids" import { cn } from "@maple/ui/lib/utils" +import { SheetDetailHeader } from "@/components/common/sheet-detail-header" import { ArrowRightIcon } from "@/components/icons" import { CONFIDENCE_TONE } from "./confidence-meter" import { ACTION_GLYPH } from "./flow/flow-nodes" @@ -77,8 +72,7 @@ export function EvidenceChips({ item }: { item: ActionEvidenceItem }) { className="rounded" > - {traceId.slice(0, 12)} - {traceId.length > 12 ? "…" : ""} + {shortId(traceId, "generic", { ellipsis: true })} ))} @@ -117,22 +111,20 @@ export function ActionDetailSheet({ {action && glyph ? ( <> - -
      - - Proposed action {String(action.index + 1).padStart(2, "0")} - · - {glyph.label} -
      - {/* - * The whole sentence, unclamped — this is the thing the canvas - * node cannot show and the reason this panel exists. - */} - {action.text} - - What this proposed action asks for, and the diagnosis behind it. - -
      + {/* The whole sentence, unclamped: the thing the canvas node cannot show. */} + + + Proposed action {String(action.index + 1).padStart(2, "0")} + · + {glyph.label} + + } + title={action.text} + description="What this proposed action asks for, and the diagnosis behind it." + />
      {action.target ? ( diff --git a/apps/web/src/components/investigations/flow/flow-nodes.tsx b/apps/web/src/components/investigations/flow/flow-nodes.tsx index 8078d8c38e..36554f916b 100644 --- a/apps/web/src/components/investigations/flow/flow-nodes.tsx +++ b/apps/web/src/components/investigations/flow/flow-nodes.tsx @@ -10,7 +10,7 @@ import { Link } from "@tanstack/react-router" import { Handle, Position, type NodeProps } from "@xyflow/react" import { cn } from "@maple/ui/lib/utils" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { AlertWarningIcon, @@ -188,13 +188,13 @@ export const FlowSpineNode = memo(function FlowSpineNode({ data }: NodeProps & { * number nobody converts back into a date. */} {data.at ? ( - + ) : null} ) diff --git a/apps/web/src/components/investigations/flow/provenance-canvas.tsx b/apps/web/src/components/investigations/flow/provenance-canvas.tsx index d5771241f9..6ae7b76e26 100644 --- a/apps/web/src/components/investigations/flow/provenance-canvas.tsx +++ b/apps/web/src/components/investigations/flow/provenance-canvas.tsx @@ -139,10 +139,7 @@ function Caption({ graph }: { graph: ProvenanceGraph }) { {graph.caption ? ( {running ? ( - + ) : null} {graph.caption} {running ? ( diff --git a/apps/web/src/components/investigations/investigation-table.tsx b/apps/web/src/components/investigations/investigation-table.tsx index 67afdcdb79..d802878179 100644 --- a/apps/web/src/components/investigations/investigation-table.tsx +++ b/apps/web/src/components/investigations/investigation-table.tsx @@ -4,8 +4,9 @@ import { Link, useNavigate } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeTime, toEpochMs } from "@maple/ui/lib/time-format" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" +import { RelativeTime } from "@/components/common/relative-time" import { SeverityBadge } from "@/components/errors/severity-badge" import { ConfidenceMeter } from "./confidence-meter" import { @@ -70,9 +71,7 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) {headline} {scope ? ( - - {scope} - + {scope} ) : null}
      @@ -86,13 +85,11 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) - + /> ) } @@ -102,12 +99,10 @@ function RowFinding({ finding }: { finding: ReturnType - + {/* The finding carries the running pass's last step when it has one, so the row is not free to print a fixed string over the top of it. */} - - {finding.text} - + {finding.text} ) } @@ -125,9 +120,8 @@ function RowFinding({ finding }: { finding: ReturnType - {finding.text} + {finding.text} ) } diff --git a/apps/web/src/components/investigations/run-progress.tsx b/apps/web/src/components/investigations/run-progress.tsx index 8b85f6596e..7933964ba8 100644 --- a/apps/web/src/components/investigations/run-progress.tsx +++ b/apps/web/src/components/investigations/run-progress.tsx @@ -101,7 +101,7 @@ function Header({ count, stalled, silentFor }: { count: number; stalled: boolean function AwaitingFirstStep({ className }: { className?: string }) { return (
      - + Starting the pass
      ) diff --git a/apps/web/src/components/investigations/verdict-card.tsx b/apps/web/src/components/investigations/verdict-card.tsx index 0205f24a95..911bdd5e6d 100644 --- a/apps/web/src/components/investigations/verdict-card.tsx +++ b/apps/web/src/components/investigations/verdict-card.tsx @@ -2,6 +2,8 @@ import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { ReactNode } from "react" import type { V2Investigation } from "@maple/domain/http/v2" +import { Badge } from "@maple/ui/components/ui/badge" +import { TONE_TEXT, type Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { toEpochMs } from "@maple/ui/lib/time-format" @@ -141,7 +143,10 @@ function DiagnosedVerdict({ investigation }: { investigation: V2Investigation }) {report.severityAssessment ? ( {SEVERITY_LABEL[report.severityAssessment]} @@ -218,13 +223,13 @@ function NextActions({ actions }: { actions: ReadonlyArray }) { } /** The badge tones are backgrounds; the stat column wants the text colour alone. */ -const SEVERITY_TEXT_TONE: Record = { - critical: "text-destructive", - high: "text-destructive", - medium: "text-severity-warn", - low: "text-muted-foreground", - unclassified: "text-muted-foreground", -} satisfies Record +const SEVERITY_TEXT_TONE: Record = { + critical: "crit", + high: "crit", + medium: "warn", + low: "neutral", + unclassified: "neutral", +} satisfies Record /* ------------------------------------------------------------------------------------------------- * Investigating @@ -247,7 +252,7 @@ function InvestigatingVerdict({ investigation }: { investigation: V2Investigatio > - + Investigating @@ -306,9 +311,9 @@ function FailedVerdict({ investigation }: { investigation: V2Investigation }) { {/* The raw error was on the wire and rendered nowhere but a toast. */} {investigation.error ? (
      - + reason - + {investigation.error} diff --git a/apps/web/src/components/logs/log-hero-header.tsx b/apps/web/src/components/logs/log-hero-header.tsx index 4e1b628b50..e976d47555 100644 --- a/apps/web/src/components/logs/log-hero-header.tsx +++ b/apps/web/src/components/logs/log-hero-header.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react" +import { useMemo, useState, type CSSProperties } from "react" import { XmarkIcon, ChevronDownIcon, ChevronUpIcon } from "@/components/icons" import { Badge } from "@maple/ui/components/ui/badge" @@ -9,19 +9,20 @@ import { cn } from "@maple/ui/lib/utils" import { CopyableValue, tryParseJson } from "@/components/attributes" import { highlightCode } from "@/lib/sugar-high" import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" +import { SEVERITY_COLORS } from "@maple/ui/lib/severity" import type { Log } from "@/api/warehouse/logs" import { ServiceDot } from "@maple/ui/components/service-dot" import { LogTextButton } from "./log-text-button" -const HERO_TONE: Record = { - TRACE: "bg-severity-trace/5 border-severity-trace/20", - DEBUG: "bg-severity-debug/5 border-severity-debug/20", - INFO: "bg-severity-info/5 border-severity-info/20", - WARN: "bg-severity-warn/5 border-severity-warn/20", - WARNING: "bg-severity-warn/5 border-severity-warn/20", - ERROR: "bg-severity-error/5 border-severity-error/20", - FATAL: "bg-severity-fatal/5 border-severity-fatal/20", -} satisfies Record +/** A faint wash of the severity colour behind the hero; unknown levels keep the plain border. */ +function heroToneStyle(severity: string): CSSProperties | undefined { + const color = SEVERITY_COLORS[severity.toUpperCase()] + if (color === undefined) return undefined + return { + backgroundColor: `color-mix(in oklch, ${color} 5%, transparent)`, + borderColor: `color-mix(in oklch, ${color} 20%, transparent)`, + } +} const BODY_LINE_THRESHOLD = 280 @@ -37,7 +38,6 @@ interface LogHeroHeaderProps { export function LogHeroHeader({ log, showClose = true }: LogHeroHeaderProps) { const [expanded, setExpanded] = useState(false) - const tone = HERO_TONE[log.severityText.toUpperCase()] ?? "border-border" const body = log.body ?? "" // A JSON body (object/array) is pretty-printed and syntax-highlighted, like @@ -76,7 +76,7 @@ export function LogHeroHeader({ log, showClose = true }: LogHeroHeaderProps) { ) return ( -
      +
      }> @@ -84,7 +84,7 @@ export function LogHeroHeader({ log, showClose = true }: LogHeroHeaderProps) { OTel severity number {log.severityNumber} - + {log.serviceName} diff --git a/apps/web/src/components/logs/log-meta-strip.tsx b/apps/web/src/components/logs/log-meta-strip.tsx index 21dcd41aa3..ac05b60a32 100644 --- a/apps/web/src/components/logs/log-meta-strip.tsx +++ b/apps/web/src/components/logs/log-meta-strip.tsx @@ -3,6 +3,7 @@ import { ClockIcon, ExternalLinkIcon, LinkIcon, PulseIcon } from "@/components/i import { CopyableValue } from "@/components/attributes" import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { shortId } from "@maple/ui/lib/ids" import { formatTimestampInTimezone } from "@/lib/timezone-format" import { encodeLogKey } from "@/lib/log-key" import { buildLogJsonPayload } from "./log-raw-panel" @@ -42,13 +43,13 @@ export function LogMetaStrip({ log, timeZone, showOpenFullPage = true }: LogMeta title={`View trace ${log.traceId}`} > - trace:{log.traceId.slice(0, 8)} + trace:{shortId(log.traceId, "trace")} )} {log.spanId && ( - span:{log.spanId.slice(0, 8)} + span:{shortId(log.spanId, "span")} )} diff --git a/apps/web/src/components/logs/log-trace-timeline.tsx b/apps/web/src/components/logs/log-trace-timeline.tsx index da67efe926..4270b64319 100644 --- a/apps/web/src/components/logs/log-trace-timeline.tsx +++ b/apps/web/src/components/logs/log-trace-timeline.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react" +import { shortId } from "@maple/ui/lib/ids" import { Result, useAtomValue } from "@/lib/effect-atom" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" @@ -136,7 +137,7 @@ export function LogTraceTimeline({ currentLog, onLogSelect }: LogTraceTimelinePr
      {spanNameMap.get(spanChanged) ?? - spanChanged.slice(0, 8)} + shortId(spanChanged, "span")}
      diff --git a/apps/web/src/components/logs/logs-table.tsx b/apps/web/src/components/logs/logs-table.tsx index 265a8219ac..e5386a8381 100644 --- a/apps/web/src/components/logs/logs-table.tsx +++ b/apps/web/src/components/logs/logs-table.tsx @@ -24,9 +24,10 @@ import { useListNavigation } from "@/hooks/use-list-navigation" import { pickImportantAttributes } from "@/lib/log-attributes" import { LogAttributeChip } from "./log-attribute-chip" import { HighlightedText } from "./highlighted-text" -import { shortTraceId } from "@/lib/logs/log-search-query" +import { shortId } from "@maple/ui/lib/ids" import { ChevronRightIcon } from "@/components/icons" import { ErrorState } from "@/components/common/error-state" +import { ListFooter } from "@maple/ui/components/ui/list-footer" import { usePageScrolledReporter } from "@maple/ui/components/ui/page-layout" import { DocsLink } from "@/components/common/docs-link" import { @@ -451,7 +452,7 @@ export function LogsTableView({ const virtualItems = virtualizer.getVirtualItems() const scopeSuffix = [ - traceId ? ` in trace ${shortTraceId(traceId)}` : "", + traceId ? ` in trace ${shortId(traceId, "trace")}` : "", searchText ? ` matching “${searchText}”` : "", ].join("") @@ -689,11 +690,14 @@ export function LogsTableView({
      -
      - {isCapped - ? `Showing first ${allData.length.toLocaleString()} logs${scopeSuffix} — narrow filters to continue` - : `Showing ${allData.length.toLocaleString()} logs${scopeSuffix}${!hasNextPage ? " (all loaded)" : ""}`} -
      +
      diff --git a/apps/web/src/components/metrics/metric-breakdown.tsx b/apps/web/src/components/metrics/metric-breakdown.tsx index 23eb2feddc..ce04277c05 100644 --- a/apps/web/src/components/metrics/metric-breakdown.tsx +++ b/apps/web/src/components/metrics/metric-breakdown.tsx @@ -1,7 +1,9 @@ import * as React from "react" import { Result, useAtomValue } from "@/lib/effect-atom" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { Meter } from "@maple/ui/components/ui/meter" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { Combobox, ComboboxContent, @@ -104,33 +106,30 @@ export function MetricBreakdown({ ) return ( -
      -
      - - Top values{" "} - - · {breakdownAggregation(draft.aggregation)} per value - - - { - if (value) onBreakdownKeyChange(value) - }} - > - - - - {SERVICE_KEY} - {attributeKeys.map((key) => ( - - {key} - - ))} - - - -
      + + { + if (value) onBreakdownKeyChange(value) + }} + > + + + + {SERVICE_KEY} + {attributeKeys.map((key) => ( + + {key} + + ))} + + + + } + />
      onAddFilter(groupBy, value)} />
      -
      + ) } @@ -165,11 +164,7 @@ function BreakdownBars({ return Result.builder(result) .onInitial(() => ( -
      - {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
      + )) .onError((error) => (

      {displayError(error).message}

      @@ -198,9 +193,11 @@ function BreakdownBars({ title={`Filter to ${filterKey} = "${row.name}"`} className="group relative flex w-full items-center justify-between gap-3 rounded-sm px-2 py-1 text-left transition-colors hover:bg-accent" > -
      {row.name} diff --git a/apps/web/src/components/metrics/metric-detail.tsx b/apps/web/src/components/metrics/metric-detail.tsx index 1accb01387..ca88954627 100644 --- a/apps/web/src/components/metrics/metric-detail.tsx +++ b/apps/web/src/components/metrics/metric-detail.tsx @@ -5,6 +5,7 @@ import { Result } from "@/lib/effect-atom" import { getChartById } from "@maple/ui/components/charts/registry" import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { ErrorState } from "@/components/common/error-state" import { DocsLink } from "@/components/common/docs-link" import { MetricQueryControls, type MetricQueryPatch } from "./metric-query-controls" @@ -276,11 +277,11 @@ function MetricChart({ }` return ( -
      -
      - {queryLabel} - {unit && unit: {unit}} -
      + + {queryLabel}} + action={unit ? unit: {unit} : undefined} + />
      {Result.builder(result) .onInitial(() => ) @@ -304,6 +305,6 @@ function MetricChart({ ) .render()}
      -
      + ) } diff --git a/apps/web/src/components/metrics/metric-metadata-panel.tsx b/apps/web/src/components/metrics/metric-metadata-panel.tsx index 15522836c0..3916faf6b7 100644 --- a/apps/web/src/components/metrics/metric-metadata-panel.tsx +++ b/apps/web/src/components/metrics/metric-metadata-panel.tsx @@ -2,6 +2,7 @@ import { formatNumber } from "@maple/ui/lib/format" import { Result, useAtomValue } from "@/lib/effect-atom" import { Badge } from "@maple/ui/components/ui/badge" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { MetricTypeBadge } from "./metric-type-badge" import type { MetricCatalogSummary } from "./metric-detail" import { getMetricAttributeKeysResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -33,12 +34,12 @@ export function MetricMetadataPanel({ summary, startTime, endTime }: MetricMetad
      {summary.unit && ( - + {summary.unit} )} {summary.metricType === "sum" && ( - + {summary.isMonotonic ? "monotonic" : "non-monotonic"} )} @@ -48,30 +49,23 @@ export function MetricMetadataPanel({ summary, startTime, endTime }: MetricMetad )}
      -
      -
      -
      Datapoints in range
      -
      {formatNumber(summary.dataPointCount)}
      -
      -
      -
      First seen
      -
      - {formatTimestampInTimezone(summary.firstSeen, { - timeZone: effectiveTimezone, - withYear: true, - })} -
      -
      -
      -
      Last seen
      -
      - {formatTimestampInTimezone(summary.lastSeen, { - timeZone: effectiveTimezone, - withYear: true, - })} -
      -
      -
      + + + {formatNumber(summary.dataPointCount)} + + + {formatTimestampInTimezone(summary.firstSeen, { + timeZone: effectiveTimezone, + withYear: true, + })} + + + {formatTimestampInTimezone(summary.lastSeen, { + timeZone: effectiveTimezone, + withYear: true, + })} + + {summary.services.length > 0 && (
      @@ -81,7 +75,7 @@ export function MetricMetadataPanel({ summary, startTime, endTime }: MetricMetad

      {summary.services.map((service) => ( - + {service} ))} diff --git a/apps/web/src/components/metrics/metric-preview-card.tsx b/apps/web/src/components/metrics/metric-preview-card.tsx index e2cc5bcbf7..1143d38487 100644 --- a/apps/web/src/components/metrics/metric-preview-card.tsx +++ b/apps/web/src/components/metrics/metric-preview-card.tsx @@ -4,6 +4,7 @@ import { Badge } from "@maple/ui/components/ui/badge" import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" import { MetricTypeBadge } from "./metric-type-badge" import type { MetricSparklinePoint } from "@/api/warehouse/metrics" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" export interface MetricPreviewEntry { metricName: string @@ -55,9 +56,7 @@ export function MetricPreviewCard({ entry, points, loading, onOpen }: MetricPrev className="group flex flex-col gap-2 rounded-md border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40" >
      - - {entry.metricName} - +
      @@ -83,7 +82,7 @@ export function MetricPreviewCard({ entry, points, loading, onOpen }: MetricPrev {entry.metricUnit && ( - + {entry.metricUnit} )} diff --git a/apps/web/src/components/metrics/metric-preview-grid.tsx b/apps/web/src/components/metrics/metric-preview-grid.tsx index 397812b8c9..05febdb156 100644 --- a/apps/web/src/components/metrics/metric-preview-grid.tsx +++ b/apps/web/src/components/metrics/metric-preview-grid.tsx @@ -1,8 +1,8 @@ import * as React from "react" import { Result, useAtomValue } from "@/lib/effect-atom" -import { Button } from "@maple/ui/components/ui/button" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { ListFooter } from "@maple/ui/components/ui/list-footer" import { SignalEmptyState } from "@/components/common/signal-empty-state" import { ErrorState } from "@/components/common/error-state" import { MetricPreviewCard, type MetricPreviewEntry } from "./metric-preview-card" @@ -77,11 +77,11 @@ export function MetricPreviewGrid({ return Result.builder(catalogResult) .onInitial(() => ( -
      - {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
      + )) .onError((error) => ) .onSuccess((response) => { @@ -117,21 +117,15 @@ export function MetricPreviewGrid({ ))}
      -
      - - Showing {Math.min(visiblePages * PAGE_SIZE, entries.length)} of {entries.length}{" "} - metrics - - {hasMore && ( - - )} -
      + setVisiblePages((current) => current + 1)} + align="start" + className="justify-start p-0 text-sm" + />
      ) }) diff --git a/apps/web/src/components/metrics/metrics-summary-cards.tsx b/apps/web/src/components/metrics/metrics-summary-cards.tsx index 91942f806a..086f9eb704 100644 --- a/apps/web/src/components/metrics/metrics-summary-cards.tsx +++ b/apps/web/src/components/metrics/metrics-summary-cards.tsx @@ -1,36 +1,18 @@ import { formatNumber } from "@maple/ui/lib/format" import { Result, useAtomValue, useAtomRefresh } from "@/lib/effect-atom" -import { PlusIcon, ChartLineIcon, ChartBarIcon, ChartBarTrendUpIcon } from "@/components/icons" -import { Card, CardContent, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { ErrorState } from "@/components/common/error-state" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { type ListMetricsInput } from "@/api/warehouse/metrics" import { getMetricsSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" export type MetricType = ListMetricsInput["metricType"] const cardConfig = [ - { - title: "Sum Metrics", - key: "sum" as const, - icon: PlusIcon, - }, - { - title: "Gauge Metrics", - key: "gauge" as const, - icon: ChartLineIcon, - }, - { - title: "Histogram", - key: "histogram" as const, - icon: ChartBarIcon, - }, - { - title: "Exp Histogram", - key: "exponential_histogram" as const, - icon: ChartBarTrendUpIcon, - }, + { title: "Sum Metrics", key: "sum" as const }, + { title: "Gauge Metrics", key: "gauge" as const }, + { title: "Histogram", key: "histogram" as const }, + { title: "Exp Histogram", key: "exponential_histogram" as const }, ] interface MetricsSummaryCardsProps { @@ -51,21 +33,7 @@ export function MetricsSummaryCards({ const refreshSummary = useAtomRefresh(summaryAtom) return Result.builder(summaryResult) - .onInitial(() => ( -
      - {cardConfig.map((card) => ( - - - - - - - - - - ))} -
      - )) + .onInitial(() => ) .onError((error) => ( + {cardConfig.map((card) => { const data = summaryByType[card.key] const isSelected = selectedType === card.key - return ( - onSelectType(isSelected ? null : card.key)} - > - - {card.title} - - - -
      - {formatNumber(data?.dataPointCount ?? 0)} -
      -

      - {data?.metricCount ?? 0} unique metrics -

      -
      -
      + onSelectType(isSelected ? null : card.key)} + /> ) })} -
      + ) }) .render() diff --git a/apps/web/src/components/metrics/metrics-table.tsx b/apps/web/src/components/metrics/metrics-table.tsx index 2d3db7f601..a1c5dc7622 100644 --- a/apps/web/src/components/metrics/metrics-table.tsx +++ b/apps/web/src/components/metrics/metrics-table.tsx @@ -5,10 +5,11 @@ import { useState } from "react" import { Result, useAtomValue } from "@/lib/effect-atom" -import { Button } from "@maple/ui/components/ui/button" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { SignalEmptyState } from "@/components/common/signal-empty-state" import { Badge } from "@maple/ui/components/ui/badge" +import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { MetricTypeBadge } from "./metric-type-badge" import { type Metric, type ListMetricsInput } from "@/api/warehouse/metrics" import { listMetricsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -138,12 +139,7 @@ export function MetricsTable({ >
      - - {metric.metricName} - + {metric.metricDescription && ( {metric.metricDescription} @@ -156,7 +152,7 @@ export function MetricsTable({ {metric.serviceName ? ( - +
      -
      - - Showing {metrics.length} metrics - {hasMore ? " — more available" : ""} - - {hasMore && ( - - )} -
      + setLimit((current) => current + PAGE_SIZE)} + align="start" + className="justify-start p-0 text-sm" + />
      ) } diff --git a/apps/web/src/components/onboarding/step-region.tsx b/apps/web/src/components/onboarding/step-region.tsx index f122ad1db1..79eb2c3b1d 100644 --- a/apps/web/src/components/onboarding/step-region.tsx +++ b/apps/web/src/components/onboarding/step-region.tsx @@ -179,9 +179,15 @@ export function StepRegion() {

      -
      diff --git a/apps/web/src/components/region/region-badge.tsx b/apps/web/src/components/region/region-badge.tsx index ce47332efd..e34b933ef5 100644 --- a/apps/web/src/components/region/region-badge.tsx +++ b/apps/web/src/components/region/region-badge.tsx @@ -1,4 +1,4 @@ -import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" import { MAPLE_REGION_LABELS, type MapleRegion } from "@/lib/region" @@ -6,14 +6,14 @@ import { MAPLE_REGION_LABELS, type MapleRegion } from "@/lib/region" export function RegionBadge({ region, className }: { region: MapleRegion; className?: string }) { const label = MAPLE_REGION_LABELS[region] return ( - {label.short} - + ) } diff --git a/apps/web/src/components/releases/release-deploy-overview.tsx b/apps/web/src/components/releases/release-deploy-overview.tsx index 3adff5bb5e..b17fd4474b 100644 --- a/apps/web/src/components/releases/release-deploy-overview.tsx +++ b/apps/web/src/components/releases/release-deploy-overview.tsx @@ -4,16 +4,14 @@ import type { ErrorIssueDocument } from "@maple/domain/http" import { ServiceDot } from "@maple/ui/components/service-dot" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" +import { RelativeTime } from "@/components/common/relative-time" import { ErrorState } from "@/components/common/error-state" import { SectionCard } from "@/components/services/section-card" import type { TimeRangeSearch } from "@/components/time-range-picker/search" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" -import { formatTimestampInTimezone } from "@/lib/timezone-format" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { errorIssueFromV2 } from "@/lib/services/error-issues" import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -32,7 +30,8 @@ import { type ReleaseServiceImpact, } from "./release-model" import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { eyebrowVariants } from "@maple/ui/components/ui/eyebrow" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" const ISSUE_LIMIT = 100 const NO_COUNTS: ReadonlyMap = new Map() @@ -97,22 +96,18 @@ export function ReleaseDeployOverview({ } function DeploySummary({ group }: { group: ReleaseGroup }) { - const { effectiveTimezone } = useTimezonePreference() const services = new Set(group.services.map((impact) => impact.serviceName)).size const flagged = group.services.filter((impact) => impact.health !== "healthy").length return (
      first seen{" "} - - {formatRelativeTimeOrDate(group.firstSeen, undefined, effectiveTimezone)} - + /> {services}{" "} @@ -183,18 +178,18 @@ function DeployServices({ } > - - - - - - - - - - - - +
      ServiceReplacedRequestsError ratep95Live share
      + + + Service + Replaced + Requests + Error rate + p95 + Live share + + + {rows.map((impact) => ( ))} - -
      + + ) } @@ -219,8 +214,8 @@ function DeployServiceRow({ timeSearch: TimeRangeSearch }) { return ( - - + + )} - - + + {impact.baseline ? shortReleaseLabel(impact.baseline.commitSha) : "-"} - - + + {formatNumber(impact.spanCount)} - - + + - - + + - - + 0 && impact.share < ROLLOUT_COMPLETE_SHARE ? "text-primary" : "text-muted-foreground", )} > {impact.share === undefined ? "-" : `${Math.round(impact.share * 100)}%`} - - + + ) } diff --git a/apps/web/src/components/releases/release-detail-panels.tsx b/apps/web/src/components/releases/release-detail-panels.tsx index abd7381ca0..9cd5839447 100644 --- a/apps/web/src/components/releases/release-detail-panels.tsx +++ b/apps/web/src/components/releases/release-detail-panels.tsx @@ -1,17 +1,16 @@ import { Link } from "@tanstack/react-router" import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" +import { RelativeTime } from "@/components/common/relative-time" import { SectionCard } from "@/components/services/section-card" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" import { ReleaseHealthPill, releaseHealthFigure } from "./release-health" import { MIN_COMPARE_SPANS, shortReleaseLabel, type ReleaseServiceImpact } from "./release-model" import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { eyebrowVariants } from "@maple/ui/components/ui/eyebrow" interface ComparisonRow { label: string @@ -132,45 +131,36 @@ export function ReleaseComparison({ impact }: { impact: ReleaseServiceImpact }) against. Widen the time range to include the version it replaced. ) : ( - - - - - - - - - +
      - - - {shortReleaseLabel(impact.commitSha)} - - - - {shortReleaseLabel(baseline.commitSha)} - - Change
      + + + + + {shortReleaseLabel(impact.commitSha)} + + + {shortReleaseLabel(baseline.commitSha)} + + Change + + + {rows.map((row) => ( - - - - - - + + ))} - -
      {row.label} + + {row.label} + {row.format(row.value)} - + + {row.baseline === undefined ? "—" : row.format(row.baseline)} - + + {formatChange(row)} -
      + + )} {baseline !== undefined && !comparable ? (
      @@ -197,7 +187,6 @@ export function ReleaseVersionsRail({ environments, timeSearch, }: ReleaseVersionsRailProps) { - const { effectiveTimezone } = useTimezonePreference() const sorted = impacts.toSorted((a, b) => a.firstSeen < b.firstSeen ? 1 : a.firstSeen > b.firstSeen ? -1 : 0, ) @@ -241,15 +230,12 @@ export function ReleaseVersionsRail({ {formatNumber(version.spanCount)} - - {formatRelativeTimeOrDate(version.firstSeen, undefined, effectiveTimezone)} - + /> ) })} diff --git a/apps/web/src/components/releases/release-health.tsx b/apps/web/src/components/releases/release-health.tsx index 7bf69ace49..46b00b414a 100644 --- a/apps/web/src/components/releases/release-health.tsx +++ b/apps/web/src/components/releases/release-health.tsx @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { Badge } from "@maple/ui/components/ui/badge" import type { ReleaseHealth } from "./release-model" @@ -41,16 +42,15 @@ interface ReleaseHealthPillProps { export function ReleaseHealthPill({ health, label, className }: ReleaseHealthPillProps) { return ( - {label ?? RELEASE_HEALTH_LABEL[health]} - + ) } diff --git a/apps/web/src/components/releases/release-issues-panel.tsx b/apps/web/src/components/releases/release-issues-panel.tsx index 3cf4a6d2ce..540f5b452d 100644 --- a/apps/web/src/components/releases/release-issues-panel.tsx +++ b/apps/web/src/components/releases/release-issues-panel.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react" import type { ErrorIssueDocument } from "@maple/domain/http" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { SectionCard } from "@/components/services/section-card" import { IssueLine } from "@/components/services/issue-line" @@ -104,11 +104,7 @@ function PanelsSkeleton() {
      {["New on this version", "Regressed on this version", "Still occurring"].map((title) => ( -
      - {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
      +
      ))}
      diff --git a/apps/web/src/components/releases/release-model.ts b/apps/web/src/components/releases/release-model.ts index f36ff13d24..d66d6b3618 100644 --- a/apps/web/src/components/releases/release-model.ts +++ b/apps/web/src/components/releases/release-model.ts @@ -5,6 +5,7 @@ import { startOfDayInTimeZone } from "@maple/query-engine/datetime" import { toEpochMs } from "@maple/ui/lib/time-format" import type { Release, ReleaseTimelineBucket } from "@/api/warehouse/releases" +import { shortId } from "@maple/ui/lib/ids" /** * Health, worst first. A single band per release so the sidebar facet and the @@ -297,7 +298,7 @@ export function releaseFacetCounts(groups: ReadonlyArray): Release /** A 40-hex git sha reads as its 7-char short form; tags and versions stay verbatim. */ export function shortReleaseLabel(sha: string): string { - return /^[0-9a-f]{40}$/i.test(sha) ? sha.slice(0, 7) : sha + return /^[0-9a-f]{40}$/i.test(sha) ? shortId(sha, "sha") : sha } /** diff --git a/apps/web/src/components/releases/releases-live-now.tsx b/apps/web/src/components/releases/releases-live-now.tsx index 0947cae46c..cdd60a6012 100644 --- a/apps/web/src/components/releases/releases-live-now.tsx +++ b/apps/web/src/components/releases/releases-live-now.tsx @@ -5,6 +5,7 @@ import { cn } from "@maple/ui/lib/utils" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { ROLLOUT_COMPLETE_SHARE, shortReleaseLabel, type LiveVersion } from "./release-model" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Button } from "@maple/ui/components/ui/button" /** Services past this fold into a count; the behind ones sort first so they never fold. */ const MAX_SHOWN = 16 @@ -38,20 +39,26 @@ export function ReleasesLiveNow({ live, timeSearch, environments }: ReleasesLive
      {shown.map((version) => ( - 0 - ? `${version.behind} newer ${version.behind === 1 ? "release" : "releases"} reached the services ${version.serviceName} usually ships with, but not ${version.serviceName}` - : undefined - } + variant="outline" + size="xs" className={cn( - "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "font-normal", version.behind > 0 && "border-severity-warn/40 bg-severity-warn/5", )} + render={ + 0 + ? `${version.behind} newer ${version.behind === 1 ? "release" : "releases"} reached the services ${version.serviceName} usually ships with, but not ${version.serviceName}` + : undefined + } + /> + } > {version.serviceName} @@ -68,7 +75,7 @@ export function ReleasesLiveNow({ live, timeSearch, environments }: ReleasesLive {version.behind} behind ) : null} - + ))} {hidden > 0 ? ( +{hidden} diff --git a/apps/web/src/components/releases/releases-table.tsx b/apps/web/src/components/releases/releases-table.tsx index 18c071f960..8233a530c9 100644 --- a/apps/web/src/components/releases/releases-table.tsx +++ b/apps/web/src/components/releases/releases-table.tsx @@ -3,13 +3,12 @@ import { Link } from "@tanstack/react-router" import { ServiceDot } from "@maple/ui/components/service-dot" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" +import { RelativeTime } from "@/components/common/relative-time" import type { VcsCommitDetailResponse, VcsCommitRangeResponse } from "@maple/domain/http" import { ChevronRightIcon } from "@/components/icons" import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" import { Result, useAtomValue } from "@/lib/effect-atom" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { @@ -340,18 +339,8 @@ function ReleasesTableRows({ - - {formatRelativeTimeOrDate( - group.firstSeen, - undefined, - effectiveTimezone, - )} + + @@ -421,18 +410,8 @@ function ReleasesTableRows({ - - {formatRelativeTimeOrDate( - service.firstSeen, - undefined, - effectiveTimezone, - )} + + -
      - Deploys over time -
      - {RELEASE_HEALTH_ORDER.map((band) => ( - - - {RELEASE_HEALTH_LABEL[band]} - - ))} -
      -
      + + + {RELEASE_HEALTH_ORDER.map((band) => ( + + + {RELEASE_HEALTH_LABEL[band]} + + ))} +
      + } + />
      {visible.map((lane, laneIndex) => (
      - - {lane.serviceName} - +
      -
      + ) } diff --git a/apps/web/src/components/replays/replay-editor-timeline.tsx b/apps/web/src/components/replays/replay-editor-timeline.tsx index bad0c320b4..80efee97b4 100644 --- a/apps/web/src/components/replays/replay-editor-timeline.tsx +++ b/apps/web/src/components/replays/replay-editor-timeline.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Link } from "@tanstack/react-router" import { Schema } from "effect" import { TraceId } from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" @@ -287,9 +288,9 @@ const TracesTrack = React.memo(function TracesTrack({ Traces {count != null && count > 0 && ( - + {count} - + )}
      ) diff --git a/apps/web/src/components/replays/session-detail-parts.tsx b/apps/web/src/components/replays/session-detail-parts.tsx index e44886a8d7..19a59884e7 100644 --- a/apps/web/src/components/replays/session-detail-parts.tsx +++ b/apps/web/src/components/replays/session-detail-parts.tsx @@ -1,10 +1,11 @@ import type { ReactNode } from "react" +import { shortId } from "@maple/ui/lib/ids" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { GlobeIcon, ClockIcon } from "@/components/icons" import { CopyButton } from "@maple/ui/components/ui/copy-button" -import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { formatSessionDuration, gradientFor, hostFromUrl } from "./replay-format" import { parseChTimestampMs } from "./replay-timeline" import { ErrorCountPill, LivePill } from "./session-pills" @@ -80,16 +81,13 @@ export function SessionIdentityBar({ {hostFromUrl(urlInitial)} {startedValid && ( - + - started {formatRelativeFrom(startedEpoch)} + )} - {sessionId.slice(0, 8)} + {shortId(sessionId, "session", { length: 8 })} ) } +// Status 0 is a request that never got a response: as bad as a 5xx. function statusTone(status: number): string { - if (status >= 500 || status === 0) return "text-destructive" - if (status >= 400) return "text-warning-foreground" - return "text-success-foreground" + const tone = status === 0 ? "crit" : httpStatusTone(status) + return tone === "neutral" ? "text-success-foreground" : TONE_TEXT[tone] } /** @@ -601,7 +605,7 @@ function EventProps({ attributes }: { attributes?: string }) { } function isFailedRequest(ev: EventRow): boolean { - return ev.type === "network" && (ev.netStatus >= 500 || ev.netStatus === 0) + return ev.type === "network" && (httpStatusTone(ev.netStatus) === "crit" || ev.netStatus === 0) } /** @@ -735,18 +739,15 @@ function formatNetDuration(ms: number): string { /** Relative-duration micro-bar for the Network view — slow requests jump out * without reading every number. Log-free linear scale capped at 3s. */ function NetDurationBar({ durationMs, failed }: { durationMs: number; failed: boolean }) { - const pct = Math.min(100, Math.max(2, (durationMs / NET_BAR_MAX_MS) * 100)) const slow = durationMs >= 1000 return ( - - - + ) } @@ -900,7 +901,7 @@ function SessionTab({ sessionId, session }: { sessionId: string; session: Sessio className="truncate font-mono text-xs text-primary underline-offset-2 hover:underline" title="All sessions from this visitor" > - {session.visitorId.slice(0, 12)}… + {shortId(session.visitorId, "generic", { ellipsis: true })} @@ -990,7 +991,7 @@ function SessionTab({ sessionId, session }: { sessionId: string; session: Sessio )} - {sessionId.slice(0, 12)}… + {shortId(sessionId, "session", { ellipsis: true })} -
      - - -
      +
      {active != null && ( {compact ? "LIVE" : "Live"} - + ) } export function ErrorCountPill({ count }: { count: number }) { return ( - + {count} error{count === 1 ? "" : "s"} - + ) } diff --git a/apps/web/src/components/replays/sessions-list.tsx b/apps/web/src/components/replays/sessions-list.tsx index ceab510d0b..3e05ada33c 100644 --- a/apps/web/src/components/replays/sessions-list.tsx +++ b/apps/web/src/components/replays/sessions-list.tsx @@ -2,7 +2,8 @@ import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { useCallback, useState } from "react" import { useNavigate } from "@tanstack/react-router" import { useVirtualizer } from "@tanstack/react-virtual" -import { Spinner } from "@maple/ui/components/ui/spinner" +import { Badge, badgeVariants } from "@maple/ui/components/ui/badge" +import { ListFooter, LoadMoreButton, LoadingMoreRow } from "@maple/ui/components/ui/list-footer" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { cn } from "@maple/ui/lib/utils" import { SignalEmptyState } from "@/components/common/signal-empty-state" @@ -262,29 +263,23 @@ export function SessionsList({ {hasMore && (manualLoadMore ? ( - +
      + onReachEnd?.()} + /> +
      ) : ( ))} {isCapped && ( -

      - Showing first {sessions.length.toLocaleString()} sessions. Narrow filters to continue. -

      + )} {loadingMore && ( -
      - - Loading more sessions… -
      + )}
      ) @@ -438,12 +433,15 @@ function SessionListRow({ {formatSessionDuration(durationMs)}
      {durationP95 != null && durationP95 > 0 && durationMs != null && durationMs > durationP95 && ( - long - + )} {session.pageViews || 1} page{(session.pageViews || 1) === 1 ? "" : "s"} ·{" "} @@ -507,10 +505,7 @@ function SessionTags({ return ( <> {SESSION_TAG_ORDER.filter((tag) => tags.includes(tag)).map((tag) => { - const className = cn( - "inline-flex shrink-0 items-center rounded-full px-1.5 py-px text-[10px] font-medium", - SESSION_TAG_STYLES[tag], - ) + const className = cn(badgeVariants({ shape: "pill", size: "xs" }), SESSION_TAG_STYLES[tag]) if (!onFilter) { return ( @@ -539,17 +534,22 @@ function SessionBadges({ session }: { session: SessionRow }) { <> {session.errorCount > 0 && } {session.traceCount > 0 && ( - + {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} - + )} {/* Metadata-only session — no rrweb chunks were ever written, so the detail page has no player. Flag it here rather than let the row look like every other (playable) session. */} {session.recorded === "false" && ( - + Transcript only - + )} ) diff --git a/apps/web/src/components/replays/trace-replay-link.tsx b/apps/web/src/components/replays/trace-replay-link.tsx index f76f3888d2..53a5231134 100644 --- a/apps/web/src/components/replays/trace-replay-link.tsx +++ b/apps/web/src/components/replays/trace-replay-link.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router" +import { Button } from "@maple/ui/components/ui/button" import { Result, useAtomValue } from "@/lib/effect-atom" import { getReplaysForTraceResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { EyeIcon } from "@/components/icons" @@ -17,13 +18,13 @@ export function TraceReplayLink({ traceId }: { traceId: string }) { const session = data.data[0] if (!session) return null return ( - } > View Session Replay - + ) }) // Stay silent on both loading and failure: a missing correlated replay and a diff --git a/apps/web/src/components/service-map/service-map-view.tsx b/apps/web/src/components/service-map/service-map-view.tsx index f748519279..fa2b5c159c 100644 --- a/apps/web/src/components/service-map/service-map-view.tsx +++ b/apps/web/src/components/service-map/service-map-view.tsx @@ -1,4 +1,6 @@ -import { formatLatency, formatPercent } from "@maple/ui/lib/format" +import { formatErrorRate, formatLatency, formatPercent } from "@maple/ui/lib/format" +import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" import { LatencyLineChart, QueryBuilderBarChart } from "@maple/ui/components/charts" import { ChartTooltipSuppressionProvider } from "@maple/ui/components/plot" import { LinkedCursorOverlay, linkedCursorChartProps, useLinkedCursor } from "@/hooks/use-linked-cursor" @@ -27,7 +29,10 @@ import { EmptyTitle, EmptyMessage, } from "@maple/ui/components/ui/empty" +import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { StatusDot } from "@maple/ui/components/ui/status-dot" @@ -239,15 +244,9 @@ function ServiceDetailPanel({
      Error Rate 0.05 - ? "text-severity-error" - : errorRate > 0.01 - ? "text-severity-warn" - : "text-foreground", - )} + className={errorRateClass(errorRate)} > - {(errorRate * 100).toFixed(1)}% + {formatErrorRate(errorRate)}
      @@ -299,15 +298,9 @@ function ServiceDetailPanel({ Error Rate 0.05 - ? "text-severity-error" - : cloudflare.errorRate > 0.01 - ? "text-severity-warn" - : "text-foreground", - )} + className={errorRateClass(cloudflare.errorRate)} > - {(cloudflare.errorRate * 100).toFixed(1)}% + {formatErrorRate(cloudflare.errorRate)}
      @@ -345,7 +338,7 @@ function ServiceDetailPanel({ {dependencies.map((dep) => { const depColor = getServiceColor(dep.targetService) const depErrorRate = dep.errorRate - const isError = depErrorRate > 0.05 + const isError = errorRateLevel(depErrorRate) === "crit" const safeDuration = Math.max(durationSeconds, 1) const depReqPerSec = dep.hasSampling ? dep.estimatedCallCount / safeDuration @@ -381,16 +374,9 @@ function ServiceDetailPanel({ {formatRate(depReqPerSec)} req/s 0.05 - ? "text-severity-error" - : depErrorRate > 0.01 - ? "text-severity-warn" - : "text-severity-info", - )} + className={cn("tabular-nums font-mono", errorRateClass(depErrorRate))} > - {(depErrorRate * 100).toFixed(1)}% + {formatErrorRate(depErrorRate)}
      @@ -439,16 +425,9 @@ function ServiceDetailPanel({ {formatRate(callerReqPerSec)} req/s 0.05 - ? "text-severity-error" - : callerErrorRate > 0.01 - ? "text-severity-warn" - : "text-severity-info", - )} + className={cn("tabular-nums font-mono", errorRateClass(callerErrorRate))} > - {(callerErrorRate * 100).toFixed(1)}% + {formatErrorRate(callerErrorRate)}
      @@ -919,10 +898,10 @@ function PlanetScaleSection({ .onError((error) => { const formatted = displayError(error) return ( -
      -

      {formatted.title}

      -

      {formatted.message}

      -
      + + {formatted.title} + {formatted.message} + ) }) .orElse(() => null)} @@ -941,9 +920,9 @@ function PlanetScaleSection({ {row.branch}
      {info?.production ? ( - + prod - + ) : null}
      @@ -984,9 +963,9 @@ function PlanetScaleSection({ {branch.name} {branch.production ? ( - + prod - + ) : null}
      @@ -1034,16 +1013,15 @@ function HyperdriveSection({ configs }: { configs: ReadonlyArray
      {config.name} - + {config.originScheme} - +
      - - {config.id.slice(0, 8)} - +
      @@ -1198,15 +1176,9 @@ function DatabaseDetailPanel({
      Error Rate 0.05 - ? "text-severity-error" - : metricErrorRate > 0.01 - ? "text-severity-warn" - : "text-foreground", - )} + className={errorRateClass(metricErrorRate)} > - {(metricErrorRate * 100).toFixed(1)}% + {formatErrorRate(metricErrorRate)}
      @@ -1269,10 +1241,10 @@ function DatabaseDetailPanel({ .onError((error) => { const formatted = displayError(error) return ( -
      -

      {formatted.title}

      -

      {formatted.message}

      -
      + + {formatted.title} + {formatted.message} + ) }) .orElse(() => null)} @@ -1293,18 +1265,7 @@ function DatabaseDetailPanel({

      {formatQueryLabel(query.queryLabel)}

      - 0.05 - ? "text-severity-error" - : query.errorRate > 0.01 - ? "text-severity-warn" - : "text-muted-foreground", - )} - > - {(query.errorRate * 100).toFixed(1)}% - +
      @@ -1370,16 +1331,9 @@ function DatabaseDetailPanel({ {formatRate(reqPerSec)} calls/s 0.05 - ? "text-severity-error" - : caller.errorRate > 0.01 - ? "text-severity-warn" - : "text-severity-info", - )} + className={cn("tabular-nums font-mono", errorRateClass(caller.errorRate))} > - {(caller.errorRate * 100).toFixed(1)}% + {formatErrorRate(caller.errorRate)}
      diff --git a/apps/web/src/components/service-map/three/spatial-layout.ts b/apps/web/src/components/service-map/three/spatial-layout.ts index 068de765f1..46582f0974 100644 --- a/apps/web/src/components/service-map/three/spatial-layout.ts +++ b/apps/web/src/components/service-map/three/spatial-layout.ts @@ -1,3 +1,4 @@ +import { errorRateLevel } from "@maple/ui/lib/error-rate" import type { Edge3D, Node3D } from "./types" import { computeTiers } from "./graph" import type { Vec3 } from "./types" @@ -132,14 +133,15 @@ export function spatialLayout(topology: SpatialTopology, view: SpatialView): Spa } export const nodeHeight = (node: Node3D) => 0.5 + Math.log10(Math.max(1, node.throughput)) * 0.48 -export const health = (rate: number) => (rate > 0.05 ? "degraded" : rate > 0.01 ? "elevated" : "healthy") +// Shared thresholds (lib/error-rate); the 3D scene keeps its own hex palette below. +const HEALTH_BY_LEVEL = { crit: "degraded", warn: "elevated", neutral: "healthy" } as const +export const health = (rate: number) => HEALTH_BY_LEVEL[errorRateLevel(rate)] export const HEALTH_COLOR = { healthy: "#79ad9b", elevated: "#d3a65c", degraded: "#dc7b6d" } as const const rateFormatter = new Intl.NumberFormat("en", { maximumFractionDigits: 1, notation: "compact" }) export const formatRate = (rate: number, estimated = false) => `${estimated ? "~" : ""}${rate > 0 && rate < 0.1 ? "<0.1" : rateFormatter.format(rate)}/s` export const formatLatency = (ms: number | undefined) => ms === undefined ? "—" : `${ms < 10 ? ms.toFixed(1) : Math.round(ms)} ms` -export const formatError = (rate: number) => `${(rate * 100).toFixed(rate < 0.01 ? 2 : 1)}%` export function connectedIds( topology: SpatialTopology, diff --git a/apps/web/src/components/service-map/three/viewport.tsx b/apps/web/src/components/service-map/three/viewport.tsx index a39e714e14..f7ced97c2b 100644 --- a/apps/web/src/components/service-map/three/viewport.tsx +++ b/apps/web/src/components/service-map/three/viewport.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from "react" import { useReducedMotion } from "motion/react" import { useTheme } from "@maple/ui/hooks/use-theme" import { Button } from "@maple/ui/components/ui/button" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { ArrowRotateAnticlockwiseIcon, CubeIcon, @@ -87,26 +88,25 @@ export function ServiceMap3DViewport({ return (
      -
      - - -
      + + {copy.encoding} diff --git a/apps/web/src/components/services/dependency-table.tsx b/apps/web/src/components/services/dependency-table.tsx index 70ea892d45..00bee93355 100644 --- a/apps/web/src/components/services/dependency-table.tsx +++ b/apps/web/src/components/services/dependency-table.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from "react" import { useNavigate } from "@tanstack/react-router" -import { cn } from "@maple/ui/lib/utils" import { Table, TableBody, TableCell, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" @@ -10,11 +9,10 @@ import { MobileListRow, MobileSortBar, SortableHead, - errorTone, - formatErrorRate, formatRate, type SortDir, } from "./service-table-cells" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" import { DependencyTypeBadge, type DependencyKind } from "./dependency-type-badge" import { ServiceDot } from "@maple/ui/components/service-dot" import { LatencyValue } from "@maple/ui/components/latency-value" @@ -145,7 +143,6 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre ) : ( sorted.map((row) => { - const tone = errorTone(row.errorRate) return ( - - {formatErrorRate(row.errorRate)} - + ) : ( sorted.map((row) => { - const tone = errorTone(row.errorRate) return ( handleRowClick(row)}>
      @@ -285,15 +272,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre err - - {formatErrorRate(row.errorRate)} - + p95 diff --git a/apps/web/src/components/services/issue-line.tsx b/apps/web/src/components/services/issue-line.tsx index 53ac2d00a3..71b970a6d7 100644 --- a/apps/web/src/components/services/issue-line.tsx +++ b/apps/web/src/components/services/issue-line.tsx @@ -2,10 +2,10 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument } from "@maple/domain/http" import { ServiceDot } from "@maple/ui/components/service-dot" import { formatNumber } from "@maple/ui/lib/format" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { SeverityBadge } from "@/components/errors/severity-badge" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { RelativeTime } from "@/components/common/relative-time" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" interface IssueLineProps { issue: ErrorIssueDocument @@ -17,7 +17,6 @@ interface IssueLineProps { /** One linked issue row: severity, title, occurrence count, last seen. */ export function IssueLine({ issue, occurrences, occurrencesTitle, showService }: IssueLineProps) { - const { effectiveTimezone } = useTimezonePreference() const title = issue.errorLabel || issue.exceptionType || issue.exceptionMessage || "Unknown error" return ( ) : null} - - {title} - + {formatNumber(occurrences ?? issue.occurrenceCount)}× - - {formatRelativeTimeOrDate(issue.lastSeenAt, undefined, effectiveTimezone)} - + ) } diff --git a/apps/web/src/components/services/service-api-tab.tsx b/apps/web/src/components/services/service-api-tab.tsx index 8fe49a26eb..8407e608f3 100644 --- a/apps/web/src/components/services/service-api-tab.tsx +++ b/apps/web/src/components/services/service-api-tab.tsx @@ -5,7 +5,9 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Button } from "@maple/ui/components/ui/button" import { Table, TableBody, TableCell, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { LatencyValue } from "@maple/ui/components/latency-value" -import { formatLatency } from "@maple/ui/lib/format" +import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" +import { errorRateClass, errorRateLevel } from "@maple/ui/lib/error-rate" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" import { Result } from "@/lib/effect-atom" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getServiceEndpointsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -17,8 +19,6 @@ import { MobileListRow, MobileSortBar, SortableHead, - errorTone, - formatErrorRate, formatRate, type SortDir, } from "./service-table-cells" @@ -26,6 +26,7 @@ import { ENDPOINTS_LIMIT, isTruncated, serviceEndpointsQueryInput } from "./serv import { callsPerSecond, operationTraceSearch, windowSeconds } from "./service-operations" import { normalizeTimestampInput } from "@/lib/timezone-format" import { groupEndpoints, leafLabel, type EndpointGroup, type EndpointSort } from "./endpoint-grouping" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" interface ServiceApiTabProps { serviceName: string @@ -65,13 +66,6 @@ const COLUMN = { p99: "w-[84px]", } as const -const errorToneClass = (rate: number): string => { - const tone = errorTone(rate) - if (tone === "error") return "text-severity-error" - if (tone === "warn") return "text-severity-warn" - return "text-muted-foreground/80" -} - export function ServiceApiTab({ serviceName, effectiveStartTime, @@ -200,7 +194,7 @@ export function ServiceApiTab({ {notes.map((note) => ( @@ -473,7 +467,7 @@ function GroupHeaderRow({ className={cn( numeric, "pr-1.5", - errorTone(group.totals.errorRate) !== "default" && errorToneClass(group.totals.errorRate), + errorRateLevel(group.totals.errorRate) !== "neutral" && errorRateClass(group.totals.errorRate), )} > {formatErrorRate(group.totals.errorRate)} @@ -511,10 +505,10 @@ function EndpointRow({ 0 ? "pl-6" : "pl-3")}>
      - + {head.length > 0 && {head}} {tail} - +
      @@ -530,11 +524,7 @@ function EndpointRow({ max={0.05} tone="errors" > - - {formatErrorRate(endpoint.errorRate)} - + @@ -630,9 +620,7 @@ function MobileGroup({ err - - {formatErrorRate(endpoint.errorRate)} - + p95 diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index d0386f90b8..9ca6cab60f 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -5,9 +5,10 @@ import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getServiceDependenciesBundleResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { toSingleDeploymentEnv } from "@/lib/services/environments" import { latencyToneClass } from "@maple/ui/lib/latency-tone" -import { formatLatency } from "@maple/ui/lib/format" +import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" import { normalizeTimestampInput } from "@/lib/timezone-format" import { DependencyTable, type DependencyRow } from "./dependency-table" +import { formatRate } from "./service-table-cells" import type { DependencyKind } from "./dependency-type-badge" import { dependencyDrillWhereClause } from "./dependency-drill" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" @@ -40,18 +41,6 @@ interface RawEdge { samplingWeight?: number } -function formatRate(value: number): string { - if (value >= 1000) return `${(value / 1000).toFixed(1)}k` - if (value >= 1) return value.toFixed(1) - return value.toFixed(2) -} - -function formatErrorRate(rate: number): string { - if (rate >= 0.01) return `${(rate * 100).toFixed(1)}%` - if (rate > 0) return "<1%" - return "0%" -} - export function ServiceDependenciesTab({ serviceName, startTime, diff --git a/apps/web/src/components/services/service-errors-panel.tsx b/apps/web/src/components/services/service-errors-panel.tsx index c98226acf1..cf4bc2e623 100644 --- a/apps/web/src/components/services/service-errors-panel.tsx +++ b/apps/web/src/components/services/service-errors-panel.tsx @@ -1,6 +1,6 @@ import { Link } from "@tanstack/react-router" import type { ErrorIssueDocument } from "@maple/domain/http" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" @@ -42,11 +42,7 @@ function PanelFrame({ children, detailLimited }: { children: React.ReactNode; de function PanelSkeleton() { return ( -
      - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
      +
      ) } diff --git a/apps/web/src/components/services/service-operations-tab.tsx b/apps/web/src/components/services/service-operations-tab.tsx index c9dea97ea4..192802a552 100644 --- a/apps/web/src/components/services/service-operations-tab.tsx +++ b/apps/web/src/components/services/service-operations-tab.tsx @@ -8,11 +8,10 @@ import { MobileListRow, MobileSortBar, SortableHead, - errorTone, - formatErrorRate, formatRate, type SortDir, } from "./service-table-cells" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Sparkline } from "@maple/ui/components/ui/gradient-chart" @@ -203,7 +202,6 @@ export function ServiceOperationsTab({ ) : ( sorted.map((op) => { - const tone = errorTone(op.errorRate) return ( - - {formatErrorRate(op.errorRate)} - + No operations recorded in this window. ) : ( sorted.map((op) => { - const tone = errorTone(op.errorRate) return ( handleRowClick(op)}> @@ -309,15 +297,7 @@ export function ServiceOperationsTab({ err - - {formatErrorRate(op.errorRate)} - + p95 diff --git a/apps/web/src/components/services/service-recent-deploys.tsx b/apps/web/src/components/services/service-recent-deploys.tsx index 0e3ef3dd64..0a22f55fe4 100644 --- a/apps/web/src/components/services/service-recent-deploys.tsx +++ b/apps/web/src/components/services/service-recent-deploys.tsx @@ -1,6 +1,9 @@ import { useMemo, useState } from "react" import { Link } from "@tanstack/react-router" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { shortId } from "@maple/ui/lib/ids" +import { RelativeTime } from "@/components/common/relative-time" import { cn } from "@maple/ui/lib/utils" import { @@ -13,11 +16,12 @@ import { import type { ReleasePoint } from "@/components/vcs/commit-markers/marker-layout" import { DocsLink } from "@/components/common/docs-link" import { Result, useAtomValue } from "@/lib/effect-atom" -import { formatNumber } from "@maple/ui/lib/format" -import { normalizeTimestampInput } from "@/lib/timezone-format" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { formatErrorRate, formatNumber } from "@maple/ui/lib/format" +import { errorRateLevel } from "@maple/ui/lib/error-rate" +import { TONE_SOFT } from "@maple/ui/lib/tone" +import { Badge } from "@maple/ui/components/ui/badge" import { SectionCard } from "./section-card" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { shortReleaseLabel } from "@/components/releases/release-model" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { EmptyMessage } from "@maple/ui/components/ui/empty" @@ -43,11 +47,6 @@ interface DeployEntry { errorCount: number } -// A 40-hex git sha reads as its 7-char short form; tags/versions stay verbatim. -function shortLabel(sha: string): string { - return /^[0-9a-f]{40}$/i.test(sha) ? sha.slice(0, 7) : sha -} - function deriveDeploys(releases: ReadonlyArray): DeployEntry[] { const bySha = new Map() for (const point of releases) { @@ -70,12 +69,6 @@ function deriveDeploys(releases: ReadonlyArray): DeployEntry[] { ) } -function formatFirstSeenExact(firstSeen: string, timeZone: string): string { - const d = new Date(normalizeTimestampInput(firstSeen)) - if (Number.isNaN(d.getTime())) return firstSeen - return `First seen ${d.toLocaleString(undefined, { timeZone, dateStyle: "medium", timeStyle: "short" })}` -} - /** * Per-version error-rate chip. Window-wide rate (errors / spans while this * version served traffic), not a deploy-impact delta — the tooltip says so. @@ -85,23 +78,19 @@ function formatFirstSeenExact(firstSeen: string, timeZone: string): string { function ErrorRateChip({ errorCount, spanCount }: { errorCount: number; spanCount: number }) { if (spanCount <= 0) return null const ratio = errorCount / spanCount - const label = ratio < 0.001 ? "0%" : `${(ratio * 100).toFixed(1)}%` - const tone = - ratio >= 0.05 - ? "bg-destructive/10 text-destructive" - : ratio >= 0.001 - ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" - : "text-muted-foreground/60" + const level = errorRateLevel(ratio) return ( - - {label} - + {formatErrorRate(ratio)} + ) } @@ -133,17 +122,17 @@ function RowFrame({ } function RowMeta({ deploy, prefix }: { deploy: DeployEntry; prefix?: React.ReactNode }) { - const { effectiveTimezone } = useTimezonePreference() return ( <> {prefix} {formatNumber(deploy.spanCount)} spans - - {formatRelativeTimeOrDate(deploy.firstSeen, undefined, effectiveTimezone)} - + ) } @@ -151,7 +140,7 @@ function RowMeta({ deploy, prefix }: { deploy: DeployEntry; prefix?: React.React /** Non-resolvable reference (tag, short sha, arbitrary telemetry): verbatim mono * label, no fetch, no hover card. */ function DeployRowPlain({ deploy }: { deploy: DeployEntry }) { - const label = deploy.sha.length > 24 ? `${deploy.sha.slice(0, 24)}…` : deploy.sha + const label = shortId(deploy.sha, "generic", { length: 24, ellipsis: true }) return ( } @@ -172,7 +161,7 @@ function DeployRowFallback({ deploy }: { deploy: DeployEntry }) { avatar={} line1={ - {shortLabel(deploy.sha)} + {shortReleaseLabel(deploy.sha)} } chip={} @@ -213,7 +202,7 @@ function DeployRowResolved({ deploy }: { deploy: DeployEntry }) { prefix={ <> {author} - {deploy.sha.slice(0, 7)} + } /> @@ -281,11 +270,7 @@ export function ServiceRecentDeploys({ releases, isLoading = false }: ServiceRec if (isLoading) { return ( -
      - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
      + } className="p-2" />
      ) } diff --git a/apps/web/src/components/services/service-table-cells.tsx b/apps/web/src/components/services/service-table-cells.tsx index 27726ab734..ebfd82420d 100644 --- a/apps/web/src/components/services/service-table-cells.tsx +++ b/apps/web/src/components/services/service-table-cells.tsx @@ -15,18 +15,6 @@ export function formatRate(value: number): string { return value.toFixed(2) } -export function formatErrorRate(rate: number): string { - if (rate >= 0.01) return `${(rate * 100).toFixed(1)}%` - if (rate > 0) return "<1%" - return "0%" -} - -export function errorTone(rate: number): "error" | "warn" | "default" { - if (rate > 0.05) return "error" - if (rate > 0.01) return "warn" - return "default" -} - interface BarCellProps { value: number max: number diff --git a/apps/web/src/components/services/service-top-operations-panel.tsx b/apps/web/src/components/services/service-top-operations-panel.tsx index 4cc216e497..e3ee4fd484 100644 --- a/apps/web/src/components/services/service-top-operations-panel.tsx +++ b/apps/web/src/components/services/service-top-operations-panel.tsx @@ -9,6 +9,8 @@ import type { ServiceOperation } from "@/api/warehouse/service-operations" import { SectionCard } from "./section-card" import { callsPerSecond, serviceOperationsQueryInput, windowSeconds } from "./service-operations" import { ViewAllButton } from "./view-all-button" +import { formatRate } from "./service-table-cells" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" const PANEL_LIMIT = 5 @@ -21,18 +23,6 @@ interface ServiceTopOperationsPanelProps { onViewAll: () => void } -function formatRate(value: number): string { - if (value >= 1000) return `${(value / 1000).toFixed(1)}k` - if (value >= 1) return value.toFixed(1) - return value.toFixed(2) -} - -function formatErrorRate(rate: number): string { - if (rate >= 0.01) return `${(rate * 100).toFixed(1)}%` - if (rate > 0) return "<1%" - return "0%" -} - /** * "Top operations" digest on the Overview tab: the service's busiest span names * with rate/error/p95 at a glance. Reads the same atom key the Operations tab @@ -101,17 +91,7 @@ export function ServiceTopOperationsPanel({ {op.estimatedSpanCount > op.spanCount ? "~" : ""} {formatRate(callsPerSecond(op.estimatedSpanCount, seconds))}/s
      - 0.05 - ? "text-severity-error" - : op.errorRate > 0.01 - ? "text-severity-warn" - : "text-muted-foreground/70", - )} - > - {formatErrorRate(op.errorRate)} - +
      {/* diff --git a/apps/web/src/components/services/service-usage-panel.tsx b/apps/web/src/components/services/service-usage-panel.tsx index c2de1d993d..8b3a3bdd3d 100644 --- a/apps/web/src/components/services/service-usage-panel.tsx +++ b/apps/web/src/components/services/service-usage-panel.tsx @@ -10,6 +10,7 @@ import { SectionCard } from "./section-card" import { formatWarehouseDateTime } from "@maple/query-engine" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Delta } from "@maple/ui/components/ui/delta" interface ServiceUsagePanelProps { serviceName: string effectiveStartTime: string @@ -122,7 +123,14 @@ export function ServiceUsagePanel({ {stat.format(value)} - + {/* Neutral-toned: ingest volume moving isn't inherently good or bad. */} +
      ) })} @@ -130,21 +138,3 @@ export function ServiceUsagePanel({ ) } - -/** "+12%" vs the preceding window. Neutral-toned — ingest volume moving isn't - * inherently good or bad, so it informs without alarming. */ -function DeltaChip({ current, previous }: { current: number; previous: number | undefined }) { - if (previous === undefined || previous <= 0) { - return   - } - const change = (current - previous) / previous - if (!Number.isFinite(change) || Math.abs(change) < 0.005) { - return no change - } - return ( - - {change > 0 ? "+" : ""} - {(change * 100).toFixed(0)}% vs prev - - ) -} diff --git a/apps/web/src/components/services/service-workloads-panel.tsx b/apps/web/src/components/services/service-workloads-panel.tsx index db1d7b88c3..11981ee800 100644 --- a/apps/web/src/components/services/service-workloads-panel.tsx +++ b/apps/web/src/components/services/service-workloads-panel.tsx @@ -6,6 +6,7 @@ import { getServiceWorkloadsResultAtom } from "@/lib/services/atoms/warehouse-qu import type { ServiceWorkload } from "@/api/warehouse/service-infra" import { MeterRows } from "@/components/infra/primitives/meter-rows" import { SectionCard } from "./section-card" +import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from "@maple/ui/components/ui/item" interface ServiceWorkloadsPanelProps { serviceName: string @@ -72,28 +73,28 @@ export function ServiceWorkloadsPanel({ >
        {workloads.map((workload) => ( -
      • } + variant="flush" + className="gap-x-3 gap-y-1.5 px-4 py-2.5" > -
        -
        - - {workload.workloadName} - - - {KIND_LABEL[workload.workloadKind]} · {workload.namespace} - {workload.clusterName ? ` · ${workload.clusterName}` : ""} - -
        - - {workload.podCount} {workload.podCount === 1 ? "pod" : "pods"} - -
        + + + {workload.workloadName} + + + {KIND_LABEL[workload.workloadKind]} · {workload.namespace} + {workload.clusterName ? ` · ${workload.clusterName}` : ""} + + + + {workload.podCount} {workload.podCount === 1 ? "pod" : "pods"} + {(workload.avgCpuLimitUtilization != null || workload.avgMemoryLimitUtilization != null) && ( meter !== false)} /> )} -
      • + ))}
      diff --git a/apps/web/src/components/services/services-table.tsx b/apps/web/src/components/services/services-table.tsx index 98abffb79e..5ea2c1b60d 100644 --- a/apps/web/src/components/services/services-table.tsx +++ b/apps/web/src/components/services/services-table.tsx @@ -10,6 +10,7 @@ import { useAlertIncidentsList } from "@/hooks/use-alerts-list" import { buildBaselineMap, baselineKey, + HEALTH_TONE, type LatencyBaselineSignal, type ServiceHealth, } from "@/components/dashboard/service-health" @@ -28,6 +29,9 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@maple/ui/components/ui import { cn } from "@maple/ui/lib/utils" import { SignalEmptyState } from "@/components/common/signal-empty-state" import { formatErrorRate } from "@maple/ui/lib/format" +import { shortId } from "@maple/ui/lib/ids" +import { TONE_FILL } from "@maple/ui/lib/tone" +import { ErrorRateValue } from "@maple/ui/components/error-rate-value" import { CommitShaHoverCard, commitsQueryAtom, @@ -67,12 +71,6 @@ function formatThroughput(rate: number): string { return `${rate.toLocaleString(undefined, { maximumFractionDigits: 3 })}/s` } -function errorRateToneClass(rate: number): string { - if (rate > 0.05) return "text-severity-error" - if (rate > 0) return "text-severity-warn" - return "text-muted-foreground" -} - /** * Search params for the per-service detail link. Carries the row's environment * so the detail page scopes its charts to the same environment the table row @@ -166,20 +164,11 @@ function NamespaceHeaderLabel({ namespace }: { namespace: string }) { ) } -function truncateCommitSha(sha: string, length = 7): string { - if (sha === "N/A" || sha === "unknown" || !sha) { - return "N/A" - } - if (sha.length <= length) return sha - return sha.slice(0, length) +function truncateCommitSha(sha: string): string { + if (sha === "N/A" || sha === "unknown" || !sha) return "N/A" + return shortId(sha, "sha") } -const HEALTH_DOT_CLASS: Record = { - healthy: "bg-success", - degraded: "bg-severity-warn", - unhealthy: "bg-destructive", -} satisfies Record - /** Quiet health marker next to the service name — rendered only when there is * something to say (degraded/unhealthy); healthy rows stay unadorned. */ function HealthDot({ health }: { health: ServiceHealth | undefined }) { @@ -190,7 +179,7 @@ function HealthDot({ health }: { health: ServiceHealth | undefined }) { aria-hidden={false} aria-label={health} title={health} - className={HEALTH_DOT_CLASS[health]} + className={TONE_FILL[HEALTH_TONE[health]]} /> ) } @@ -1014,14 +1003,10 @@ export function ServicesTable({ filters }: ServicesTableProps) {
      -
      - {formatErrorRate(service.errorRate)} -
      + err
      diff --git a/apps/web/src/components/settings/ai-triage-settings-section.tsx b/apps/web/src/components/settings/ai-triage-settings-section.tsx index 01694ca773..07d3027980 100644 --- a/apps/web/src/components/settings/ai-triage-settings-section.tsx +++ b/apps/web/src/components/settings/ai-triage-settings-section.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@mapl import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { MapleInternalAtomClient, retainedInternalQuery } from "@/lib/services/common/internal-atom-client" import { AiTriageSettingsUpdateRequest } from "@maple/domain/http" @@ -145,25 +146,22 @@ export function AiTriageSettingsSection({ isAdmin, hasEntitlement }: AiTriageSet )) .onSuccess((current) => ( <> -
      -
      - -

      - Investigate each new incident automatically. -

      -
      - - save( - new AiTriageSettingsUpdateRequest({ enabled: checked }), - checked ? "AI auto-triage enabled" : "AI auto-triage disabled", - ) - } - /> -
      + + save( + new AiTriageSettingsUpdateRequest({ enabled: checked }), + checked ? "AI auto-triage enabled" : "AI auto-triage disabled", + ) + } + /> + } + /> (null) - const [isRevoking, setIsRevoking] = useState(false) const [rollOpen, setRollOpen] = useState(false) const [rollingKey, setRollingKey] = useState(null) @@ -147,7 +144,6 @@ export function ApiKeysSection() { async function handleRevoke() { if (!revokingKey) return - setIsRevoking(true) prepareForMutation() const result = await revokeMutation({ params: { id: revokingKey.id } }) if (Exit.isSuccess(result)) { @@ -157,9 +153,7 @@ export function ApiKeysSection() { const { title, message } = displayError(result) toastManager.add({ title, description: message, type: "error" }) } - setIsRevoking(false) - setRevokeOpen(false) - // Keep `revokingKey` set so the dialog copy doesn't swap to the generic + // ConfirmDialog closes once this resolves. Keep `revokingKey` set so the dialog copy doesn't swap to the generic // fallback while the close animation plays; the next open overwrites it. } @@ -203,21 +197,26 @@ export function ApiKeysSection() {
      {keys.length > 0 && ( <> - + { + const next = values[0] + if (next === "active" || next === "expired" || next === "revoked") setView(next) + }} + > {(["active", "expired", "revoked"] as const).map((tab) => // A tab for an empty bucket is a dead end. Active always shows, so // there is something to fall back to. tab === "active" || buckets[tab].length > 0 ? ( - setView(tab)} - > + {VIEW_LABELS[tab]} · {buckets[tab].length} - + ) : null, )} - + {buckets.active.length > 0 && ( {standardCount} standard @@ -242,12 +241,9 @@ export function ApiKeysSection() {
      -
      +
      {isLoading ? ( -
      - - -
      + ) : isError ? ( @@ -317,25 +313,16 @@ export function ApiKeysSection() { )} ) : ( -
      -
      - - Key - - - Prefix - - - Scopes - - - Last used - - - Expires - - -
      + // The card frame replaces DataTable's own top/bottom rule. + + + + + + + + + {visibleKeys.map((key) => ( openRevokeDialog(key)} /> ))} -
      + )}
      @@ -370,36 +357,27 @@ export function ApiKeysSection() { - - - - - - - Revoke API key? - - {revokingKey ? ( - <> - {revokingKey.name} ( - {revokingKey.key_prefix}) will - stop working immediately. This action cannot be undone. - - ) : ( - <> - This action cannot be undone. Any integrations using this key will stop - working immediately. - - )} - - - - Cancel - - {isRevoking ? "Revoking..." : "Revoke key"} - - - - + + {revokingKey.name} ( + {revokingKey.key_prefix}) will stop + working immediately. This action cannot be undone. + + ) : ( + <> + This action cannot be undone. Any integrations using this key will stop working + immediately. + + ) + } + confirmLabel="Revoke key" + onConfirm={handleRevoke} + />
      ) } @@ -467,14 +445,12 @@ function ApiReference() {
      -
      -
      - API Reference - - The Maple v2 API is a resource-oriented REST interface — snake_case JSON, - prefixed object IDs, cursor-paginated lists, and scoped API keys. - -
      + API Reference + + The Maple v2 API is a resource-oriented REST interface — snake_case JSON, + prefixed object IDs, cursor-paginated lists, and scoped API keys. + + -
      +
      @@ -538,10 +514,10 @@ function ApiReference() {
      - + {family.id}:read - + {family.id}:write
      @@ -587,7 +563,6 @@ function ApiKeyRow({ }) { const isMcp = apiKey.kind === "mcp" const Icon = isMcp ? SquareTerminalIcon : KeyIcon - const relativeLastUsed = apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : null const expiresAt = apiKey.expires_at === null ? null : Date.parse(apiKey.expires_at) const expiresInPast = status === "expired" const expiresSoon = status === "expiring" @@ -611,7 +586,7 @@ function ApiKeyRow({ return (
      @@ -663,11 +638,8 @@ function ApiKeyRow({
      - - {apiKey.last_used_at ? (relativeLastUsed ?? formatDate(apiKey.last_used_at)) : "—"} + + {apiKey.last_used_at ? : "—"}
      {Result.isInitial(listResult) ? ( -
      - {[0, 1].map((i) => ( -
      + ( +
      - ))} -
      + )} + /> ) : !Result.isSuccess(listResult) ? ( @@ -574,36 +567,24 @@ export function AttributeMappingsSection() { {/* Delete Confirmation */} - { if (!open) setDeleteConfirm(null) }} - > - - - Delete attribute mapping - - Are you sure you want to delete{" "} - {deleteConfirm?.name}? This - action cannot be undone. - - - - Cancel - { - if (deleteConfirm) { - void handleDelete(deleteConfirm.id) - } - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - + title="Delete attribute mapping" + description={ + <> + Are you sure you want to delete{" "} + {deleteConfirm?.name}? This action + cannot be undone. + + } + confirmLabel="Delete" + onConfirm={() => { + if (deleteConfirm) void handleDelete(deleteConfirm.id) + }} + /> ) } diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index 8cc487e330..56b836fa5d 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -6,7 +6,7 @@ import { type V2AuditLogEntry, } from "@maple/domain/http/v2" import { Option } from "effect" -import { useState, type ReactNode } from "react" +import { useState } from "react" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { auditLogPageAtom } from "@/lib/services/atoms/audit-log-atoms" @@ -16,13 +16,17 @@ import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { Eyebrow, eyebrowVariants } from "@maple/ui/components/ui/eyebrow" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { ListFooter } from "@maple/ui/components/ui/list-footer" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { trySync } from "@maple/ui/lib/try-sync" import { cn } from "@maple/ui/lib/utils" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { AlertWarningIcon, ArrowPathIcon, ChevronRightIcon, HistoryIcon } from "@/components/icons" -import { FilterTab, FilterTabs } from "./filter-tab" type ActorFilter = AuditActorType | "all" type OutcomeFilter = AuditOutcome | "all" @@ -104,16 +108,6 @@ function actorDisplayName(entry: V2AuditLogEntry): string | null { return null } -function formatDateTime(value: string): string { - return new Date(value).toLocaleString(undefined, { - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) -} - function formatDateTimeFull(value: string): string { return new Date(value).toLocaleString(undefined, { year: "numeric", @@ -259,28 +253,38 @@ export function AuditLogSection() {

      - + { + const next = ACTOR_FILTERS.find((filter) => filter.value === values[0]) + if (next) handleFilterSelect(next.value) + }} + > {ACTOR_FILTERS.map((filter) => ( - handleFilterSelect(filter.value)} - > + {filter.label} - + ))} - - + + { + const next = OUTCOME_FILTERS.find((filter) => filter.value === values[0]) + if (next) handleOutcomeSelect(next.value) + }} + > {OUTCOME_FILTERS.map((filter) => ( - handleOutcomeSelect(filter.value)} - > + {filter.label} - + ))} - +
      {view !== null && view.hasMore && view.nextCursor !== null && ( -
      - Showing {view.entries.length} entries — more available - -
      + { + if (view.nextCursor === null) return + // Pin the window to the newest entry already on screen before + // the first Load more, so later offsets address a list that + // cannot grow underneath them. + if (until === undefined) { + const newest = view.entries[0] + if (newest !== undefined) setUntil(newest.occurred_at) + } + setCursor(view.nextCursor) + }} + /> )}
      ) @@ -475,9 +473,7 @@ function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { expanded && "rotate-90", )} /> - - {formatRelativeTime(entry.occurred_at)} - +
      @@ -490,9 +486,9 @@ function AuditLogRow({ entry }: { entry: V2AuditLogEntry }) { )}
      {denied && entry.denial_reason !== null && ( -

      + {entry.denial_reason} -

      + )} {entry.changes !== null && entry.changes.fields.length > 0 && (

      @@ -533,25 +529,14 @@ function ResourceCell({ entry }: { entry: V2AuditLogEntry }) { )} {id !== null && ( - + {id} - + )}

      ) } -function DetailField({ label, children }: { label: string; children: ReactNode }) { - return ( - <> - - {label} - -
      {children}
      - - ) -} - /** An identifier with its copy affordance; the one place the full value is shown untruncated. */ function Identifier({ value, label }: { value: string; label: string }) { return ( @@ -574,30 +559,28 @@ function ChangeValue({ value }: { value: unknown }) { function ChangesTable({ changes }: { changes: V2AuditChanges }) { return ( -
      - - - - - - - - - - {changes.fields.map((field) => ( - - - - - - ))} - -
      FieldBeforeAfter
      {field} - - - -
      -
      + + + + Field + Before + After + + + + {changes.fields.map((field) => ( + + {field} + + + + + + + + ))} + +
      ) } @@ -605,12 +588,12 @@ function MetadataList({ metadata }: { metadata: Record }) { const keys = Object.keys(metadata) if (keys.length === 0) return null return ( -
      + {keys.map((key) => { const value = metadata[key] const block = metadataBlock(value) return ( - + {block !== null ? (
       								{block}
      @@ -620,10 +603,10 @@ function MetadataList({ metadata }: { metadata: Record }) {
       								
       							
       						)}
      -					
      +					
       				)
       			})}
      -		
      + ) } @@ -634,14 +617,17 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { return (
      -
      - + + {formatDateTimeFull(entry.occurred_at)} - - + + {formatDateTimeFull(entry.recorded_at)} - - + + {badge.label} @@ -649,8 +635,8 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { {entry.actor_name !== null && {entry.actor_name}} {entry.actor_id !== null && } - - + + {sourceLabel(entry.source)} {(entry.origin_ip !== null || entry.origin_country !== null) && ( @@ -659,8 +645,8 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { {entry.origin_country !== null && ` (${entry.origin_country})`} )} - - + + {entry.action} {entry.outcome === "denied" ? ( @@ -676,8 +662,8 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { {entry.denial_reason !== null && (

      {entry.denial_reason}

      )} -
      - + + {entry.resource_type === null && entry.resource_id === null ? ( — ) : ( @@ -692,26 +678,26 @@ function AuditLogDetail({ entry }: { entry: V2AuditLogEntry }) { )} )} - + {entry.affected_user !== null && ( - + - + )} {entry.request_id !== null && ( - + - + )} - + {/* The wire codec hands the client the raw id; show the `alog_…` form the API itself returns, so it can be quoted back to it. */} - -
      + + {hasChanges && entry.changes !== null && (
      diff --git a/apps/web/src/components/settings/automation-section.tsx b/apps/web/src/components/settings/automation-section.tsx index 5ea8ff9ccf..041f34f7ed 100644 --- a/apps/web/src/components/settings/automation-section.tsx +++ b/apps/web/src/components/settings/automation-section.tsx @@ -20,7 +20,7 @@ import { EscalationPolicySection } from "./escalation-policy-section" import { SectionHeader } from "@/components/layout/section-header" import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" const CONFIDENCES: ReadonlyArray = ["high", "medium", "low"] @@ -209,9 +209,10 @@ function RecentDeliveries() { : (attempt.skipReason?.replaceAll("_", " ") ?? "Awaiting delivery")}

      - - {formatRelativeTime(attempt.createdAt)} - +
      ))}
      diff --git a/apps/web/src/components/settings/billing-controls-card.tsx b/apps/web/src/components/settings/billing-controls-card.tsx index 81568b6f5e..40c43a472c 100644 --- a/apps/web/src/components/settings/billing-controls-card.tsx +++ b/apps/web/src/components/settings/billing-controls-card.tsx @@ -7,18 +7,15 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { maximumInvoiceCents, spendLimitFor } from "@/lib/billing/controls" import { formatCurrency } from "@maple/domain/format" import { - featureUnit, FEATURE_COLORS, FEATURE_LABELS, SPEND_FEATURES, type SpendFeatureId, type SpendModel, } from "@/lib/billing/spend" -import { formatCount, formatUsage } from "@/lib/billing/usage" import { BillingControlsDialog } from "./billing-controls-dialog" +import { formatFeatureUsage } from "./format-feature-usage" -const formatUnits = (featureId: SpendFeatureId, value: number) => - featureUnit(featureId) === "GB" ? formatUsage(value) : formatCount(value) export function BillingControlsCardSkeleton() { return ( @@ -86,12 +83,12 @@ export function BillingControlsCard({

      {limit === undefined ? "Paid overage uncapped" - : `${formatUnits(featureId, limit)} paid overage`} + : `${formatFeatureUsage(featureId, limit)} paid overage`}

      {stopAt === null ? "No enforced stop this cycle" - : `Stops at ${formatUnits(featureId, stopAt)} total usage`} + : `Stops at ${formatFeatureUsage(featureId, stopAt)} total usage`}

      diff --git a/apps/web/src/components/settings/billing-details-dialog.tsx b/apps/web/src/components/settings/billing-details-dialog.tsx index 24d7287a72..9e6759361b 100644 --- a/apps/web/src/components/settings/billing-details-dialog.tsx +++ b/apps/web/src/components/settings/billing-details-dialog.tsx @@ -21,7 +21,6 @@ import { } from "@maple/ui/components/ui/dialog" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" -import { Spinner } from "@maple/ui/components/ui/spinner" import { toastManager } from "@maple/ui/components/ui/toast" import { useAtomSet } from "@/lib/effect-atom" @@ -196,8 +195,8 @@ export function BillingDetailsDialog({ }>Cancel - diff --git a/apps/web/src/components/settings/billing-details-section.tsx b/apps/web/src/components/settings/billing-details-section.tsx index d6b5f6d21b..1315959eba 100644 --- a/apps/web/src/components/settings/billing-details-section.tsx +++ b/apps/web/src/components/settings/billing-details-section.tsx @@ -167,8 +167,8 @@ function AddTaxIdRow({ profile }: { readonly profile: BillingProfile }) { placeholder={taxIdExampleFor(type, profile.address?.country)} className="w-56 font-mono" /> -
      ) diff --git a/apps/web/src/components/settings/billing-kpis.tsx b/apps/web/src/components/settings/billing-kpis.tsx index b20ac8b8c2..367bb075d3 100644 --- a/apps/web/src/components/settings/billing-kpis.tsx +++ b/apps/web/src/components/settings/billing-kpis.tsx @@ -1,10 +1,8 @@ -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { cn } from "@maple/ui/lib/utils" +import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" import { formatCurrency } from "@maple/domain/format" -import { featureUnit, FEATURE_COLORS, type SpendModel } from "@/lib/billing/spend" -import { formatCount, formatUsage } from "@/lib/billing/usage" +import { FEATURE_COLORS, type SpendModel } from "@/lib/billing/spend" +import { formatFeatureUsage } from "./format-feature-usage" /** * The four numbers that answer "what is this cycle costing me?" before the @@ -15,37 +13,10 @@ import { formatCount, formatUsage } from "@/lib/billing/usage" * screenshots into a finance thread. */ -function KpiCard({ - label, - children, - className, -}: { - label: string - children: React.ReactNode - className?: string -}) { - return ( -
      - {label} -
      {children}
      -
      - ) -} - const dollars = (cents: number, currency: string) => formatCurrency(cents / 100, currency) export function BillingKpisSkeleton() { - return ( -
      - {Array.from({ length: 4 }).map((_, i) => ( -
      - - - -
      - ))} -
      - ) + return } export function BillingKpis({ @@ -58,77 +29,82 @@ export function BillingKpis({ }) { const driver = model.topDriver - return ( -
      - -

      {dollars(model.spendCents, model.currency)}

      -

      - Day {model.dayOfCycle} of {model.cycleDays} · {dollars(model.baseCents, model.currency)}{" "} - base + {dollars(model.overageCents, model.currency)} overage - {model.partial && " · some legacy items aren't itemized"} -

      -
      - - -

      - {dollars(model.projectedCents, model.currency)} -

      -

      At the current pace

      -
      + // Sublines here carry the arithmetic behind the number, so they wrap instead of truncating. + const wrap = (text: React.ReactNode) => {text} - - {maximumInvoiceCents === null ? ( - <> -

      Uncapped

      -

      - At least one paid feature has no overage cap -

      - - ) : ( + return ( + + -

      - {dollars(maximumInvoiceCents, model.currency)} -

      -

      - Base plan plus all paid-overage caps -

      - + Day {model.dayOfCycle} of {model.cycleDays} · {dollars(model.baseCents, model.currency)}{" "} + base + {dollars(model.overageCents, model.currency)} overage + {model.partial && " · some legacy items aren't itemized"} + , )} -
      - - - {driver === null ? ( - <> -

      Within included

      -

      - No feature is over its allotment this cycle -

      - - ) : ( - <> -
      + /> + + {maximumInvoiceCents === null ? ( + + ) : ( + + )} + {driver === null ? ( + + ) : ( + - {driver.label} + {driver.label} {dollars(driver.overageCents, model.currency)} -
      -

      + + } + subline={wrap( + <> {model.overageCents > 0 ? `${Math.round((driver.overageCents / model.overageCents) * 100)}% of overage · ` : ""} - {featureUnit(driver.featureId) === "GB" - ? formatUsage(driver.overageUnits) - : formatCount(driver.overageUnits)}{" "} - over included -

      - - )} -
      -
      + {formatFeatureUsage(driver.featureId, driver.overageUnits)} over included + , + )} + /> + )} + ) } diff --git a/apps/web/src/components/settings/cost-breakdown.tsx b/apps/web/src/components/settings/cost-breakdown.tsx index 3d30bd140c..4b934f7041 100644 --- a/apps/web/src/components/settings/cost-breakdown.tsx +++ b/apps/web/src/components/settings/cost-breakdown.tsx @@ -1,21 +1,23 @@ import type { CycleCostEstimate } from "@/lib/billing/cost-estimate" import { formatCurrency } from "@maple/domain/format" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" export function CostBreakdownSkeleton() { return ( -
      - {Array.from({ length: 3 }).map((_, i) => ( -
      + ( +
      - ))} -
      + )} + /> ) } diff --git a/apps/web/src/components/settings/create-api-key-dialog.tsx b/apps/web/src/components/settings/create-api-key-dialog.tsx index b0668bd681..9553ddb549 100644 --- a/apps/web/src/components/settings/create-api-key-dialog.tsx +++ b/apps/web/src/components/settings/create-api-key-dialog.tsx @@ -178,7 +178,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea {createdKey.scopes !== null && createdKey.scopes.map((scope) => ( - + {scope} ))} @@ -377,8 +377,8 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea - diff --git a/apps/web/src/components/settings/data-platform-usage-section.tsx b/apps/web/src/components/settings/data-platform-usage-section.tsx index b645c3eabb..fb8aeb7716 100644 --- a/apps/web/src/components/settings/data-platform-usage-section.tsx +++ b/apps/web/src/components/settings/data-platform-usage-section.tsx @@ -4,7 +4,7 @@ import { Result } from "@/lib/effect-atom" import { ChartLineIcon, DatabaseIcon, FileIcon, GridSquareCirclePlusIcon } from "@/components/icons" import { cn } from "@maple/ui/lib/utils" import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { StatRail, StatRailItem } from "@/components/common/stat-rail" import { getServiceUsageResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import type { ServiceUsageResponse } from "@/api/warehouse/service-usage" @@ -19,29 +19,29 @@ const STATS: ReadonlyArray<{ key: StatKey label: string icon: typeof FileIcon - badge: string + tone: string format: (n: number) => string }> = [ - { key: "logs", label: "Logs", icon: FileIcon, badge: "bg-chart-2/10 text-chart-2", format: formatNumber }, + { key: "logs", label: "Logs", icon: FileIcon, tone: "text-chart-2", format: formatNumber }, { key: "traces", label: "Traces", icon: GridSquareCirclePlusIcon, - badge: "bg-chart-5/10 text-chart-5", + tone: "text-chart-5", format: formatNumber, }, { key: "metrics", label: "Metrics", icon: ChartLineIcon, - badge: "bg-chart-3/10 text-chart-3", + tone: "text-chart-3", format: formatNumber, }, { key: "dataSize", label: "Storage", icon: DatabaseIcon, - badge: "bg-chart-1/10 text-chart-1", + tone: "text-chart-1", format: formatStorageBytes, }, ] @@ -58,23 +58,16 @@ function sumTotals(response: ServiceUsageResponse) { ) } -function StatTile({ stat, children }: { stat: (typeof STATS)[number]; children: ReactNode }) { +function statItem(stat: (typeof STATS)[number], value: ReactNode) { const Icon = stat.icon return ( -
      - - - - -
      {children}
      - - {stat.label} - -
      + } + /> ) } @@ -90,33 +83,19 @@ export function DataPlatformUsageSection() {

      -
      + {Result.builder(result) .onSuccess((response) => { const totals = sumTotals(response) - return STATS.map((stat) => ( - - - {stat.format(totals[stat.key])} - - - )) + return STATS.map((stat) => statItem(stat, stat.format(totals[stat.key]))) }) .onError(() => - STATS.map((stat) => ( - - — - - )), + STATS.map((stat) => + statItem(stat, —), + ), ) - .orElse(() => - STATS.map((stat) => ( - - - - )), - )} -
      + .orElse(() => STATS.map((stat) => statItem(stat, )))} + ) } diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index 9d1debfc4a..f2a2b9224e 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -22,6 +22,7 @@ import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { @@ -140,16 +141,11 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { return (
      -
      -
      -

      Severity escalation

      -

      - Route issues to destinations when AI triage or a teammate sets their severity. - Fires once per issue and severity level, upward only. -

      -
      - -
      + } + /> {Result.builder(destinationsResult) // Destinations come from the live-synced collection, which only diff --git a/apps/web/src/components/settings/feature-usage-cards.tsx b/apps/web/src/components/settings/feature-usage-cards.tsx index d03962a07f..01c5babd88 100644 --- a/apps/web/src/components/settings/feature-usage-cards.tsx +++ b/apps/web/src/components/settings/feature-usage-cards.tsx @@ -1,16 +1,17 @@ +import { Badge } from "@maple/ui/components/ui/badge" +import { SegmentedBar } from "@maple/ui/components/ui/meter" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" import { formatCurrency } from "@maple/domain/format" import { - featureUnit, FEATURE_COLORS, formatRateLabel, SPEND_FEATURES, type FeatureSpend, type SpendModel, } from "@/lib/billing/spend" -import { formatCount, formatUsage } from "@/lib/billing/usage" +import { formatFeatureUsage } from "./format-feature-usage" /** * One card per billable signal: how much was ingested, how much of it was @@ -20,9 +21,6 @@ import { formatCount, formatUsage } from "@/lib/billing/usage" * customer is billed for, and the chart is only their sum over time. */ -const formatVolume = (featureId: string, value: number) => - featureUnit(featureId) === "GB" ? formatUsage(value) : formatCount(value) - export function FeatureUsageCardsSkeleton() { return (
      @@ -82,13 +80,16 @@ function FeatureCard({ {feature.label}
      {overageCap === null ? ( - + NO CAP - + ) : ( - + : `+${formatFeatureUsage(feature.featureId, overageCap)} CAP`} + )}
      @@ -108,7 +109,7 @@ function FeatureCard({ knocks the meters out of alignment. */}
      - {formatVolume(feature.featureId, feature.used)} + {formatFeatureUsage(feature.featureId, feature.used)} {feature.unlimited @@ -119,14 +120,18 @@ function FeatureCard({ feature.ratePerUnit === null ? "not on your plan" : "no allotment" - : `of ${formatVolume(feature.featureId, included)} included`} + : `of ${formatFeatureUsage(feature.featureId, included)} included`}
      -
      -
      - {hasOverage &&
      } -
      +
      diff --git a/apps/web/src/components/settings/filter-tab.tsx b/apps/web/src/components/settings/filter-tab.tsx deleted file mode 100644 index 27f984cb07..0000000000 --- a/apps/web/src/components/settings/filter-tab.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ReactNode } from "react" -import { cn } from "@maple/ui/lib/utils" - -// Compact mono filter tabs for settings tables. Lighter than ToggleGroup's recessed track -// on purpose: they sit inline in dense table toolbars next to counts. -export function FilterTabs({ className, children }: { className?: string; children: ReactNode }) { - return ( -
      - {children} -
      - ) -} - -export function FilterTab({ - active, - onClick, - children, -}: { - active: boolean - onClick: () => void - children: ReactNode -}) { - return ( - - ) -} diff --git a/apps/web/src/components/settings/format-feature-usage.ts b/apps/web/src/components/settings/format-feature-usage.ts new file mode 100644 index 0000000000..2d1480f0d9 --- /dev/null +++ b/apps/web/src/components/settings/format-feature-usage.ts @@ -0,0 +1,6 @@ +import { featureUnit } from "@/lib/billing/spend" +import { formatCount, formatUsage } from "@/lib/billing/usage" + +/** A billable feature's volume in its own unit: bytes for GB features, a count otherwise. */ +export const formatFeatureUsage = (featureId: string, value: number): string => + featureUnit(featureId) === "GB" ? formatUsage(value) : formatCount(value) diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 6c6e99fe44..ebfda5a8c2 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -5,22 +5,11 @@ import { Exit } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Button } from "@maple/ui/components/ui/button" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { cn } from "@maple/ui/lib/utils" import { - AlertWarningIcon, ArrowPathIcon, ArrowRightIcon, EyeIcon, @@ -362,34 +351,22 @@ export function IngestionSection() {
      - - - - - - - - Regenerate {regenerateKeyType === "public" ? "public" : "private"} key? - - - This action cannot be undone. All existing integrations using this key will stop - working immediately. You will need to update your{" "} - {regenerateKeyType === "public" ? "client-side SDKs" : "server configurations"}{" "} - with the new key. - - - - Cancel - - Regenerate key - - - - + + This action cannot be undone. All existing integrations using this key will stop working + immediately. You will need to update your{" "} + {regenerateKeyType === "public" ? "client-side SDKs" : "server configurations"} with the + new key. + + } + confirmLabel="Regenerate key" + onConfirm={handleRegenerate} + pending={submittingKeyType !== null} + /> ) } diff --git a/apps/web/src/components/settings/invoices-section.tsx b/apps/web/src/components/settings/invoices-section.tsx index def09fb3e0..4c165f852a 100644 --- a/apps/web/src/components/settings/invoices-section.tsx +++ b/apps/web/src/components/settings/invoices-section.tsx @@ -4,7 +4,7 @@ import type { BillingInvoice } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { Result, useAtomValue } from "@/lib/effect-atom" import { billingInvoicesAtom } from "@/lib/services/atoms/billing-atoms" import { formatCurrency } from "@maple/domain/format" @@ -70,16 +70,18 @@ function InvoiceRow({ invoice }: { invoice: BillingInvoice }) { function InvoicesSkeleton() { return ( -
      - {Array.from({ length: 3 }).map((_, i) => ( -
      + ( +
      - ))} -
      + )} + /> ) } diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index acdbe1cc23..02dbeb85e2 100644 --- a/apps/web/src/components/settings/members-section.tsx +++ b/apps/web/src/components/settings/members-section.tsx @@ -4,7 +4,14 @@ import { useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@maple/ui/components/ui/card" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Avatar, AvatarFallback, AvatarImage } from "@maple/ui/components/ui/avatar" import { Badge } from "@maple/ui/components/ui/badge" @@ -25,18 +32,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@maple/ui/components/ui/dropdown-menu" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { PlusIcon, @@ -45,7 +42,6 @@ import { ShieldIcon, UserIcon, EnvelopeIcon, - AlertWarningIcon, } from "@/components/icons" function getInitials(firstName?: string | null, lastName?: string | null) { @@ -165,16 +161,20 @@ export function MembersSection() { - - {Array.from({ length: 3 }).map((_, i) => ( -
      - -
      - - + + ( +
      + +
      + + +
      -
      - ))} + )} + />
      @@ -205,13 +205,15 @@ export function MembersSection() { return (
      - + Team Members {isAdmin && ( - + + + )} @@ -451,30 +453,15 @@ export function MembersSection() { - - - - - - - Remove member? - - {memberToRemove?.name} will lose access to this organization immediately. This - action cannot be undone. - - - - Cancel - - {removeLoading ? "Removing..." : "Remove member"} - - - - + void handleRemoveMember()} + />
      ) } diff --git a/apps/web/src/components/settings/notifications-section.tsx b/apps/web/src/components/settings/notifications-section.tsx index 1577259b67..6006c0dfd3 100644 --- a/apps/web/src/components/settings/notifications-section.tsx +++ b/apps/web/src/components/settings/notifications-section.tsx @@ -9,11 +9,11 @@ import { formatWarehouseDateTime } from "@maple/query-engine" import { Button } from "@maple/ui/components/ui/button" import { Label } from "@maple/ui/components/ui/label" +import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Switch } from "@maple/ui/components/ui/switch" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" import { ChartBarTrendUpIcon, EnvelopeIcon } from "@/components/icons" -import { cn } from "@maple/ui/lib/utils" import { getServicesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { snapRangeForCache } from "@/lib/time-utils" @@ -208,23 +208,16 @@ export function NotificationsSection() { return (
      -
      -
      -
      - -
      -
      -

      Email

      -

      Weekly digest via email

      -
      -
      - -
      + } + label="Email" + description="Weekly digest via email" + control={ + + } + /> {enabled && (
      @@ -271,25 +264,15 @@ export function NotificationsSection() {
      )} -
      -
      -
      - -
      -
      -

      Web analytics

      -

      - Weekly overview of visitors, top pages and AI traffic. Only sent once the browser - SDK is reporting visits. -

      -
      -
      -
      + } + label="Web analytics" + description="Weekly overview of visitors, top pages and AI traffic. Only sent once the browser SDK is reporting visits." + control={ + <>
      -
      + + } + />
      ) } diff --git a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx index 22b52c307b..0a8545f433 100644 --- a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx +++ b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx @@ -7,22 +7,21 @@ import { displayError } from "@/lib/error-messages" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@maple/ui/components/ui/card" import { Badge } from "@maple/ui/components/ui/badge" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" +import { TruncatedId } from "@maple/ui/components/ui/truncated-id" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { AlertWarningIcon, ChevronDownIcon, @@ -59,11 +58,6 @@ function formatSyncDate(value: string | null): string { } } -function shortRevision(rev: string | null): string { - if (!rev) return "—" - return rev.length > 10 ? rev.slice(0, 10) : rev -} - interface OrgClickHouseSettingsSectionProps { isAdmin: boolean hasEntitlement: boolean @@ -263,21 +257,15 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli -
      -
      - Bring your own ClickHouse - - Route this organization's read queries through your own ClickHouse - server. Save the connection first, then review the schema diff and apply - the bundled snapshot to your cluster. - -
      - {Result.isInitial(settingsResult) ? ( - - ) : ( - statusBadge - )} -
      + Bring your own ClickHouse + + Route this organization's read queries through your own ClickHouse server. + Save the connection first, then review the schema diff and apply the bundled + snapshot to your cluster. + + + {Result.isInitial(settingsResult) ? : statusBadge} +
      {!Result.isSuccess(settingsResult) && !Result.isInitial(settingsResult) ? ( @@ -391,13 +379,21 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli

      Applied version

      - {shortRevision(settings?.schemaVersion ?? null)} + {settings?.schemaVersion ? ( + + ) : ( + "—" + )}

      Expected version

      - {shortRevision(diff?.expectedSchemaVersion ?? null)} + {diff?.expectedSchemaVersion ? ( + + ) : ( + "—" + )}

      @@ -411,20 +407,18 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli
      {Result.isInitial(diffResult) ? ( -
      - - - -
      + ) : !Result.isSuccess(diffResult) ? ( -
      - Failed to introspect ClickHouse:{" "} - {getExitErrorMessage( - // SAFETY: this branch has already excluded loading and success, leaving the failure Exit variant. - diffResult as unknown as Exit.Exit, - "check that credentials are valid", - )} -
      + + + Failed to introspect ClickHouse:{" "} + {getExitErrorMessage( + // SAFETY: this branch has already excluded loading and success, leaving the failure Exit variant. + diffResult as unknown as Exit.Exit, + "check that credentials are valid", + )} + + ) : diff && diff.entries.length > 0 ? (
      {diff.entries.map((entry) => { @@ -557,33 +551,22 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli ) : null}
      - - - - - - - Disable BYO ClickHouse? - - This org will fall back to the default Maple-managed Tinybird Cloud. Tables in - your ClickHouse cluster are NOT touched: disable just removes Maple's pointer - to it. - - - - Cancel - { - event.preventDefault() - void handleDisable() - }} - disabled={isDisabling} - > - {isDisabling ? "Disabling..." : "Disable"} - - - - + } + title="Disable BYO ClickHouse?" + description={ + <> + This org will fall back to the default Maple-managed Tinybird Cloud. Tables in your + ClickHouse cluster are NOT touched: disable just removes Maple's pointer to it. + + } + confirmLabel="Disable" + onConfirm={() => void handleDisable()} + pending={isDisabling} + /> ) } diff --git a/apps/web/src/components/settings/organization-section.tsx b/apps/web/src/components/settings/organization-section.tsx index 923317cd82..3df71e1644 100644 --- a/apps/web/src/components/settings/organization-section.tsx +++ b/apps/web/src/components/settings/organization-section.tsx @@ -9,20 +9,10 @@ import { Button } from "@maple/ui/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" -import { AlertWarningIcon, UploadIcon, UserIcon } from "@/components/icons" +import { UploadIcon, UserIcon } from "@/components/icons" import { OrgAvatar } from "@/components/dashboard/org-switcher-menu" import { RegionBadge } from "@/components/region/region-badge" import { organizationHomeRegion } from "@maple/domain/organization-regions" @@ -327,44 +317,30 @@ export function OrganizationSection() {
      - - - - - - - Delete organization? - - All dashboards, alerts, API keys, ingest keys, and integrations for this org will - be permanently deleted. This cannot be undone. - - - {/* AlertDialog has no panel slot, so a body between header and footer pads itself. */} -
      - - setConfirmText(e.target.value)} - placeholder={organization.name} - autoComplete="off" - /> -
      - - Cancel - - {isDeleting ? "Deleting..." : "Delete organization"} - - -
      -
      + void handleDelete()} + > + {/* AlertDialog has no panel slot, so a body between header and footer pads itself. */} +
      + + setConfirmText(e.target.value)} + placeholder={organization.name} + autoComplete="off" + /> +
      +
      ) } diff --git a/apps/web/src/components/settings/plan-offer.tsx b/apps/web/src/components/settings/plan-offer.tsx index b9013b8740..5fa2475b57 100644 --- a/apps/web/src/components/settings/plan-offer.tsx +++ b/apps/web/src/components/settings/plan-offer.tsx @@ -3,6 +3,7 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import type { CatalogPlan } from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Spinner } from "@maple/ui/components/ui/spinner" @@ -84,9 +85,9 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana
      {model.planName ?? "Your plan"} - + Current plan - +
      @@ -201,11 +202,17 @@ export function PlanOffer({
      {plan.name} {isActive && ( - + {isTrialing && daysRemaining != null ? `Trial · ${daysRemaining}d left` : "Current plan"} - + )}
      diff --git a/apps/web/src/components/settings/pricing-cards.tsx b/apps/web/src/components/settings/pricing-cards.tsx index 11e6ab02be..7b881eed6e 100644 --- a/apps/web/src/components/settings/pricing-cards.tsx +++ b/apps/web/src/components/settings/pricing-cards.tsx @@ -28,6 +28,7 @@ import { } from "@maple/ui/components/ui/card" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Separator } from "@maple/ui/components/ui/separator" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Spinner } from "@maple/ui/components/ui/spinner" @@ -376,14 +377,13 @@ export function PricingCards() { {confirmDialog && (
      - {confirmDialog.lines.map((line, i) => ( -
      - {line.description} - + + {confirmDialog.lines.map((line, i) => ( + {formatCurrency(line.amount, confirmDialog.currency)} - -
      - ))} + + ))} +
      Due today @@ -410,8 +410,8 @@ export function PricingCards() { > Cancel - @@ -470,18 +470,15 @@ export function PlanCards({ {plan.name} {isActive && isTrialing && daysRemaining != null ? ( - + Trial · {daysRemaining}d left ) : isActive ? ( - + Current ) : isUpgrade ? ( - + Recommended ) : null} diff --git a/apps/web/src/components/settings/recommended-mappings-section.tsx b/apps/web/src/components/settings/recommended-mappings-section.tsx index dd59b69636..66dcadc87f 100644 --- a/apps/web/src/components/settings/recommended-mappings-section.tsx +++ b/apps/web/src/components/settings/recommended-mappings-section.tsx @@ -18,7 +18,7 @@ import { } from "@/lib/services/atoms/ingestion-atoms" import { formatNumber } from "@maple/ui/lib/format" import { DocsLink } from "@/components/common/docs-link" -import { FilterTab, FilterTabs } from "./filter-tab" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { formatRelativeTime } from "@maple/ui/lib/time-format" type IssueKind = V2Recommendation["kind"] @@ -189,14 +189,24 @@ export function RecommendedMappingsSection() {

      - - setTab("open")}> + { + const next = values[0] + if (next === "open" || next === "closed") setTab(next) + }} + > + Open · {openIssues.length} - - setTab("closed")}> + + Closed · {closedIssues.length} - - + +
      {rows.length === 0 ? ( diff --git a/apps/web/src/components/settings/roll-api-key-dialog.tsx b/apps/web/src/components/settings/roll-api-key-dialog.tsx index 4040a2c28b..f283c8019b 100644 --- a/apps/web/src/components/settings/roll-api-key-dialog.tsx +++ b/apps/web/src/components/settings/roll-api-key-dialog.tsx @@ -101,8 +101,8 @@ export function RollApiKeyDialog({ open, onOpenChange, apiKey, onRolled }: RollA - diff --git a/apps/web/src/components/settings/scrape-targets-section.tsx b/apps/web/src/components/settings/scrape-targets-section.tsx index b7850aaccc..19c41b0497 100644 --- a/apps/web/src/components/settings/scrape-targets-section.tsx +++ b/apps/web/src/components/settings/scrape-targets-section.tsx @@ -2,7 +2,7 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import { ScrapeIntervalSeconds } from "@maple/domain/http" import type { ScrapeAuthType, ScrapeTargetId } from "@maple/domain/http" import type { V2ScrapeTarget, V2ScrapeTargetCheck } from "@maple/domain/http/v2" -import { useState, type KeyboardEvent, type ReactNode } from "react" +import { useState, type KeyboardEvent } from "react" import { Exit, Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" import { Spinner } from "@maple/ui/components/ui/spinner" @@ -13,16 +13,7 @@ import { type ScrapeTargetChecksResponse, useScrapeTargetChecks } from "@/hooks/ import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { @@ -40,12 +31,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@maple/ui/components/ui/dropdown-menu" -import { Eyebrow, eyebrowVariants } from "@maple/ui/components/ui/eyebrow" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Input } from "@maple/ui/components/ui/input" import { Label } from "@maple/ui/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Switch } from "@maple/ui/components/ui/switch" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" @@ -66,7 +59,7 @@ import { } from "@/components/icons" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { formatDuration, formatNumber } from "@maple/ui/lib/format" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { diagnoseScrapeError } from "@/lib/scrape-error-diagnosis" import { scheduledStatusFromChecks, scheduledStatusFromRollup } from "@/lib/scrape-target-status" import { catalogEntry } from "../integrations/integration-catalog" @@ -394,11 +387,7 @@ export function ScrapeTargetsSection({ )} {Result.isInitial(listResult) ? ( -
      - - - -
      + ) : !Result.isSuccess(listResult) ? (
      Failed to load scrape targets. @@ -615,36 +604,25 @@ export function ScrapeTargetsSection({ - { if (!open) setDeleteConfirmTarget(null) }} - > - - - Delete scrape target - - Are you sure you want to delete{" "} - {deleteConfirmTarget?.name}? - This action cannot be undone. - - - - Cancel - { - if (deleteConfirmTarget) { - void handleDelete(deleteConfirmTarget.id) - } - }} - > - Delete - - - - + title="Delete scrape target" + description={ + <> + Are you sure you want to delete{" "} + {deleteConfirmTarget?.name}? + This action cannot be undone. + + } + confirmLabel="Delete" + onConfirm={() => { + // handleDelete clears the target, which closes the dialog. + if (deleteConfirmTarget) void handleDelete(deleteConfirmTarget.id) + }} + /> ) } @@ -715,7 +693,7 @@ function ScrapeTargetRow({ {target.scrape_interval_seconds}s interval {status.detail} {target.last_scrape_at && ( - Last scrape {formatRelativeTime(target.last_scrape_at)} + )}
      {target.last_scrape_error && ( @@ -923,17 +901,16 @@ function ScrapeTargetDetails({ Target -
      - - - - {target.id}} /> - - -
      + + {target.service_name ?? target.name} + {hostnameFromUrl(target.url)} + {AUTH_TYPE_LABELS[target.auth_type] ?? target.auth_type} + + {target.id} + + {formatDateTime(target.created_at)} + {formatDateTime(target.updated_at)} + {labels.length > 0 && (
      {labels.map(([key, value]) => ( @@ -954,9 +931,11 @@ function ScrapeTargetDetails({ Check History {latestCheck && ( - - Latest {formatRelativeTime(latestCheck.timestamp)} - + )}
      @@ -975,15 +954,6 @@ function MetricBox({ label, value }: { label: string; value: string }) { ) } -function DetailRow({ label, value }: { label: string; value: ReactNode }) { - return ( -
      - {label} - {value} -
      - ) -} - export function ScrapeTargetChecksTable({ result, checks, @@ -993,11 +963,7 @@ export function ScrapeTargetChecksTable({ }) { if (Result.isInitial(result)) { return ( -
      - - - -
      + ) } if (!Result.isSuccess(result)) { @@ -1017,52 +983,51 @@ export function ScrapeTargetChecksTable({ return (
      -
      - Time - State - Duration - Samples -
      -
      - {checks.map((check) => ( -
      -
      -
      {formatDateTime(check.timestamp)}
      - {check.message && ( - - } - className="text-muted-foreground mt-0.5 cursor-default truncate" - > - {check.message} - - - {check.message} - - - )} -
      -
      - {check.success ? ( - - ) : ( - - )} - {check.success ? "up" : "down"} -
      - {formatDurationSeconds(check.duration_seconds)} - {formatOptionalCount(check.samples_scraped)} -
      - ))} -
      + + + + Time + State + Duration + Samples + + + + {checks.map((check) => ( + + {/* max-w-0 lets the flexible column truncate instead of widening the table. */} + +
      {formatDateTime(check.timestamp)}
      + {check.message && ( + + } + className="text-muted-foreground mt-0.5 cursor-default truncate" + > + {check.message} + + + {check.message} + + + )} +
      + + + {check.success ? ( + + ) : ( + + )} + {check.success ? "up" : "down"} + + + {formatDurationSeconds(check.duration_seconds)} + {formatOptionalCount(check.samples_scraped)} +
      + ))} +
      +
      ) } diff --git a/apps/web/src/components/settings/setup-audit-section.tsx b/apps/web/src/components/settings/setup-audit-section.tsx index 3d99af5607..cb94c5e9fd 100644 --- a/apps/web/src/components/settings/setup-audit-section.tsx +++ b/apps/web/src/components/settings/setup-audit-section.tsx @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router" import type { V2SetupAudit, V2SetupAuditCheck } from "@maple/domain/http/v2" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Button, buttonVariants } from "@maple/ui/components/ui/button" @@ -19,7 +20,7 @@ import { import { setupAuditAtom } from "@/lib/services/atoms/audit-atoms" import { DocsLink, EmptyActions } from "@/components/common/docs-link" import type { SettingsTab } from "@/components/settings/settings-nav" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" type Severity = V2SetupAuditCheck["severity"] type Category = V2SetupAuditCheck["category"] @@ -106,7 +107,7 @@ function CheckRow({ check }: { check: V2SetupAuditCheck }) { {check.title} {check.id} {check.status === "skip" && ( - + Skipped )} @@ -235,9 +236,7 @@ function Report({ audit }: { audit: V2SetupAudit }) { )}
      - - {formatRelativeTime(audit.generated_at)} - +
      {!audit.telemetry_checks_available && ( -
      - Telemetry checks skipped. Your - warehouse could not be queried, so only configuration was audited. The findings below - still apply. -
      + + + Telemetry checks skipped. + + Your warehouse could not be queried, so only configuration was audited. The findings + below still apply. + + )} {findingCount === 0 && !showPassing ? ( diff --git a/apps/web/src/components/traces/advanced-filter-dialog.tsx b/apps/web/src/components/traces/advanced-filter-dialog.tsx index 24c7c457b4..be24936803 100644 --- a/apps/web/src/components/traces/advanced-filter-dialog.tsx +++ b/apps/web/src/components/traces/advanced-filter-dialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle, DialogTrigger, } from "@maple/ui/components/ui/dialog" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { Kbd } from "@maple/ui/components/ui/kbd" import { CircleWarningIcon, MagnifierIcon } from "@/components/icons" @@ -111,14 +112,16 @@ export function AdvancedFilterDialog({ initialValue, onApply }: AdvancedFilterDi ariaLabel="Advanced traces where clause" /> {warnings.length > 0 && ( -
      - -
        - {warnings.map((warning) => ( -
      • {warning}
      • - ))} -
      -
      + + + +
        + {warnings.map((warning) => ( +
      • {warning}
      • + ))} +
      +
      +
      )} diff --git a/apps/web/src/components/traces/span-detail-panel.tsx b/apps/web/src/components/traces/span-detail-panel.tsx index 45effecff3..9d3ea82e3c 100644 --- a/apps/web/src/components/traces/span-detail-panel.tsx +++ b/apps/web/src/components/traces/span-detail-panel.tsx @@ -5,6 +5,7 @@ import { ErrorSection } from "@maple/ui/components/error-section" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" +import { SeverityBadge } from "@maple/ui/components/logs/severity-badge" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@maple/ui/components/ui/tabs" import { ScrollArea } from "@maple/ui/components/ui/scroll-area" @@ -101,18 +102,7 @@ function SpanPositionBar({ const LOG_LIMIT = 100 -const severityStyles: Record = { - TRACE: "text-severity-trace", - DEBUG: "text-severity-debug", - INFO: "text-severity-info", - WARN: "text-severity-warn", - ERROR: "text-severity-error", - FATAL: "text-severity-fatal", -} satisfies Record - function LogEntry({ log, timeZone, onClick }: { log: Log; timeZone: string; onClick?: (log: Log) => void }) { - const severityStyle = severityStyles[log.severityText] ?? "text-severity-trace" - return ( @@ -296,14 +284,11 @@ export function SpanDetailPanel({
      - + {span.statusCode || "Unset"} {cacheInfo?.result && ( - + {cacheInfo.result === "hit" ? "HIT" : "MISS"} )} @@ -313,7 +298,7 @@ export function SpanDetailPanel({ {cacheInfo && (
      {cacheInfo.system && ( - + {cacheInfo.system} )} @@ -339,10 +324,8 @@ export function SpanDetailPanel({ {platform.outcome && ( {platform.outcome.value} @@ -405,7 +388,7 @@ export function SpanDetailPanel({ Logs {logCount !== null && logCount > 0 && ( - + {logCountLabel} )} diff --git a/apps/web/src/components/traces/trace-anatomy-strip.tsx b/apps/web/src/components/traces/trace-anatomy-strip.tsx index 54b94944a1..6b757f1d70 100644 --- a/apps/web/src/components/traces/trace-anatomy-strip.tsx +++ b/apps/web/src/components/traces/trace-anatomy-strip.tsx @@ -3,6 +3,9 @@ import * as React from "react" import { ServiceSpectrumBar, computeServiceShares } from "@maple/ui/components/traces/service-spectrum-bar" import { ServiceDot } from "@maple/ui/components/service-dot" import { formatDuration } from "@maple/ui/lib/format" +import { httpStatusTone } from "@maple/ui/lib/http" +import { shortId } from "@maple/ui/lib/ids" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card" import { TraceIdBadge } from "@/components/traces/trace-id-badge" @@ -19,11 +22,11 @@ interface TraceAnatomyStripProps { commitSha?: string } +// 4xx/5xx follow the shared status tone; 3xx keeps its own blue so redirects stand apart from 2xx. function httpStatusColor(code: number): string { - if (code >= 500) return "text-severity-error" - if (code >= 400) return "text-severity-warn" - if (code >= 300) return "text-chart-p50" - return "text-severity-info" + const tone = httpStatusTone(code) + if (tone !== "neutral") return TONE_TEXT[tone] + return code >= 300 ? "text-chart-p50" : TONE_TEXT.info } /** @@ -88,7 +91,7 @@ export function TraceAnatomyStrip({ copy={{ value: commitSha, label: "commit SHA" }} className="font-mono text-xs text-muted-foreground hover:text-foreground" > - {commitSha.slice(0, 7)} + {shortId(commitSha, "sha")} )} diff --git a/apps/web/src/components/traces/trace-logs-link.tsx b/apps/web/src/components/traces/trace-logs-link.tsx index 61ac93bb48..9820d0ad54 100644 --- a/apps/web/src/components/traces/trace-logs-link.tsx +++ b/apps/web/src/components/traces/trace-logs-link.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router" +import { Button } from "@maple/ui/components/ui/button" import { formatWarehouseDateTime, parseWarehouseDateTime } from "@maple/query-engine" import { Result, useAtomValue } from "@/lib/effect-atom" import { getLogsCountResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -45,13 +46,18 @@ export function TraceLogsLink({ const total = response.data[0]?.total ?? 0 if (total === 0) return null return ( - + } > View Logs ({total}) - + ) }) .onError(() => null) diff --git a/apps/web/src/components/traces/trace-peek-sheet.tsx b/apps/web/src/components/traces/trace-peek-sheet.tsx index c2668c3898..58ddabb504 100644 --- a/apps/web/src/components/traces/trace-peek-sheet.tsx +++ b/apps/web/src/components/traces/trace-peek-sheet.tsx @@ -15,15 +15,17 @@ import { SheetHeader, SheetTitle, } from "@maple/ui/components/ui/sheet" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" import { TraceViewTabs } from "@maple/ui/components/traces/trace-view-tabs" import { findSpanById } from "@maple/ui/components/traces/flow-utils" -import { getHttpInfo } from "@maple/ui/lib/http" +import { getHttpInfo, httpStatusTone } from "@maple/ui/lib/http" +import { shortId } from "@maple/ui/lib/ids" import type { Span, SpanHierarchyResponse, SpanNode } from "@/api/warehouse/traces" import { ArrowDownIcon, ArrowUpIcon } from "@/components/icons" import { ErrorState } from "@/components/common/error-state" +import { SheetDetailHeader } from "@/components/common/sheet-detail-header" import { TraceReplayLink } from "@/components/replays/trace-replay-link" import { SpanDetailPanel } from "@/components/traces/span-detail-panel" import { TraceAnatomyStrip } from "@/components/traces/trace-anatomy-strip" @@ -32,7 +34,6 @@ import { TraceLogsLink } from "@/components/traces/trace-logs-link" import { Result, useAtomValue } from "@/lib/effect-atom" import { getSpanHierarchyResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import type { PeekTarget } from "@/lib/traces/peek" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" /** * The peek: a trace's page, in a sheet, without leaving the list. @@ -254,32 +255,34 @@ function TracePeekBody({ return Result.builder(result) .onInitial(() => ( <> - - Trace - {target.traceId.slice(0, 8)} - Loading trace details - +
      -
      - {Array.from({ length: 6 }).map((_, i) => ( -
      + ( +
      - ))} -
      + )} + />
      )) .onError((error) => ( <> - - {target.traceId.slice(0, 8)} - Failed to load trace - +
      @@ -332,7 +335,7 @@ function TracePeekLoaded({ return ( <> - {traceId.slice(0, 8)} + {shortId(traceId, "trace")} {data.spans.length === 0 ? "This trace could not be found. It may have expired or not been ingested yet." @@ -352,32 +355,35 @@ function TracePeekLoaded({ const httpStatus = s.spanAttributes?.["http.response.status_code"] || s.spanAttributes?.["http.status_code"] const code = typeof httpStatus === "string" ? parseInt(httpStatus) : httpStatus - return typeof code === "number" && code >= 500 + return typeof code === "number" && httpStatusTone(code) === "crit" }) return (
      - - Trace - + - - Spans and timing for trace {traceId} -
      - - - -
      -
      + } + description={`Spans and timing for trace ${traceId}`} + meta={ + <> + + + + + } + />
      event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey -function truncateId(id: string, length = 8): string { - if (id.length <= length) return id - return id.slice(0, length) -} - function StatusBadge({ hasError }: { hasError: boolean }) { - if (hasError) { - return ( - - Error - - ) - } return ( - - OK + + {hasError ? "Error" : "OK"} ) } +// 4xx/5xx follow the shared status tone; 3xx keeps its own blue so redirects stand apart from 2xx. function HttpStatusBadge({ statusCode }: { statusCode: number }) { + const tone = httpStatusTone(statusCode) return ( = 500 - ? "bg-severity-error/15 text-severity-error" - : statusCode >= 400 - ? "bg-severity-warn/15 text-severity-warn" - : statusCode >= 300 - ? "bg-chart-p50/15 text-chart-p50" - : "bg-severity-info/15 text-severity-info" + tone !== "neutral" + ? TONE_SOFT[tone] + : statusCode >= 300 + ? "bg-chart-p50/15 text-chart-p50" + : TONE_SOFT.ok } > {statusCode} @@ -145,8 +139,6 @@ const TABLE_FEATURES = tableFeatures({ columnSizingFeature }) const ROW_HEIGHT = 44 -const HEADER_CELL_CLASS = "h-10 px-2 text-left align-middle font-medium text-muted-foreground" - /** * Column layout, shared by the real table, the loading skeleton and the empty state so the three * can't drift apart. @@ -265,7 +257,7 @@ function TracesTableView({ {...traceLink(row.original)} className="relative z-10 font-mono text-xs text-primary underline decoration-primary/30 underline-offset-2 hover:decoration-primary" > - {truncateId(row.original.traceId)} + {shortId(row.original.traceId, "trace")} ), }, @@ -455,12 +447,12 @@ function TracesTableView({ * fixed layout pins the sized columns and hands the remainder to Root Span, which is the * only column that should flex. */} - - +
      + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( - + ))} - - + + {virtualItems.length > 0 && ( { if (e.key === "Enter" || e.key === " ") { @@ -509,15 +501,15 @@ function TracesTableView({ {row.getAllCells().map((cell) => { const { responsive, cellClass } = columnClasses(cell.column.id) return ( - + ) })} - + ) })} {virtualItems.length > 0 && ( @@ -533,26 +525,28 @@ function TracesTableView({ )} {isFetchingNextPage && ( - - + )} - -
      + ))} -
      {flexRender(cell.column.columnDef.cell, cell.getContext())} -
      - Loading more traces… +
      +
      + +
      -
      - {isCapped - ? `Showing first ${allData.length.toLocaleString()} traces — narrow filters to continue` - : `Showing ${allData.length.toLocaleString()} traces${!hasNextPage ? " (all loaded)" : ""}`} - {/* Hidden rows are never silently dropped — say how many and offer the way back. */} +
      + + {/* Hidden rows are never silently dropped: say how many and offer the way back. */} {hiddenCount > 0 && ( - <> + {" · "} {hiddenCount.toLocaleString()} single-span noise{" "} {hiddenCount === 1 ? "trace" : "traces"} hidden{" "} @@ -563,7 +557,7 @@ function TracesTableView({ > show - + )}
      diff --git a/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx b/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx index a10d732fd2..64ea2f4cc2 100644 --- a/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx +++ b/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx @@ -3,6 +3,7 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import type { ResolvedScale } from "@tanstack/charts" import { usePlotRect, usePlotScales, useSuppressChartTooltip, type PlotRect } from "@maple/ui/components/plot" +import { TruncatedText } from "@maple/ui/components/ui/truncated-text" import { parseBucketMs } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -356,9 +357,7 @@ function MarkerGroup({ : "border-border/60 bg-popover/85 text-muted-foreground hover:text-popover-foreground", )} > - - {group.label} - + {group.label} {badge > 0 ? ( - {sha.length > 16 ? `${sha.slice(0, 16)}…` : sha} + {shortId(sha, "generic", { length: 16, ellipsis: true })} Deployment reference — not a resolvable git commit.
      @@ -274,9 +276,7 @@ function CommitCard({ commit, compact = false }: { commit: VcsCommitDetailRespon
      - - {formatRelativeFrom(commit.committedAt)} - +
      - {sha.length > 18 ? `${sha.slice(0, 18)}…` : sha} + {shortId(sha, "generic", { length: 18, ellipsis: true })} Deployment reference
      @@ -373,13 +373,13 @@ function CommitListRowLink({ commit }: { commit: VcsCommitDetailResponse }) {
      {author} - {commit.sha.slice(0, 7)} - + - {formatRelativeShortFrom(commit.committedAt)} - + />
      @@ -403,7 +403,7 @@ function CommitListRowFallback({ sha, note }: { sha: string; note: string }) {
      - {sha.slice(0, 12)} + {shortId(sha)} {note}
      @@ -459,7 +459,7 @@ function CopyableSha({ sha }: { sha: string }) { aria-label="Copy commit SHA" className="group inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 transition-colors hover:bg-muted/70" > - {sha.slice(0, 7)} + {shortId(sha, "sha")} ActorDirectory = isClerkAuthEnabled diff --git a/apps/web/src/lab/infra-lab.tsx b/apps/web/src/lab/infra-lab.tsx index 995269253d..bc410bffc9 100644 --- a/apps/web/src/lab/infra-lab.tsx +++ b/apps/web/src/lab/infra-lab.tsx @@ -24,7 +24,7 @@ import { summarizeRailway, } from "@/components/infra/overview/summaries" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar } from "@/components/common/search-toolbar" +import { SearchToolbar } from "@/components/common/search-toolbar" import { PageHero } from "@/components/common/page-hero" import { RailwayServiceTable, @@ -237,7 +237,7 @@ export function InfraLab() { onScopeChange={setHostScope} className={FLEET_BAND_BOXED} /> - - undefined} placeholder="Search services…" diff --git a/apps/web/src/lab/service-map-3d/index.tsx b/apps/web/src/lab/service-map-3d/index.tsx index 33b696feec..10537c5020 100644 --- a/apps/web/src/lab/service-map-3d/index.tsx +++ b/apps/web/src/lab/service-map-3d/index.tsx @@ -7,8 +7,8 @@ import { SERVICE_MAP_3D_TOPOLOGY } from "./fixture" import type { Node3D } from "@/components/service-map/three/types" import { resolveMachineBadge } from "@/components/service-map/three/factory-badge" import { ServiceMap3DViewport } from "@/components/service-map/three/viewport" +import { formatErrorRate } from "@maple/ui/lib/format" import { - formatError, formatLatency, formatRate, health, @@ -78,7 +78,7 @@ function ServiceInventory({ > {health(selected.errorRate)} - · {formatError(selected.errorRate)} errors + · {formatErrorRate(selected.errorRate)} errors
      @@ -183,7 +183,7 @@ function ServiceInventory({ - {formatError(node.errorRate)} + {formatErrorRate(node.errorRate)} ))} diff --git a/apps/web/src/lib/logs/log-search-query.ts b/apps/web/src/lib/logs/log-search-query.ts index df028ca54e..ccabb5348b 100644 --- a/apps/web/src/lib/logs/log-search-query.ts +++ b/apps/web/src/lib/logs/log-search-query.ts @@ -31,6 +31,3 @@ export function parseLogSearch(raw: string | undefined): LogSearchQuery | undefi return { kind: "text", text: value } } - -/** First 8 characters of a trace id — what the chip and the trace page show. */ -export const shortTraceId = (id: string): string => id.slice(0, 8) diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx index b7bb6126ed..b4020d3bc2 100644 --- a/apps/web/src/routes/agent-sessions/$sessionId.tsx +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -5,7 +5,7 @@ import { Schema } from "effect" import type { GetAiSessionSummaryResponse } from "@maple/domain/http" import { formatWarehouseDateTime } from "@maple/query-engine" import { toEpochMs } from "@maple/ui/lib/time-format" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" import { SquareSparkleIcon } from "@/components/icons" import { Button } from "@maple/ui/components/ui/button" @@ -104,11 +104,7 @@ function AgentSessionDetailPage() {
      -
      - {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
      +
      diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 7f7ea6e484..bf33dfbd2a 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -57,6 +57,9 @@ import { import { cn } from "@maple/ui/lib/utils" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" +import { Meter } from "@maple/ui/components/ui/meter" import { Card, CardContent } from "@maple/ui/components/ui/card" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" import { Skeleton } from "@maple/ui/components/ui/skeleton" @@ -726,7 +729,7 @@ function RuleDetailContent() {

      Configuration

      -
      + {rule.notes && (
      Notes
      @@ -877,7 +880,7 @@ function RuleDetailContent() { label={rule.enabled ? "Enabled" : "Disabled"} /> -
      +
      @@ -998,19 +1001,16 @@ function RuleDetailContent() { > {groupKey} -
      -
      -
      + {count}/{stats.totalTriggered} @@ -1209,10 +1209,9 @@ function RuleDetailContent() { function ConfigRow({ label, children, wide }: { label: string; children: React.ReactNode; wide?: boolean }) { return ( -
      -
      {label}
      -
      {children}
      -
      + + {children} + ) } @@ -1236,15 +1235,10 @@ function CheckDelta({ if (!Number.isFinite(delta) || delta === 0) return null const sign = delta > 0 ? "+" : "−" return ( - + {sign} {formatSignalValue(signalType, Math.abs(delta))} - + ) } @@ -1622,9 +1616,7 @@ function ChecksPanel({ {loadedChecks.length} of {totals.total} loaded - +
      )}
      diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx index 1e9c0d79e1..ff51946ef2 100644 --- a/apps/web/src/routes/analytics/index.tsx +++ b/apps/web/src/routes/analytics/index.tsx @@ -58,6 +58,7 @@ import { webAnalyticsTimeseriesResultAtom, } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { Badge } from "@maple/ui/components/ui/badge" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" @@ -233,14 +234,16 @@ function WebAnalyticsPage() { {chips.length > 0 ? (
      {chips.map((chip) => ( - + ))} -
      + ) : null}
      )} diff --git a/apps/web/src/routes/code-review/issues.tsx b/apps/web/src/routes/code-review/issues.tsx index bdc45b2596..75ad781e28 100644 --- a/apps/web/src/routes/code-review/issues.tsx +++ b/apps/web/src/routes/code-review/issues.tsx @@ -8,9 +8,11 @@ import { type CodeReviewFinding, } from "@maple/domain/http" import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" +import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { formatRelativeFrom, toEpochMs } from "@maple/ui/lib/time-format" @@ -18,7 +20,7 @@ import { CATEGORY_LABELS, FINDING_STATUS_LABELS, SEVERITY_LABELS, - SEVERITY_TONES, + SEVERITY_TONE, } from "@/components/code-review/code-review-format" import { CodeReviewFilters, @@ -122,11 +124,8 @@ function CodeReviewIssuesPage() { {Result.builder(result) .onInitial(() => ( -
      - {Array.from({ length: 6 }, (_, i) => ( - - ))} -
      + + )) .onError((error) => ( @@ -166,16 +165,14 @@ function CodeReviewIssuesPage() { {response.nextCursor !== null ? ( limit < MAX_ROWS ? ( - + /> + ) : (

      Showing the latest {MAX_ROWS}. Narrow the window to see older issues. @@ -242,7 +239,7 @@ function FindingRow({ finding, onOpen }: { finding: CodeReviewFinding; onOpen: ( {SEVERITY_LABELS[finding.severity]} diff --git a/apps/web/src/routes/code-review/pull-requests.tsx b/apps/web/src/routes/code-review/pull-requests.tsx index dfface0c7b..43059c6419 100644 --- a/apps/web/src/routes/code-review/pull-requests.tsx +++ b/apps/web/src/routes/code-review/pull-requests.tsx @@ -2,9 +2,12 @@ import { useState } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import type { CodeReviewListItem } from "@maple/domain/http" -import { Button } from "@maple/ui/components/ui/button" +import { Badge } from "@maple/ui/components/ui/badge" +import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SkeletonList } from "@maple/ui/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { formatRelativeFrom, toEpochMs } from "@maple/ui/lib/time-format" @@ -118,11 +121,7 @@ function CodeReviewPullRequestsPage() { {Result.builder(result) .onInitial(() => ( -

      - {Array.from({ length: 6 }, (_, i) => ( - - ))} -
      + )) .onError((error) => ( @@ -156,16 +155,13 @@ function CodeReviewPullRequestsPage() { /> {response.nextCursor !== null ? ( limit < MAX_ROWS ? ( - + /> ) : (

      Showing the latest {MAX_ROWS}. Narrow the window to see older reviews. @@ -206,31 +202,29 @@ function ReviewTable({ }) { return (

      - - - - - -
      Pull request - Outcome - + + + + Pull request + Outcome + Confidence - - - - - - - + + + + {reviews.map((review) => { const outcome = outcomeOf(review) const Icon = OUTCOME_ICONS[outcome.kind] return ( - onOpen(review)} className={cn( @@ -238,7 +232,7 @@ function ReviewTable({ selected === review.id && "bg-muted/60", )} > - - - - - - - + + ) })} - -
      + + Quality - Issues + + Issues + Reviewed -
      + + + ) : null} - + + {review.confidence === null ? ( – ) : ( @@ -295,8 +289,8 @@ function ReviewTable({ /5 )} - + + {review.score === null ? ( – ) : ( @@ -305,32 +299,29 @@ function ReviewTable({ /100 )} - + + {review.status !== "completed" ? ( – ) : ( {review.criticalFindings > 0 ? ( - + {review.criticalFindings} - + ) : null} {formatCount(review.findings)} )} - + + {formatRelativeFrom(review.createdAt)} -
      + +
      ) } diff --git a/apps/web/src/routes/dashboards/$dashboardId.tsx b/apps/web/src/routes/dashboards/$dashboardId.tsx index a69cefa040..6f1e5d1f7b 100644 --- a/apps/web/src/routes/dashboards/$dashboardId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId.tsx @@ -1,4 +1,5 @@ import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { DashboardId, DashboardVersionId } from "@maple/domain/http" @@ -376,9 +377,11 @@ function DashboardViewPage() { {degraded && } {persistenceError && ( -
      - {persistenceError}. Dashboard editing is temporarily disabled. -
      + + + {persistenceError}. Dashboard editing is temporarily disabled. + + )} {isPreviewing && previewed ? ( diff --git a/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx b/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx index 0c7a78d96c..d03768a93c 100644 --- a/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId_.widgets.$widgetId.tsx @@ -154,8 +154,13 @@ function WidgetConfigurePage() { - diff --git a/apps/web/src/routes/dashboards/index.tsx b/apps/web/src/routes/dashboards/index.tsx index ae9f5c7fcb..0d9b480f0a 100644 --- a/apps/web/src/routes/dashboards/index.tsx +++ b/apps/web/src/routes/dashboards/index.tsx @@ -2,6 +2,7 @@ import { useRef } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" import { toastManager } from "@maple/ui/components/ui/toast" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Unitflow, View } from "@maple/unitflow/react" import { Button } from "@maple/ui/components/ui/button" @@ -277,13 +278,13 @@ function PageShell({ {/* Pinned with the header rather than inside Scroll: it explains the disabled buttons above it, so it must not scroll away from them. */} {persistenceError && ( -
      - - + + + {persistenceError}. Editing, import and delete are disabled until it - recovers — reading is unaffected. - -
      + recovers; reading is unaffected. + + )} {children} diff --git a/apps/web/src/routes/errors/issues/$issueId.tsx b/apps/web/src/routes/errors/issues/$issueId.tsx index 7bfe503e3c..45f2eaf54d 100644 --- a/apps/web/src/routes/errors/issues/$issueId.tsx +++ b/apps/web/src/routes/errors/issues/$issueId.tsx @@ -5,16 +5,7 @@ import { Exit, Schema } from "effect" import { useMemo, useState } from "react" import { toastManager } from "@maple/ui/components/ui/toast" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@maple/ui/components/ui/alert-dialog" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { warehouseDateTimeToIso } from "@maple/query-engine" @@ -682,38 +673,29 @@ function IssueDetailContent() { /> )} - { if (!open) setSeverityConfirmation(null) }} - > - - - - Notify escalation destinations? - - - Changing severity to {severityConfirmation?.severity} will - notify {severityConfirmation?.destinationNames.join(", ")} - . Manual severity changes represent explicit human intent - and bypass AI confidence gates. - - - - Cancel - { - const pending = severityConfirmation - setSeverityConfirmation(null) - if (pending) void applySeverity(pending.severity) - }} - > - Change severity and notify - - - - + tone="default" + icon={null} + title="Notify escalation destinations?" + description={ + <> + Changing severity to {severityConfirmation?.severity} will + notify {severityConfirmation?.destinationNames.join(", ")} + . Manual severity changes represent explicit human intent + and bypass AI confidence gates. + + } + confirmLabel="Change severity and notify" + onConfirm={() => { + const pending = severityConfirmation + setSeverityConfirmation(null) + if (pending) void applySeverity(pending.severity) + }} + />
      diff --git a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx index 567ede13bb..b5bb0b8228 100644 --- a/apps/web/src/routes/infra/cloudflare/$zoneName.tsx +++ b/apps/web/src/routes/infra/cloudflare/$zoneName.tsx @@ -38,7 +38,7 @@ import { type CloudflareFilterKey, type CloudflareFilters, } from "@/components/infra/cloudflare/filters" -import { errorRateTone } from "@/components/infra/cloudflare/constants" +import { errorRateLevel } from "@maple/ui/lib/error-rate" import { chartBucketSeconds } from "@/components/infra/chart-utils" import { cloudflareZoneDetailResultAtom, @@ -288,7 +288,7 @@ function ZoneDetailContent({ - patchSearch({ q: value || undefined }) diff --git a/apps/web/src/routes/infra/hosts/$hostName.tsx b/apps/web/src/routes/infra/hosts/$hostName.tsx index cd33ed6373..053fce362b 100644 --- a/apps/web/src/routes/infra/hosts/$hostName.tsx +++ b/apps/web/src/routes/infra/hosts/$hostName.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Schema } from "effect" +import { Panel, PanelHeader } from "@maple/ui/components/ui/panel" import { Result, useAtomValue, useAtomRefresh } from "@/lib/effect-atom" import { DashboardLayout } from "@/components/layout/dashboard-layout" @@ -130,13 +131,15 @@ function HostDetailPage() { )) .render()} -
      -
      - Metrics - - {METRIC_STRIPS.length} signals - -
      + + + {METRIC_STRIPS.length} signals + + } + />
      {METRIC_STRIPS.map((strip) => ( ))}
      -
      + diff --git a/apps/web/src/routes/infra/hosts/index.tsx b/apps/web/src/routes/infra/hosts/index.tsx index ee528383b6..a2ea4f8989 100644 --- a/apps/web/src/routes/infra/hosts/index.tsx +++ b/apps/web/src/routes/infra/hosts/index.tsx @@ -21,7 +21,7 @@ import { HostsViewTabs } from "@/components/infra/hosts-view-tabs" import { InfraSetupEmpty } from "@/components/infra/infra-empty-state" import { InstallHostModal } from "@/components/infra/install-modal" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/common/search-toolbar" +import { SearchToolbar, countLabel } from "@/components/common/search-toolbar" import { PageHero } from "@/components/common/page-hero" import { listHostsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" @@ -206,7 +206,7 @@ function HostList({ onScopeChange={onScopeChange} className={FLEET_BAND_BOXED} /> - = { - active: "bg-[var(--severity-info)]", - idle: "bg-[var(--severity-warn)]", + active: TONE_FILL.info, + idle: TONE_FILL.warn, ended: "bg-muted-foreground/40", } satisfies Record @@ -181,7 +182,7 @@ function NodesPage() { waiting={result.waiting} />
      - patchSearch({ q: value || undefined })} placeholder="Search nodes…" diff --git a/apps/web/src/routes/infra/kubernetes/pods/index.tsx b/apps/web/src/routes/infra/kubernetes/pods/index.tsx index a1b5338661..e4701ad74b 100644 --- a/apps/web/src/routes/infra/kubernetes/pods/index.tsx +++ b/apps/web/src/routes/infra/kubernetes/pods/index.tsx @@ -17,7 +17,7 @@ import { PodPeekSheet } from "@/components/infra/kubernetes/pod-peek-sheet" import { PodsFilterSidebarView, type PodFilters } from "@/components/infra/k8s-filter-sidebar" import { PodTable, PodTableLoading, podKey, type PodRow } from "@/components/infra/pod-table" import { FleetBand, FleetBandLoading, type FleetBandCell } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/common/search-toolbar" +import { SearchToolbar, countLabel } from "@/components/common/search-toolbar" import { podFilterChips } from "@/lib/infra/pod-filter-chips" import { listPodsResultAtom, @@ -31,6 +31,7 @@ import { pickTimeRangeSearch, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" +import { TONE_FILL } from "@maple/ui/lib/tone" const PAGE_SIZE = 50 const DEFAULT_PRESET = "12h" @@ -292,12 +293,12 @@ function PodsPage() { { key: "elevated", count: counts.elevatedPods, - className: "bg-[var(--severity-warn)]", + className: TONE_FILL.warn, }, { key: "saturated", count: counts.saturatedPods, - className: "bg-[var(--severity-error)]", + className: TONE_FILL.crit, }, ]} cells={cells} @@ -356,7 +357,7 @@ function PodsPage() {
      - patchSearch({ q: value || undefined })} placeholder="Search all pods…" diff --git a/apps/web/src/routes/infra/kubernetes/services/$serviceName.tsx b/apps/web/src/routes/infra/kubernetes/services/$serviceName.tsx index 88468a2b88..46970d07a7 100644 --- a/apps/web/src/routes/infra/kubernetes/services/$serviceName.tsx +++ b/apps/web/src/routes/infra/kubernetes/services/$serviceName.tsx @@ -18,6 +18,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { GridIcon } from "@/components/icons" import { DocsLink } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" +import { PageHero } from "@/components/common/page-hero" import { PodTable, PodTableLoading } from "@/components/infra/pod-table" import { chartBucketSeconds } from "@/components/infra/chart-utils" import { toIsoBucket } from "@/api/warehouse/timeseries-utils" @@ -320,57 +321,50 @@ function LensBody({ (Result.isSuccess(overviewResult) && overviewResult.waiting) || (Result.isSuccess(cpuResult) && cpuResult.waiting) + const workloadMeta = workload ? ( +
      + + {workload.workloadKind} {workload.workloadName} + + {workload.namespace && ns {workload.namespace}} + {workload.clusterName && cluster {workload.clusterName}} + + Open the service + +
      + ) : null + return (
      -
      - {loading ? ( - <> - - - - ) : evidenceMissing ? ( - <> -

      - {serviceName} — evidence incomplete. -

      -

      - The pod list or the CPU series failed to load, so this page can't say whether - Kubernetes is involved. The charts below show what did arrive. -

      - - ) : ( - <> -

      - {lensHeadline(verdict, serviceName)} -

      -

      - {lensSubhead(verdict)} -

      - - )} - {workload && ( -
      - - {workload.workloadKind} {workload.workloadName} - - {workload.namespace && ns {workload.namespace}} - {workload.clusterName && cluster {workload.clusterName}} - - Open the service - -
      - )} -
      + {loading ? ( +
      + + + {workloadMeta} +
      + ) : ( + + )} {workload == null && !loading ? ( diff --git a/apps/web/src/routes/infra/kubernetes/workloads/index.tsx b/apps/web/src/routes/infra/kubernetes/workloads/index.tsx index 7d59a38263..7a4284c9ed 100644 --- a/apps/web/src/routes/infra/kubernetes/workloads/index.tsx +++ b/apps/web/src/routes/infra/kubernetes/workloads/index.tsx @@ -17,7 +17,7 @@ import { deriveHostStatus, severityLevel } from "@/components/infra/format" import { WorkloadTable, WorkloadTableLoading, type WorkloadRow } from "@/components/infra/workload-table" import { WorkloadsFilterSidebarView, type WorkloadFilters } from "@/components/infra/k8s-filter-sidebar" import { FleetBand, type FleetBandCell } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/common/search-toolbar" +import { SearchToolbar, countLabel } from "@/components/common/search-toolbar" import { SegmentPivot } from "@/components/infra/primitives/segment-pivot" import { listWorkloadsResultAtom, workloadFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" @@ -27,6 +27,7 @@ import { pickTimeRangeSearch, } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" +import { TONE_FILL } from "@maple/ui/lib/tone" const DEFAULT_PRESET = "12h" @@ -254,12 +255,12 @@ function WorkloadsPage() { { key: "elevated", count: elevated, - className: "bg-[var(--severity-warn)]", + className: TONE_FILL.warn, }, { key: "saturated", count: saturated, - className: "bg-[var(--severity-error)]", + className: TONE_FILL.crit, }, ]} cells={cells} @@ -268,7 +269,7 @@ function WorkloadsPage() { waiting={result.waiting} />
      - patchSearch({ q: value || undefined })} placeholder="Search workloads…" @@ -282,7 +283,7 @@ function WorkloadsPage() { patchSearch({ kind: next, workloadNames: undefined }) } /> - + {(q || scope) && filtered.length === 0 ? ( diff --git a/apps/web/src/routes/infra/railway/index.tsx b/apps/web/src/routes/infra/railway/index.tsx index 33758ac126..f165c71022 100644 --- a/apps/web/src/routes/infra/railway/index.tsx +++ b/apps/web/src/routes/infra/railway/index.tsx @@ -16,7 +16,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout" import { PageHero } from "@/components/common/page-hero" import { DataTable } from "@/components/common/data-table" import { FLEET_BAND_BOXED } from "@/components/infra/primitives/fleet-band" -import { ListToolbar, countLabel } from "@/components/common/search-toolbar" +import { SearchToolbar, countLabel } from "@/components/common/search-toolbar" import { RailwayServiceTable, RailwayServiceTableLoading, @@ -220,7 +220,7 @@ function RailwayServices({ onScopeChange={onScopeChange} className={FLEET_BAND_BOXED} /> - Maple scrapes any public Prometheus{" "} - /metrics endpoint you + /metrics endpoint you add as a target below and stores the samples as OpenTelemetry metrics.{" "} WarpStream clusters are scraped as Prometheus targets. Point a target at an agent's{" "} - :8080/metrics endpoint + :8080/metrics endpoint or the hosted Prometheus endpoint with Basic auth.{" "} - + + {priorityPaused ? "Automatic triage paused" : "Automatic triage paused for routine incidents"} - - - {spent} - {resets}{" "} - {priorityPaused - ? "Nothing new will start until then." - : "High and critical incidents still start."} - + + + + {spent} + {resets}{" "} + {priorityPaused + ? "Nothing new will start until then." + : "High and critical incidents still start."} + + {canEditSettings ? ( - - Raise the limit - + + + ) : null} -
      + ) } @@ -526,14 +531,10 @@ function HubHero({ onSubmit, busy }: { onSubmit: (title: string) => void | Promi return (
      Investigations -

      - Ask, and Maple goes and finds out. -

      -

      - One agent reads the traces, logs and metrics around it, tests the likely explanations - (deploys, dependencies, saturation, traffic) and comes back with a cause, the evidence for it, - and what it ruled out. -

      + {tracePresence.status === "absent" && (
      Investigations read your telemetry. Send traces first. diff --git a/apps/web/src/routes/logs/$logId.tsx b/apps/web/src/routes/logs/$logId.tsx index e4d4026a52..95855ef856 100644 --- a/apps/web/src/routes/logs/$logId.tsx +++ b/apps/web/src/routes/logs/$logId.tsx @@ -5,6 +5,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { ErrorState } from "@/components/common/error-state" +import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { LogHeroHeader } from "@/components/logs/log-hero-header" import { LogMetaStrip } from "@/components/logs/log-meta-strip" @@ -122,12 +123,14 @@ function LogDetailPage() {

      This log could not be found. It may have aged out of retention.

      -
      -
      Service
      -
      {key.serviceName}
      -
      Timestamp
      -
      {key.timestamp}
      -
      + + + {key.serviceName} + + + {key.timestamp} + + diff --git a/apps/web/src/routes/logs/index.tsx b/apps/web/src/routes/logs/index.tsx index 1c661ffd25..164e67c1c8 100644 --- a/apps/web/src/routes/logs/index.tsx +++ b/apps/web/src/routes/logs/index.tsx @@ -1,4 +1,5 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router" +import { shortId } from "@maple/ui/lib/ids" import { Schema } from "effect" import { OptionalStringArrayParam } from "@/lib/search-params" @@ -76,7 +77,7 @@ function LogsPage() { negated: chip.negated, // The chip's tooltip still carries the full ID; the trace page itself // abbreviates to the same 8 characters. - getValueLabel: chip.param === "traceId" ? (value: string) => value.slice(0, 8) : undefined, + getValueLabel: chip.param === "traceId" ? (value: string) => shortId(value, "trace") : undefined, onRemove: () => navigate({ search: (prev) => ({ ...prev, [chip.param]: undefined }) }), })) diff --git a/apps/web/src/routes/mcp-authorize.tsx b/apps/web/src/routes/mcp-authorize.tsx index 763d85e994..9ae53e4e60 100644 --- a/apps/web/src/routes/mcp-authorize.tsx +++ b/apps/web/src/routes/mcp-authorize.tsx @@ -2,7 +2,9 @@ import { useOrganization } from "@clerk/clerk-react" import { createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" import { useState } from "react" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" +import { CircleWarningIcon } from "@/components/icons" import { AuthLayout } from "@/components/layout/auth-layout" import { ClerkOrgSwitcherMenu } from "@/components/dashboard/org-switcher-menu" import { useMountEffect } from "@/hooks/use-mount-effect" @@ -169,12 +171,10 @@ function McpAuthorizePage() { {state._tag === "error" ? (
      -

      - {state.message} -

      + + + {state.message} +

      Return to your MCP client and start the connection again.

      diff --git a/apps/web/src/routes/recommendations/$recommendationKey.tsx b/apps/web/src/routes/recommendations/$recommendationKey.tsx index 9230d7d1ee..d2f5b41f7f 100644 --- a/apps/web/src/routes/recommendations/$recommendationKey.tsx +++ b/apps/web/src/routes/recommendations/$recommendationKey.tsx @@ -14,7 +14,7 @@ import { ingestAttributeMappingsListAtom, recommendationIssuesListAtom, } from "@/lib/services/atoms/ingestion-atoms" -import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { RelativeTime } from "@/components/common/relative-time" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -490,10 +490,12 @@ function DetailSidebar({ {issue.usage_count.toLocaleString()} - - - {formatRelativeTime(issue.opened_at)} - + + diff --git a/apps/web/src/routes/releases/$commitSha.tsx b/apps/web/src/routes/releases/$commitSha.tsx index a8dbb03967..4d91df2f14 100644 --- a/apps/web/src/routes/releases/$commitSha.tsx +++ b/apps/web/src/routes/releases/$commitSha.tsx @@ -6,13 +6,11 @@ import { Button } from "@maple/ui/components/ui/button" import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" import { PageLayout } from "@maple/ui/components/ui/page-layout" import { ServiceDot } from "@maple/ui/components/service-dot" -import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { OptionalStringArrayParam } from "@/lib/search-params" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" -import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" +import { RelativeTime } from "@/components/common/relative-time" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getReleaseDetailResultAtom, getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { DashboardLayout } from "@/components/layout/dashboard-layout" @@ -332,7 +330,6 @@ function ReleaseBodyLoaded({ baselineCommitSha, }: ScopedProps & { serviceName: string; baselineCommitSha: string | undefined }) { const search = Route.useSearch() - const { effectiveTimezone } = useTimezonePreference() const atom = getReleaseDetailResultAtom({ data: { serviceName, commitSha, baselineCommitSha, startTime, endTime, environments }, }) @@ -420,15 +417,12 @@ function ReleaseBodyLoaded({ first seen{" "} - - {formatRelativeTimeOrDate(impact.firstSeen, undefined, effectiveTimezone)} - {" "} + />{" "} on {serviceName} {impact.environment ? {impact.environment} : null} diff --git a/apps/web/src/routes/replays/$sessionId.tsx b/apps/web/src/routes/replays/$sessionId.tsx index ea239766b9..5b5e957d16 100644 --- a/apps/web/src/routes/replays/$sessionId.tsx +++ b/apps/web/src/routes/replays/$sessionId.tsx @@ -1,4 +1,5 @@ import { warmAtoms } from "@effect-router/core" +import { shortId } from "@maple/ui/lib/ids" import { useMemo } from "react" import { createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" @@ -54,7 +55,7 @@ function ReplayDetailPage() { const window = useMemo(() => replayPartitionWindow(t), [t]) const detailResult = useAtomValue(getReplayResultAtom({ data: { sessionId, ...window } })) - const breadcrumbs = [{ label: "Session Replays", href: "/replays" }, { label: sessionId.slice(0, 8) }] + const breadcrumbs = [{ label: "Session Replays", href: "/replays" }, { label: shortId(sessionId, "session", { length: 8 }) }] return Result.builder(detailResult) .onInitial(() => ( diff --git a/apps/web/src/routes/select-plan.tsx b/apps/web/src/routes/select-plan.tsx index a2f5a793b2..339d89b94d 100644 --- a/apps/web/src/routes/select-plan.tsx +++ b/apps/web/src/routes/select-plan.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@clerk/clerk-react" import { useMapleCustomer } from "@/hooks/use-maple-customer" import { Navigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" +import { Badge } from "@maple/ui/components/ui/badge" import { RocketIcon } from "@/components/icons" import { BootSplash } from "@/components/boot-splash" import { PricingCards } from "@/components/settings/pricing-cards" @@ -87,10 +88,14 @@ function SelectPlanPageInner() {
      {!isReactivating && ( -
      - + + {TRIAL_DURATION_DAYS}-day free trial -
      + )}

      {isReactivating ? "Pick up where you left off" : "Start your free trial"} diff --git a/apps/web/src/routes/traces/$traceId.tsx b/apps/web/src/routes/traces/$traceId.tsx index be72e857f0..808e0baa1f 100644 --- a/apps/web/src/routes/traces/$traceId.tsx +++ b/apps/web/src/routes/traces/$traceId.tsx @@ -24,7 +24,8 @@ import { getSpanHierarchyResultAtom } from "@/lib/services/atoms/warehouse-query import { findSpanById } from "@maple/ui/components/traces/flow-utils" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" import { TraceIdBadge } from "@/components/traces/trace-id-badge" -import { getHttpInfo } from "@maple/ui/lib/http" +import { getHttpInfo, httpStatusTone } from "@maple/ui/lib/http" +import { shortId } from "@maple/ui/lib/ids" const TraceDetailSearchSchema = Schema.Struct({ spanId: Schema.optional(Schema.String), @@ -127,7 +128,7 @@ function TraceDetailPage() { @@ -163,7 +164,7 @@ function TraceDetailPage() { @@ -288,7 +289,7 @@ function TraceDetailContent({ s.spanAttributes?.["http.response.status_code"] || s.spanAttributes?.["http.status_code"] if (httpStatus) { const code = typeof httpStatus === "string" ? parseInt(httpStatus) : httpStatus - if (typeof code === "number" && code >= 500) return true + if (typeof code === "number" && httpStatusTone(code) === "crit") return true } return false }) @@ -296,7 +297,7 @@ function TraceDetailContent({ return ( diff --git a/packages/ui/src/components/service-map/service-map-node.tsx b/packages/ui/src/components/service-map/service-map-node.tsx index 5c4ab21678..f9899e8eb0 100644 --- a/packages/ui/src/components/service-map/service-map-node.tsx +++ b/packages/ui/src/components/service-map/service-map-node.tsx @@ -2,6 +2,8 @@ import { formatLatency } from "../../lib/format" import { memo } from "react" import { Handle, Position } from "@xyflow/react" import { cn } from "../../lib/utils" +import { ERROR_RATE_TEXT, type ErrorRateLevel, errorRateLevel } from "../../lib/error-rate" +import { TONE_FILL } from "../../lib/tone" import { latencyToneClass } from "../../lib/latency-tone" import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip" import { @@ -43,16 +45,24 @@ export function formatRate(value: number): string { return value.toFixed(2) } +const HEALTH_DOT_CLASS = { + crit: TONE_FILL.crit, + warn: TONE_FILL.warn, + neutral: TONE_FILL.ok, +} satisfies Record + +const SELECTED_BORDER_CLASS = { + crit: "border-severity-error ring-[3px] ring-severity-error/15", + warn: "border-severity-warn ring-[3px] ring-severity-warn/15", + neutral: "border-border-active ring-[3px] ring-foreground/15", +} satisfies Record + export function getHealthDotClass(errorRate: number): string { - if (errorRate > 0.05) return "bg-severity-error" - if (errorRate > 0.01) return "bg-severity-warn" - return "bg-severity-info" + return HEALTH_DOT_CLASS[errorRateLevel(errorRate)] } function getSelectedBorderClass(errorRate: number): string { - if (errorRate > 0.05) return "border-severity-error ring-[3px] ring-severity-error/15" - if (errorRate > 0.01) return "border-severity-warn ring-[3px] ring-severity-warn/15" - return "border-border-active ring-[3px] ring-foreground/15" + return SELECTED_BORDER_CLASS[errorRateLevel(errorRate)] } function MetricCell({ @@ -82,9 +92,8 @@ function MetricCell({ } function errorRateClass(errorRate: number): string { - if (errorRate > 0.05) return "text-severity-error" - if (errorRate > 0.01) return "text-severity-warn" - return "text-secondary-foreground" + const level = errorRateLevel(errorRate) + return level === "neutral" ? "text-secondary-foreground" : ERROR_RATE_TEXT[level] } const Handles = () => ( diff --git a/packages/ui/src/components/traces/flow-node.tsx b/packages/ui/src/components/traces/flow-node.tsx index dbe6cf5cc1..c553cfbe5c 100644 --- a/packages/ui/src/components/traces/flow-node.tsx +++ b/packages/ui/src/components/traces/flow-node.tsx @@ -3,6 +3,9 @@ import { Handle, Position } from "@xyflow/react" import { DatabaseIcon } from "../icons" import { cn } from "../../lib/utils" +import { httpStatusTone } from "../../lib/http" +import { TONE_SOFT } from "../../lib/tone" +import { TruncatedId } from "../ui/truncated-id" import { formatDuration } from "../../lib/format" import { cacheResultStyles } from "../../lib/cache" import type { CacheInfo } from "../../lib/cache" @@ -115,6 +118,7 @@ function StatusBadge({ } if (httpInfo?.statusCode != null) { const code = httpInfo.statusCode + const tone = httpStatusTone(code) return ( = 200 && code < 300 && "bg-severity-info/15 text-severity-info", code >= 300 && code < 400 && "bg-chart-p50/15 text-chart-p50", - code >= 400 && code < 500 && "bg-severity-warn/15 text-severity-warn", - code >= 500 && "bg-severity-error/15 text-severity-error", + tone !== "neutral" && TONE_SOFT[tone], code < 200 && "text-muted-foreground", )} > @@ -166,9 +169,7 @@ export const FlowSpanNode = memo(function FlowSpanNode({ data }: FlowSpanNodePro
      Missing Span - - {span.spanId.slice(0, 8)} - +
      Not ingested or dropped

      diff --git a/packages/ui/src/components/traces/span-row.tsx b/packages/ui/src/components/traces/span-row.tsx index b266a2c87e..0bd9af8abe 100644 --- a/packages/ui/src/components/traces/span-row.tsx +++ b/packages/ui/src/components/traces/span-row.tsx @@ -3,12 +3,14 @@ import { ChevronRightIcon, ChevronDownIcon, GlobeIcon } from "../icons" import { Badge } from "../ui/badge" import { Button } from "../ui/button" +import { TruncatedId } from "../ui/truncated-id" import { cn } from "../../lib/utils" import { formatDuration } from "../../lib/format" import { spanStartMs as spanStartMsOf } from "../../lib/span-tree" import { getServiceColor } from "../../lib/colors" import { getCacheInfo, cacheResultStyles } from "../../lib/cache" -import { getHttpInfo, HTTP_METHOD_COLORS } from "../../lib/http" +import { getHttpInfo, HTTP_METHOD_COLORS, httpStatusTone } from "../../lib/http" +import { TONE_TEXT } from "../../lib/tone" import { getCloudPlatform, outcomeBadgeStyle } from "../../lib/cloud-platforms" import { getSpanKindLabel, getSpanStatusBadgeClass } from "../../lib/span-kind" import { PixelDurationBar } from "./pixel-duration-bar" @@ -82,12 +84,11 @@ function SpanRowImpl({
      - - {span.spanId.slice(0, 8)} - +
      @@ -107,10 +108,12 @@ function SpanRowImpl({ const statusStyle = getSpanStatusBadgeClass(span.statusCode) const kindLabel = getSpanKindLabel(span.spanKind) + const httpTone = httpStatusTone(httpInfo?.statusCode) + const barColor = - httpInfo?.statusCode && httpInfo.statusCode >= 500 + httpTone === "crit" ? "bg-destructive" - : httpInfo?.statusCode && httpInfo.statusCode >= 400 + : httpTone === "warn" ? "bg-severity-warn" : span.statusCode === "Error" ? "bg-destructive" @@ -247,13 +250,11 @@ function SpanRowImpl({ = 500 - ? "text-severity-error" - : httpInfo.statusCode >= 400 - ? "text-severity-warn" - : httpInfo.statusCode >= 300 - ? "text-chart-p50" - : "text-severity-info", + httpTone !== "neutral" + ? TONE_TEXT[httpTone] + : httpInfo.statusCode >= 300 + ? "text-chart-p50" + : "text-severity-info", )} > {httpInfo.statusCode} diff --git a/packages/ui/src/components/traces/span-tooltip.tsx b/packages/ui/src/components/traces/span-tooltip.tsx index 3097610c1b..0a3bc9146e 100644 --- a/packages/ui/src/components/traces/span-tooltip.tsx +++ b/packages/ui/src/components/traces/span-tooltip.tsx @@ -1,6 +1,7 @@ import { formatDuration } from "../../lib/format" import { getServiceColor, calculateSelfTime } from "../../lib/colors" -import { getHttpInfo } from "../../lib/http" +import { getHttpInfo, httpStatusTone } from "../../lib/http" +import { TONE_TEXT } from "../../lib/tone" import { getSpanKindLabel } from "../../lib/span-kind" import { spanStartMs } from "../../lib/span-tree" import type { SpanNode } from "../../lib/types" @@ -98,11 +99,9 @@ export function SpanTooltipContent({ span, totalDurationMs, traceStartTime }: Sp HTTP = 400 - ? "text-severity-error" - : httpInfo.statusCode >= 300 - ? "text-severity-warn" - : "text-severity-info" + httpStatusTone(httpInfo.statusCode) === "neutral" + ? "text-severity-info" + : TONE_TEXT[httpStatusTone(httpInfo.statusCode)] } > {httpInfo.statusCode} diff --git a/packages/ui/src/components/ui/confirm-dialog.tsx b/packages/ui/src/components/ui/confirm-dialog.tsx index 9245c0fcb0..df521f043b 100644 --- a/packages/ui/src/components/ui/confirm-dialog.tsx +++ b/packages/ui/src/components/ui/confirm-dialog.tsx @@ -30,11 +30,13 @@ export interface ConfirmDialogProps { * A returned promise drives the pending state and closes the dialog when it * resolves; a rejection keeps it open so the caller's toast can explain. */ - onConfirm: () => void | Promise + onConfirm: () => void /** Externally owned pending state, for callers that track their own mutation. */ pending?: boolean + /** Keeps confirm disabled until a precondition holds (e.g. the typed name matches). */ + confirmDisabled?: boolean /** Third button between cancel and confirm (e.g. "Delete section only"). */ - secondaryAction?: { label: React.ReactNode; onClick: () => void | Promise } + secondaryAction?: { label: React.ReactNode; onClick: () => void } /** Extra body below the description (a typed-name check, a list of affected items). */ children?: React.ReactNode className?: string @@ -52,6 +54,7 @@ export function ConfirmDialog({ icon, onConfirm, pending: pendingProp = false, + confirmDisabled = false, secondaryAction, children, className, @@ -59,8 +62,9 @@ export function ConfirmDialog({ const [running, setRunning] = React.useState(false) const pending = pendingProp || running - const run = (action: () => void | Promise) => { - const result = action() + // Typed `() => void` so async handlers are accepted as-is; a returned promise is detected at runtime. + const run = (action: () => void) => { + const result: unknown = action() if (!(result instanceof Promise)) return setRunning(true) result.then( @@ -98,7 +102,7 @@ export function ConfirmDialog({
      diff --git a/packages/ui/src/components/ui/list-footer.tsx b/packages/ui/src/components/ui/list-footer.tsx index fb69676aba..67d1a8590a 100644 --- a/packages/ui/src/components/ui/list-footer.tsx +++ b/packages/ui/src/components/ui/list-footer.tsx @@ -1,6 +1,6 @@ import type * as React from "react" import { cn } from "../../lib/utils" -import { Button } from "./button" +import { Button, type ButtonProps } from "./button" import { Spinner } from "./spinner" /** "Load more" with a spinner while the next page is in flight. */ @@ -8,15 +8,17 @@ export function LoadMoreButton({ loading = false, onClick, label = "Load more", + variant = "outline", className, }: { loading?: boolean onClick: () => void label?: React.ReactNode + variant?: ButtonProps["variant"] className?: string }): React.ReactElement { return ( - ) @@ -37,6 +39,7 @@ export function ListFooter({ onLoadMore, align = "center", className, + children, }: { shown?: number total?: number @@ -50,6 +53,8 @@ export function ListFooter({ onLoadMore?: () => void align?: "start" | "center" className?: string + /** Extra trailing content (a source note, a deep link). */ + children?: React.ReactNode }): React.ReactElement | null { const count = shown === undefined || !noun @@ -67,7 +72,7 @@ export function ListFooter({ ) : null - if (!count && !button) return null + if (!count && !button && !children) return null return (
      {count ? {count} : null} {button} + {children}
      ) } diff --git a/packages/ui/src/components/ui/meter.tsx b/packages/ui/src/components/ui/meter.tsx index 16422fb049..e281253e81 100644 --- a/packages/ui/src/components/ui/meter.tsx +++ b/packages/ui/src/components/ui/meter.tsx @@ -63,11 +63,17 @@ export interface BarSegment { export function SegmentedBar({ segments, total, + minVisible = 0, + label, className, }: { segments: ReadonlyArray /** Defaults to the segment sum; pass a larger total to leave the remainder empty. */ total?: number + /** Smallest width, in percent, for any non-zero segment. */ + minVisible?: number + /** Accessible summary; the bar is `role="img"` with it and decorative without. */ + label?: string className?: string }): React.ReactElement { const sum = total ?? segments.reduce((acc, s) => acc + Math.max(0, s.value), 0) @@ -75,7 +81,9 @@ export function SegmentedBar({
      {sum > 0 ? segments.map((segment) => @@ -85,7 +93,7 @@ export function SegmentedBar({ title={segment.title} className={cn("h-full transition-[width] duration-500", segment.className)} style={{ - width: `${(segment.value / sum) * 100}%`, + width: `${Math.max((segment.value / sum) * 100, minVisible)}%`, backgroundColor: segment.color, }} /> diff --git a/packages/ui/src/lib/log-attributes.ts b/packages/ui/src/lib/log-attributes.ts index 1f40325efb..f174144362 100644 --- a/packages/ui/src/lib/log-attributes.ts +++ b/packages/ui/src/lib/log-attributes.ts @@ -9,6 +9,8 @@ // Kept dependency-free (structural `LogLike` instead of an app-specific `Log`) // so it can be consumed from web and React Native alike. +import { httpStatusTone } from "./http" + export type ChipTone = "error" | "warn" | "info" | "muted" export interface PickedAttribute { @@ -123,8 +125,9 @@ export function getChipTone(key: string, value: string, severityText: string, rp ) { const status = isNumericStatus(value) if (status !== null) { - if (status >= 500) return "error" - if (status >= 400) return "warn" + const tone = httpStatusTone(status) + if (tone === "crit") return "error" + if (tone === "warn") return "warn" if (status >= 300) return "info" return "muted" } From dba4c12c3bce5a4785cc6e9e124abc3d7ec4d91a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:48:09 +0200 Subject: [PATCH 4/5] fix(ui): keep ConfirmDialog open when the confirm handler reports failure Exit-returning mutations resolve on failure, so closing on any resolution dismissed the dialog after a failed API key revoke. A handler can now resolve to false to keep it open for a retry; revoke does so on failure. --- .../src/components/settings/api-keys-section.tsx | 15 ++++++++------- packages/ui/src/components/ui/confirm-dialog.tsx | 7 ++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index 78fd3c9679..a928f0a668 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -142,19 +142,20 @@ export function ApiKeysSection() { setRollOpen(true) } - async function handleRevoke() { - if (!revokingKey) return + async function handleRevoke(): Promise { + if (!revokingKey) return false prepareForMutation() const result = await revokeMutation({ params: { id: revokingKey.id } }) if (Exit.isSuccess(result)) { toastManager.add({ title: "API key revoked", type: "success" }) void reconcileTxid(result.value.txid) - } else { - const { title, message } = displayError(result) - toastManager.add({ title, description: message, type: "error" }) + // ConfirmDialog closes on `true`. `revokingKey` stays set so the copy doesn't swap mid-animation. + return true } - // ConfirmDialog closes once this resolves. Keep `revokingKey` set so the dialog copy doesn't swap to the generic - // fallback while the close animation plays; the next open overwrites it. + const { title, message } = displayError(result) + toastManager.add({ title, description: message, type: "error" }) + // `false` keeps the dialog open so the user can retry. + return false } // One pass, one clock. An expired key used to count as "Active" and sit in the active list behind diff --git a/packages/ui/src/components/ui/confirm-dialog.tsx b/packages/ui/src/components/ui/confirm-dialog.tsx index df521f043b..995c220ad5 100644 --- a/packages/ui/src/components/ui/confirm-dialog.tsx +++ b/packages/ui/src/components/ui/confirm-dialog.tsx @@ -28,7 +28,8 @@ export interface ConfirmDialogProps { icon?: React.ReactNode | null /** * A returned promise drives the pending state and closes the dialog when it - * resolves; a rejection keeps it open so the caller's toast can explain. + * resolves. Resolving to `false` (an Exit-style failure) or rejecting keeps it + * open so the user can retry after the caller's toast. */ onConfirm: () => void /** Externally owned pending state, for callers that track their own mutation. */ @@ -68,9 +69,9 @@ export function ConfirmDialog({ if (!(result instanceof Promise)) return setRunning(true) result.then( - () => { + (value: unknown) => { setRunning(false) - onOpenChange(false) + if (value !== false) onOpenChange(false) }, () => setRunning(false), ) From 9be30bd9c05c9af798227bec79c0d7214f48c427 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 5 Oct 2026 23:55:17 +0200 Subject: [PATCH 5/5] fix(ui): rename Badge shape to a pill boolean, pad ConfirmDialog bodies The effect lint rejects a prop named for structure, so `shape="pill"` becomes `pill`. ConfirmDialog now pads its children itself, which the dialog-padding sweep requires for AlertDialog bodies; callers drop their px-6. --- .../src/views/session-detail-view.tsx | 4 ++-- .../local-ui/src/views/sessions-list-view.tsx | 4 ++-- .../components/account/profile-section.tsx | 2 +- .../agent-sessions/agent-sessions-list.tsx | 2 +- .../session-detail/session-transcript.tsx | 4 ++-- .../session-detail/session-waterfall.tsx | 8 +++---- .../session-detail/span-expansion.tsx | 6 +++--- .../anomalies/anomaly-linked-issue-card.tsx | 2 +- .../src/components/anomalies/anomaly-row.tsx | 10 ++++----- .../components/anomalies/anomaly-sidebar.tsx | 2 +- .../src/components/errors/issue-timeline.tsx | 2 +- .../header/onboarding-checklist-button.tsx | 2 +- .../components/ingest/connection-status.tsx | 2 +- .../integrations/integration-catalog.tsx | 4 ++-- .../components/releases/release-health.tsx | 2 +- .../replays/replay-editor-timeline.tsx | 2 +- .../src/components/replays/session-pills.tsx | 4 ++-- .../src/components/replays/sessions-list.tsx | 8 +++---- .../settings/organization-section.tsx | 2 +- .../src/components/settings/plan-offer.tsx | 4 ++-- apps/web/src/routes/select-plan.tsx | 2 +- packages/ui/src/components/ui/badge.tsx | 21 ++++++++++--------- .../ui/src/components/ui/confirm-dialog.tsx | 3 ++- 23 files changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/local-ui/src/views/session-detail-view.tsx b/apps/local-ui/src/views/session-detail-view.tsx index cd0e85d22f..87ff2e1f04 100644 --- a/apps/local-ui/src/views/session-detail-view.tsx +++ b/apps/local-ui/src/views/session-detail-view.tsx @@ -358,7 +358,7 @@ function TranscriptBody({ event }: { event: SessionTranscriptOutput }) { function StatusBadge({ active }: { active: boolean }) { if (active) { return ( - + @@ -368,7 +368,7 @@ function StatusBadge({ active }: { active: boolean }) { ) } return ( - + Ended ) diff --git a/apps/local-ui/src/views/sessions-list-view.tsx b/apps/local-ui/src/views/sessions-list-view.tsx index e783982fd7..f32714f64a 100644 --- a/apps/local-ui/src/views/sessions-list-view.tsx +++ b/apps/local-ui/src/views/sessions-list-view.tsx @@ -215,12 +215,12 @@ function SessionCard({ session, href }: { session: SessionReplaysListOutput; hre title="page views" /> {session.traceCount > 0 && ( - + {session.traceCount} trace{session.traceCount === 1 ? "" : "s"} )} {session.errorCount > 0 && ( - + {session.errorCount} diff --git a/apps/web/src/components/account/profile-section.tsx b/apps/web/src/components/account/profile-section.tsx index a35f0e1b4e..4943122dbe 100644 --- a/apps/web/src/components/account/profile-section.tsx +++ b/apps/web/src/components/account/profile-section.tsx @@ -295,7 +295,7 @@ export function ProfileSection() { onConfirm={() => void handleDelete()} > {/* AlertDialog has no panel slot, so a body between header and footer pads itself. */} -
      +
      diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 2336e1d9f9..62cbefec32 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -875,7 +875,7 @@ function ErrorChip({ }) { return ( {Icon ? ( diff --git a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx index fa2afadba2..41b781c7c5 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-transcript.tsx @@ -437,7 +437,7 @@ function TurnChapter({ {turnOrdinal(turn)} {turn.failed && ( - + Failed )} @@ -710,7 +710,7 @@ function AssistantBlock({ > {(!continued || row.failed) && Assistant} {row.failed && ( - + Failed )} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx index ff5183362c..03719c2c8a 100644 --- a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx @@ -515,12 +515,12 @@ function TurnHeader({ )} {turn.failed && ( - + Failed )} {collapsed && ( - + {row.visibleCount} spans )} @@ -648,12 +648,12 @@ function SpanRow({ {span.statusMessage} )} {errored && ( - + {span.genAi.errorType ?? "Error"} )} {isDelegation(span, spansById) && ( - + Subagent )} diff --git a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx index eb7be5f190..34c49d0b9e 100644 --- a/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx +++ b/apps/web/src/components/agent-sessions/session-detail/span-expansion.tsx @@ -679,12 +679,12 @@ function FailureBanner({ span }: { span: AiSessionSpan }) { This call failed {span.statusCode === "Error" && ( - + span status Error )} {errorType !== undefined && errorType !== "" && ( - + error.type {errorType} )} @@ -693,7 +693,7 @@ function FailureBanner({ span }: { span: AiSessionSpan }) { {span.statusCode !== "Error" && (errorType === undefined || errorType === "") && responseStatus !== undefined && ( - + response.status {responseStatus} )} diff --git a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx index 8b8bbb28e7..f1f4d73790 100644 --- a/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx +++ b/apps/web/src/components/anomalies/anomaly-linked-issue-card.tsx @@ -112,7 +112,7 @@ function LinkedIssueBody({ {shortIssueId(issue.id)} - +