From d44b3ee800fb31f50922d89db14c20fe99207aa0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 22 Jul 2026 15:22:28 +0200 Subject: [PATCH] feat(local-ui): metrics/services/errors views + promote shared chrome into @maple/ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the filter chrome the local UI had hand-copied from apps/web into packages/ui so both apps render from one source: filter-section (web's superset with serviceColorMap/option icons), filter-sidebar (width-agnostic frame), duration-range-filter (web's onRangeChange API + configurable debounceMs and flush on blur/Enter), the generic toolbar family (data- agnostic: RefreshButton takes onRefresh, TimeRangeSelect takes ranges, so @maple/ui gains no react-query dep), metric-type-badge, and the pure replay-format formatters (the warehouse partition-window helper stays in web — @maple/ui must not depend on @maple/query-engine). Web paths become re-export shims so no consumers change; local copies are deleted. Close most of the local-mode feature gap with three new views backed by MVs that already exist in the local chDB schema and existing CH queries (all 15 smoke-tested against a live chDB binary, incl. the windowed metricsTimeseriesRateQuery): - #/metrics + #/metrics/:name — sparkline preview grid with type/service facets; detail plots true per-second rate for monotonic counters and avg for gauges/histograms via the shared QueryBuilderLineChart, plus a per-service breakdown table - #/services + #/services/:name — dense table (spans/errors/error rate/ p50/p95/logs) with env/ns facets; detail shows golden-signal cards, throughput-by-operation chart, and a top-operations table deep-linking into filtered traces - #/errors — summary stats, fingerprint-grouped error cards with service/ environment facets, expandable to recent affected traces linking to the trace waterfall span-detail-panel/log-detail-sheet stay intentionally divergent (web's are effect-atom/infra-coupled); their headers now document that instead of claiming to mirror web. Co-Authored-By: Claude Fable 5 --- apps/local-ui/src/App.tsx | 77 +++++- .../src/components/duration-range-filter.tsx | 216 ---------------- .../src/components/filter-sidebar.tsx | 64 ----- .../src/components/log-detail-sheet.tsx | 5 + .../src/components/span-detail-panel.tsx | 5 + apps/local-ui/src/components/toolbar.tsx | 143 ++--------- apps/local-ui/src/hooks/use-local-errors.ts | 115 +++++++++ apps/local-ui/src/hooks/use-local-logs.ts | 2 +- .../src/hooks/use-local-metric-detail.ts | 123 +++++++++ apps/local-ui/src/hooks/use-local-metrics.ts | Bin 0 -> 6530 bytes .../src/hooks/use-local-service-catalog.ts | 110 ++++++++ .../src/hooks/use-local-service-detail.ts | 116 +++++++++ apps/local-ui/src/hooks/use-local-services.ts | 2 +- apps/local-ui/src/hooks/use-local-sessions.ts | 2 +- .../src/hooks/use-local-trace-facets.ts | 10 +- apps/local-ui/src/lib/replay-format.ts | 42 ---- apps/local-ui/src/views/errors-view.tsx | 224 +++++++++++++++++ apps/local-ui/src/views/logs-view.tsx | 6 +- .../local-ui/src/views/metric-detail-view.tsx | 189 ++++++++++++++ apps/local-ui/src/views/metrics-list-view.tsx | 209 +++++++++++++++ .../src/views/service-detail-view.tsx | 237 ++++++++++++++++++ .../local-ui/src/views/services-list-view.tsx | 166 ++++++++++++ .../src/views/session-detail-view.tsx | 2 +- .../local-ui/src/views/sessions-list-view.tsx | 8 +- apps/local-ui/src/views/trace-list-view.tsx | 17 +- .../src/components/filters/filter-section.tsx | 215 +--------------- .../src/components/filters/filter-sidebar.tsx | 101 +------- .../components/metrics/metric-type-badge.tsx | 39 +-- .../src/components/replays/replay-format.ts | 79 +----- .../traces/duration-range-filter.tsx | 208 +-------------- .../filters/duration-range-filter.tsx | 230 +++++++++++++++++ .../components/filters}/filter-section.tsx | 102 ++++---- .../src/components/filters/filter-sidebar.tsx | 92 +++++++ .../components/metrics/metric-type-badge.tsx | 45 ++++ packages/ui/src/components/toolbar.tsx | 152 +++++++++++ packages/ui/src/lib/replay-format.ts | 77 ++++++ 36 files changed, 2305 insertions(+), 1125 deletions(-) delete mode 100644 apps/local-ui/src/components/duration-range-filter.tsx delete mode 100644 apps/local-ui/src/components/filter-sidebar.tsx create mode 100644 apps/local-ui/src/hooks/use-local-errors.ts create mode 100644 apps/local-ui/src/hooks/use-local-metric-detail.ts create mode 100644 apps/local-ui/src/hooks/use-local-metrics.ts create mode 100644 apps/local-ui/src/hooks/use-local-service-catalog.ts create mode 100644 apps/local-ui/src/hooks/use-local-service-detail.ts delete mode 100644 apps/local-ui/src/lib/replay-format.ts create mode 100644 apps/local-ui/src/views/errors-view.tsx create mode 100644 apps/local-ui/src/views/metric-detail-view.tsx create mode 100644 apps/local-ui/src/views/metrics-list-view.tsx create mode 100644 apps/local-ui/src/views/service-detail-view.tsx create mode 100644 apps/local-ui/src/views/services-list-view.tsx create mode 100644 packages/ui/src/components/filters/duration-range-filter.tsx rename {apps/local-ui/src/components => packages/ui/src/components/filters}/filter-section.tsx (58%) create mode 100644 packages/ui/src/components/filters/filter-sidebar.tsx create mode 100644 packages/ui/src/components/metrics/metric-type-badge.tsx create mode 100644 packages/ui/src/components/toolbar.tsx create mode 100644 packages/ui/src/lib/replay-format.ts diff --git a/apps/local-ui/src/App.tsx b/apps/local-ui/src/App.tsx index 69b04b91d..2b84b510b 100644 --- a/apps/local-ui/src/App.tsx +++ b/apps/local-ui/src/App.tsx @@ -3,10 +3,22 @@ import { Toaster, toast } from "sonner" import { cn } from "@maple/ui/utils" import { AttributesProvider } from "@maple/ui/components/attributes" import { Badge } from "@maple/ui/components/ui/badge" -import { CodeIcon, EyeIcon, NetworkNodesIcon } from "@maple/ui/components/icons" +import { + CircleWarningIcon, + CodeIcon, + DatabaseIcon, + EyeIcon, + NetworkNodesIcon, + PulseIcon, +} from "@maple/ui/components/icons" import { TraceListView } from "./views/trace-list-view" import { TraceDetailView } from "./views/trace-detail-view" import { LogsView } from "./views/logs-view" +import { MetricsListView } from "./views/metrics-list-view" +import { MetricDetailView } from "./views/metric-detail-view" +import { ErrorsView } from "./views/errors-view" +import { ServicesListView } from "./views/services-list-view" +import { ServiceDetailView } from "./views/service-detail-view" import { SessionsListView } from "./views/sessions-list-view" import { SessionDetailView } from "./views/session-detail-view" import { navigate, useLocation } from "./lib/router" @@ -24,23 +36,38 @@ type Route = | { name: "traces" } | { name: "trace-detail"; traceId: string } | { name: "logs" } + | { name: "metrics" } + | { name: "metric-detail"; metricName: string } + | { name: "services" } + | { name: "service-detail"; serviceName: string } + | { name: "errors" } | { name: "sessions" } | { name: "session-detail"; sessionId: string } function parseRoute(path: string): Route { const traceDetail = path.match(/^\/traces\/(.+)$/) if (traceDetail) return { name: "trace-detail", traceId: decodeURIComponent(traceDetail[1]) } + const metricDetail = path.match(/^\/metrics\/(.+)$/) + if (metricDetail) return { name: "metric-detail", metricName: decodeURIComponent(metricDetail[1]) } + const serviceDetail = path.match(/^\/services\/(.+)$/) + if (serviceDetail) return { name: "service-detail", serviceName: decodeURIComponent(serviceDetail[1]) } const sessionDetail = path.match(/^\/sessions\/(.+)$/) if (sessionDetail) return { name: "session-detail", sessionId: decodeURIComponent(sessionDetail[1]) } + if (path.startsWith("/errors")) return { name: "errors" } if (path.startsWith("/logs")) return { name: "logs" } + if (path.startsWith("/metrics")) return { name: "metrics" } + if (path.startsWith("/services")) return { name: "services" } if (path.startsWith("/sessions")) return { name: "sessions" } return { name: "traces" } } -type Tab = "traces" | "logs" | "sessions" +type Tab = "traces" | "logs" | "metrics" | "services" | "errors" | "sessions" function activeTab(route: Route): Tab { + if (route.name === "errors") return "errors" if (route.name === "logs") return "logs" + if (route.name === "metrics" || route.name === "metric-detail") return "metrics" + if (route.name === "services" || route.name === "service-detail") return "services" if (route.name === "sessions" || route.name === "session-detail") return "sessions" return "traces" } @@ -94,6 +121,24 @@ export function App() { active={tab === "logs"} onClick={() => switchTab("/logs")} /> + } + active={tab === "metrics"} + onClick={() => switchTab("/metrics")} + /> + } + active={tab === "services"} + onClick={() => switchTab("/services")} + /> + } + active={tab === "errors"} + onClick={() => switchTab("/errors")} + /> } @@ -120,6 +165,34 @@ export function App() { onBack={() => navigate("/sessions", carry())} onSelectTrace={(traceId) => navigate(`/traces/${encodeURIComponent(traceId)}`)} /> + ) : route.name === "metric-detail" ? ( + navigate("/metrics", carry())} + /> + ) : route.name === "metrics" ? ( + + navigate(`/metrics/${encodeURIComponent(metricName)}`, carry()) + } + /> + ) : route.name === "service-detail" ? ( + navigate("/services", carry())} + /> + ) : route.name === "services" ? ( + + navigate(`/services/${encodeURIComponent(serviceName)}`, carry()) + } + /> + ) : route.name === "errors" ? ( + + navigate(`/traces/${encodeURIComponent(traceId)}`, carry()) + } + /> ) : route.name === "logs" ? ( ) : route.name === "sessions" ? ( diff --git a/apps/local-ui/src/components/duration-range-filter.tsx b/apps/local-ui/src/components/duration-range-filter.tsx deleted file mode 100644 index 5a1e9f429..000000000 --- a/apps/local-ui/src/components/duration-range-filter.tsx +++ /dev/null @@ -1,216 +0,0 @@ -// Min/max duration filter for the filter sidebar — mirrors the web app's -// `@/components/traces/duration-range-filter`, with one local adaptation: -// changes are debounced (the web pushes per keystroke into router state, but -// locally every change re-queries chDB) and flushed on blur/Enter. - -import * as React from "react" -import { ChevronDownIcon, XmarkIcon } from "@maple/ui/components/icons" -import { cn } from "@maple/ui/utils" -import { Input } from "@maple/ui/components/ui/input" -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" -import type { DurationStats } from "../hooks/use-local-trace-facets" - -const DEBOUNCE_MS = 300 - -const flushOnEnter = (flush: () => void) => (event: React.KeyboardEvent) => { - if (event.key === "Enter") flush() -} - -interface DurationRangeFilterProps { - minValue: number | undefined - maxValue: number | undefined - onMinChange: (value: number | undefined) => void - onMaxChange: (value: number | undefined) => void - durationStats?: DurationStats - defaultOpen?: boolean -} - -function useDebouncedNumberInput(value: number | undefined, onChange: (value: number | undefined) => void) { - const [text, setText] = React.useState(value != null ? String(value) : "") - const timeoutRef = React.useRef | undefined>(undefined) - - // Re-sync from the URL when it changes externally (e.g. "Clear all" or a preset chip). - const [lastValue, setLastValue] = React.useState(value) - if (value !== lastValue) { - setLastValue(value) - setText(value != null ? String(value) : "") - } - - const commit = React.useCallback( - (raw: string) => { - const parsed = Number(raw) - onChange(raw === "" || !Number.isFinite(parsed) || parsed < 0 ? undefined : Math.round(parsed)) - }, - [onChange], - ) - - const handleChange = (e: React.ChangeEvent) => { - const raw = e.target.value - setText(raw) - clearTimeout(timeoutRef.current) - timeoutRef.current = setTimeout(() => commit(raw), DEBOUNCE_MS) - } - - const flush = () => { - clearTimeout(timeoutRef.current) - commit(text) - } - - React.useEffect(() => () => clearTimeout(timeoutRef.current), []) - - return { text, handleChange, flush } -} - -export function DurationRangeFilter({ - minValue, - maxValue, - onMinChange, - onMaxChange, - durationStats, - defaultOpen = false, -}: DurationRangeFilterProps) { - const hasActiveRange = minValue !== undefined || maxValue !== undefined - const [isOpen, setIsOpen] = React.useState(defaultOpen || hasActiveRange) - const min = useDebouncedNumberInput(minValue, onMinChange) - const max = useDebouncedNumberInput(maxValue, onMaxChange) - - const applyPreset = (minMs: number) => { - if (minValue === Math.round(minMs) && maxValue === undefined) { - onMinChange(undefined) - return - } - onMinChange(Math.round(minMs)) - onMaxChange(undefined) - } - - const clearRange = () => { - onMinChange(undefined) - onMaxChange(undefined) - } - - const presets: Array<{ key: string; label: string; minMs: number }> = [] - if (durationStats && durationStats.p50DurationMs > 0) { - presets.push({ - key: "p50", - label: `> p50 · ${formatDuration(durationStats.p50DurationMs)}`, - minMs: durationStats.p50DurationMs, - }) - } - if (durationStats && durationStats.p95DurationMs > 0) { - presets.push({ - key: "p95", - label: `> p95 · ${formatDuration(durationStats.p95DurationMs)}`, - minMs: durationStats.p95DurationMs, - }) - } - presets.push({ key: "1s", label: "> 1s", minMs: 1000 }) - - return ( - - - Duration - - {!isOpen && hasActiveRange && ( - - {formatRange(minValue, maxValue)} - { - e.stopPropagation() - clearRange() - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - e.stopPropagation() - clearRange() - } - }} - > - - - - )} - - - - -
-
- {presets.map((preset) => { - const isActive = minValue === Math.round(preset.minMs) && maxValue === undefined - return ( - - ) - })} -
-
- - - - ms -
-
-
-
- ) -} - -function formatRange(minValue: number | undefined, maxValue: number | undefined): string { - if (minValue !== undefined && maxValue !== undefined) { - return `${formatDuration(minValue)} – ${formatDuration(maxValue)}` - } - if (minValue !== undefined) { - return `≥ ${formatDuration(minValue)}` - } - return `≤ ${formatDuration(maxValue ?? 0)}` -} - -function formatDuration(ms: number): string { - if (ms < 1) { - return `${(ms * 1000).toFixed(0)}us` - } - if (ms < 1000) { - return `${ms.toFixed(1)}ms` - } - return `${(ms / 1000).toFixed(2)}s` -} diff --git a/apps/local-ui/src/components/filter-sidebar.tsx b/apps/local-ui/src/components/filter-sidebar.tsx deleted file mode 100644 index 7ccc78bcb..000000000 --- a/apps/local-ui/src/components/filter-sidebar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// Filter-sidebar frame — mirrors the web app's `@/components/filters/filter-sidebar`. - -import type { ReactNode } from "react" -import { Separator } from "@maple/ui/components/ui/separator" -import { ScrollArea } from "@maple/ui/components/ui/scroll-area" -import { cn } from "@maple/ui/utils" - -export function FilterSidebarFrame({ - children, - waiting = false, - className, -}: { - children: ReactNode - waiting?: boolean - className?: string -}) { - return ( -
- {children} -
- ) -} - -export function FilterSidebarHeader({ - title = "Filters", - canClear = false, - onClear, -}: { - title?: string - canClear?: boolean - onClear?: () => void -}) { - return ( -
-

{title}

- {canClear && onClear && ( - - )} -
- ) -} - -export function FilterSidebarBody({ children }: { children: ReactNode }) { - return ( - <> - -
- -
{children}
-
-
-
- - ) -} diff --git a/apps/local-ui/src/components/log-detail-sheet.tsx b/apps/local-ui/src/components/log-detail-sheet.tsx index 9d0f33b71..2bb9d0f93 100644 --- a/apps/local-ui/src/components/log-detail-sheet.tsx +++ b/apps/local-ui/src/components/log-detail-sheet.tsx @@ -1,3 +1,8 @@ +// Intentionally divergent from the web app's `@/components/logs/log-detail-sheet`: +// that one is wired to effect-atom (trace timeline, correlated logs) and a wider +// sub-component family local mode doesn't have. The shareable pieces (AttributesTable, +// SeverityBadge, severity/format libs) already come from @maple/ui. + import { useMemo, useState } from "react" import { Sheet, SheetContent, SheetTitle } from "@maple/ui/components/ui/sheet" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@maple/ui/components/ui/tabs" diff --git a/apps/local-ui/src/components/span-detail-panel.tsx b/apps/local-ui/src/components/span-detail-panel.tsx index 32fce2b21..daec21ae4 100644 --- a/apps/local-ui/src/components/span-detail-panel.tsx +++ b/apps/local-ui/src/components/span-detail-panel.tsx @@ -1,3 +1,8 @@ +// Intentionally divergent from the web app's `@/components/traces/span-detail-panel`: +// that one is wired to effect-atom, infra correlation, and timezone preferences local +// mode doesn't have. The shareable pieces (AttributesTable, SeverityBadge, +// HttpSpanLabel, format/colors libs) already come from @maple/ui. + import { useState } from "react" import { toast } from "sonner" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" diff --git a/apps/local-ui/src/components/toolbar.tsx b/apps/local-ui/src/components/toolbar.tsx index 62395725c..6bcf9b2f8 100644 --- a/apps/local-ui/src/components/toolbar.tsx +++ b/apps/local-ui/src/components/toolbar.tsx @@ -1,128 +1,29 @@ -// Sticky page toolbar — search + result stats + time range. Mirrors the web -// app's `ReplaysToolbar` so the chrome reads identically in local mode. +// Local bindings for the shared @maple/ui toolbar family: the refresh button +// invalidates the `["local", …]` React Query prefix, and the time-range select +// is bound to local mode's presets. -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react" +import { useCallback } from "react" import { useQueryClient } from "@tanstack/react-query" -import { ArrowRotateClockwiseIcon, ClockIcon, MagnifierIcon, XmarkIcon } from "@maple/ui/components/icons" -import { Button } from "@maple/ui/components/ui/button" import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@maple/ui/components/ui/input-group" -import { NativeSelect, NativeSelectOption } from "@maple/ui/components/ui/native-select" -import { cn } from "@maple/ui/utils" + RefreshButton as SharedRefreshButton, + TimeRangeSelect as SharedTimeRangeSelect, +} from "@maple/ui/components/toolbar" import { TIME_RANGES } from "../lib/time" -export function Toolbar({ search, stats }: { search: ReactNode; stats: ReactNode }) { - return ( -
- {search} -
{stats}
-
- ) -} +export { Toolbar, ToolbarSearch, ToolbarStat } from "@maple/ui/components/toolbar" /** * Manual reload for the active view. Every local hook keys off `["local", …]`, * so invalidating that prefix refetches exactly the mounted view's queries - * (list + facets) — React Query only refetches active observers. Self-contained - * so it can drop into any toolbar or detail header. + * (list + facets) — React Query only refetches active observers. */ export function RefreshButton({ className }: { className?: string }) { const queryClient = useQueryClient() - const [spinning, setSpinning] = useState(false) - - const onClick = useCallback(() => { - setSpinning(true) - queryClient.invalidateQueries({ queryKey: ["local"] }).finally(() => setSpinning(false)) - }, [queryClient]) - - return ( - - ) -} - -export function ToolbarSearch({ - query, - onSearch, - placeholder, -}: { - query: string - onSearch: (value: string | undefined) => void - placeholder: string -}) { - const [value, setValue] = useState(query) - const debounceRef = useRef | null>(null) - - // Keep the input in sync when the param changes elsewhere (e.g. Clear all). - useEffect(() => { - setValue(query) - }, [query]) - - const handleChange = useCallback( - (next: string) => { - setValue(next) - if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => { - onSearch(next.trim() || undefined) - }, 300) - }, - [onSearch], - ) - - return ( - - - - - handleChange(e.target.value)} - placeholder={placeholder} - /> - {value && ( - - handleChange("")}> - - - - )} - - ) -} - -export function ToolbarStat({ - value, - label, - dot, - danger, -}: { - value: number - label: string - dot?: boolean - danger?: boolean -}) { - return ( - - {dot ? : null} - 0 && "text-destructive")}> - {value.toLocaleString()} - - {label} - + const onRefresh = useCallback( + () => queryClient.invalidateQueries({ queryKey: ["local"] }), + [queryClient], ) + return } const RANGE_LABELS: Record = { @@ -133,17 +34,11 @@ const RANGE_LABELS: Record = { "30d": "Last 30 days", } +const RANGE_OPTIONS = TIME_RANGES.map((range) => ({ + key: range.key, + label: RANGE_LABELS[range.key] ?? range.label, +})) + export function TimeRangeSelect({ value, onChange }: { value: string; onChange: (next: string) => void }) { - return ( -
- - onChange(e.target.value)}> - {TIME_RANGES.map((range) => ( - - {RANGE_LABELS[range.key] ?? range.label} - - ))} - -
- ) + return } diff --git a/apps/local-ui/src/hooks/use-local-errors.ts b/apps/local-ui/src/hooks/use-local-errors.ts new file mode 100644 index 000000000..95b4ced4a --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-errors.ts @@ -0,0 +1,115 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { CH } from "@maple/query-engine" +import { Option } from "effect" +import { executeLocalCompiledFirstRow, executeLocalCompiledQuery } from "@/lib/query" +import { LOCAL_ORG_ID } from "../lib/constants" +import { boundsForRange } from "../lib/time" + +export interface ErrorsFilters { + /** Exact service name match. */ + service?: string + /** Exact `deployment.environment` resource attribute. */ + env?: string + /** Restrict to root-span errors. */ + rootOnly?: boolean + /** Time-range preset key (see `TIME_RANGES`). */ + range?: string +} + +function commonOpts(filters: ErrorsFilters) { + return { + rootOnly: filters.rootOnly, + services: filters.service ? [filters.service] : undefined, + deploymentEnvs: filters.env ? [filters.env] : undefined, + } +} + +/** Headline stats for the errors view (error_events × service_usage). */ +export function useLocalErrorsSummary(filters: ErrorsFilters) { + return useQuery({ + queryKey: ["local", "errors", "summary", filters], + placeholderData: keepPreviousData, + queryFn: async (): Promise => { + const { startTime, endTime } = boundsForRange(filters.range) + const row = await executeLocalCompiledFirstRow( + CH.compile(CH.errorsSummaryQuery(commonOpts(filters)), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }), + ) + return Option.getOrNull(row) + }, + }) +} + +/** Fingerprint-grouped error types, most frequent first. */ +export function useLocalErrorsByType(filters: ErrorsFilters) { + return useQuery({ + queryKey: ["local", "errors", "by-type", filters], + placeholderData: keepPreviousData, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(filters.range) + return executeLocalCompiledQuery( + CH.compile(CH.errorsByTypeQuery({ ...commonOpts(filters), limit: 50 }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }), + ) + }, + }) +} + +export interface ErrorsFacets { + services: Array<{ name: string; count: number }> + environments: Array<{ name: string; count: number }> +} + +/** Service + environment facet counts for the sidebar (UNION query). */ +export function useLocalErrorsFacets(filters: ErrorsFilters) { + return useQuery({ + queryKey: ["local", "errors", "facets", filters], + placeholderData: keepPreviousData, + queryFn: async (): Promise => { + const { startTime, endTime } = boundsForRange(filters.range) + const rows = await executeLocalCompiledQuery( + CH.compileUnion(CH.errorsFacetsQuery({ rootOnly: filters.rootOnly }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }), + ) + const pick = (facetType: string) => + rows + .filter((r) => r.facetType === facetType) + .map((r) => ({ name: r.name, count: Number(r.count) })) + return { services: pick("service"), environments: pick("environment") } + }, + }) +} + +/** Most recently errored traces for one fingerprint (expanded row). */ +export function useLocalErrorTraces( + fingerprintHash: string | undefined, + filters: ErrorsFilters, +) { + return useQuery({ + queryKey: ["local", "errors", "traces", fingerprintHash, filters], + enabled: !!fingerprintHash, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(filters.range) + return executeLocalCompiledQuery( + CH.compile( + CH.errorDetailTracesQuery({ + fingerprintHash: fingerprintHash!, + rootOnly: filters.rootOnly, + services: filters.service ? [filters.service] : undefined, + limit: 10, + }), + { orgId: LOCAL_ORG_ID, startTime, endTime }, + ), + ) + }, + }) +} diff --git a/apps/local-ui/src/hooks/use-local-logs.ts b/apps/local-ui/src/hooks/use-local-logs.ts index fe481e29c..30e017061 100644 --- a/apps/local-ui/src/hooks/use-local-logs.ts +++ b/apps/local-ui/src/hooks/use-local-logs.ts @@ -3,7 +3,7 @@ import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" import { boundsForRange } from "../lib/time" -import type { FilterOption } from "../components/filter-section" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" const PAGE_SIZE = 50 diff --git a/apps/local-ui/src/hooks/use-local-metric-detail.ts b/apps/local-ui/src/hooks/use-local-metric-detail.ts new file mode 100644 index 000000000..eb80f8cde --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-metric-detail.ts @@ -0,0 +1,123 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { CH } from "@maple/query-engine" +import { executeLocalCompiledQuery } from "@/lib/query" +import { LOCAL_ORG_ID } from "../lib/constants" +import { boundsForRange } from "../lib/time" +import { bucketSecondsForRange, type MetricEntry } from "./use-local-metrics" + +/** Catalog row(s) for one metric, aggregated across services. */ +export function useLocalMetricEntry(metricName: string, range: string | undefined) { + return useQuery({ + queryKey: ["local", "metrics", "entry", metricName, range], + placeholderData: keepPreviousData, + queryFn: async (): Promise => { + const { startTime, endTime } = boundsForRange(range) + const compiled = CH.compile(CH.listMetricsQuery({ search: metricName, limit: 50 }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }) + const rows = (await executeLocalCompiledQuery(compiled)).filter( + (r) => r.metricName === metricName, + ) + if (rows.length === 0) return null + const first = rows[0]! + return { + metricName, + metricType: first.metricType, + metricUnit: first.metricUnit, + metricDescription: first.metricDescription, + serviceNames: [...new Set(rows.map((r) => r.serviceName))], + dataPointCount: rows.reduce((sum, r) => sum + Number(r.dataPointCount), 0), + lastSeen: rows.reduce((max, r) => (r.lastSeen > max ? r.lastSeen : max), first.lastSeen), + isMonotonic: Number(first.isMonotonic) === 1, + } + }, + }) +} + +export interface MetricSeriesPoint { + bucket: string + groupName: string + value: number +} + +/** + * Detail timeseries, one series per service. Monotonic counters plot the true + * per-second rate (window-CTE query); gauges/histograms plot the average value. + */ +export function useLocalMetricTimeseries(entry: MetricEntry | null | undefined, range: string | undefined) { + const metricName = entry?.metricName + const isRate = entry?.metricType === "sum" && entry.isMonotonic + return useQuery({ + queryKey: ["local", "metrics", "timeseries", metricName, entry?.metricType, isRate, range], + enabled: entry != null, + placeholderData: keepPreviousData, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(range) + const bucketSeconds = bucketSecondsForRange(range) + const params = { orgId: LOCAL_ORG_ID, startTime, endTime, bucketSeconds, metricName: metricName! } + if (isRate) { + const rows = await executeLocalCompiledQuery( + CH.compile( + CH.metricsTimeseriesRateQuery({ metricName: metricName!, bucketSeconds }), + params, + ), + ) + return rows.map((r) => ({ + bucket: r.bucket, + groupName: r.groupName, + value: Number(r.rateValue), + })) + } + const rows = await executeLocalCompiledQuery( + CH.compile( + CH.metricsTimeseriesQuery({ + metricType: entry!.metricType as CH.MetricsTimeseriesOpts["metricType"], + }), + params, + ), + ) + return rows.map((r) => ({ + bucket: r.bucket, + groupName: r.groupName, + value: Number(r.avgValue), + })) + }, + }) +} + +export interface MetricBreakdownRow { + name: string + avgValue: number + sumValue: number + count: number +} + +/** Per-service breakdown for the detail page's table. */ +export function useLocalMetricBreakdown(entry: MetricEntry | null | undefined, range: string | undefined) { + const metricName = entry?.metricName + return useQuery({ + queryKey: ["local", "metrics", "breakdown", metricName, entry?.metricType, range], + enabled: entry != null, + placeholderData: keepPreviousData, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(range) + const rows = await executeLocalCompiledQuery( + CH.compile( + CH.metricsBreakdownQuery({ + metricType: entry!.metricType as CH.MetricsBreakdownOpts["metricType"], + limit: 20, + }), + { orgId: LOCAL_ORG_ID, startTime, endTime, metricName: metricName! }, + ), + ) + return rows.map((r) => ({ + name: r.name, + avgValue: Number(r.avgValue), + sumValue: Number(r.sumValue), + count: Number(r.count), + })) + }, + }) +} diff --git a/apps/local-ui/src/hooks/use-local-metrics.ts b/apps/local-ui/src/hooks/use-local-metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..08d4fea7aeece0e0739f4548a75959795bbb4039 GIT binary patch literal 6530 zcmcgw+j85;5zQ<46=PMZ0(wcv`>=jsEID3T-gU*Y9m~lJOJxti5s4BAaAyF?Tv@4n zM7}UzlGEKYzyQ9~#z{pFB01=u>FLYq)5y2G%GT;uT^X}`ZOwIFHSVdd^;9*^{G~B= zukO^Pt+wjmPqi*xt+T5`Yjjr6exaua<2-C}@^dG;)w{wRvc$}k>%26Pp!s03rZz9C zOcy8Bc9$1sH8|?9$d{rX>AiS$^5n(4S8slL_xx#lRGPAER*`LG?IQVQ)s!putg>%( zxi(Yv_W9ZAyEji>{&adiRIl?bEF7B;?2f#wjlI;FQD>&MdFGzw1y-E89ghz0-&dy} zaDU~Dz0M(1>a9^*U1ytA-9H?U{QBd$a!@YUvCfwFQ}uplRN|Vg`l!~rSsQ$Aa#vSt zt7(nwC{0=Cx_H;FPh@-29qfJHEafOMLsg}!H?T<#WBZc2s*0tyuv*(}Va7CC8a-s>w@ENR{MeRy3=~p*|?c9a5Y71ZF>x;S`#4 zpSP6YwaVxQP9-ZGjNR()B=Mh9J40E+xjvavO3eJPnOM=w`e(E$&V6F7-aoqK;s~Dm zqZ}68rS6W&6J*lgA3jjaCcA>F^65fp;7nmMN(|1isk$tx%Hn5TsfQ1ox~#0qHcx;2 zgHxNTvBiFtyN$Z8iU#3|)8?|l#SmD3#<>Anbij}xy@QFUJz$2p1YoTut)WOT#uu?e z9mPnJLUxrTcA}1sl})Ksj*gC~WG3qI<5+SL6;E0K73+P*+0bgMrk z4zT_}MpZ~Gf(_a$aa)w@G`Q7P1t5CX>?ZB*0EVUj23CwT4h5nZ?NR1f+vlcO zxv-m07u8F+fBoa1YMWbI0ahv2H)cs@A(l(3dZU(RqtTA6y2)#Vw^o`ga~wOrm7GZV z33melt1FX2=rB}Q8+Y_F?l#^>RAh;Gqb-qu@e`^%ACS-j6D*DRus>6(EDj24T?bQj z;A@1wlE3lkx4oD`WmljYHdV1QmTG$bX_pQAJ_BTEw=Xl5AnjhG-{#Idip(YbV@feH z#b@I!8jQ9z;q_FRaz$Uz*p7fRapMa&O*xGwoqpzfFEmbmPW`Who{#XG?&A&M=FCpO zBe}A`s=sVXJ&7)iKWQoR;D=Ox`-eroCFK3#!2?;io5<&_s_gptYOdlIKmF|WSRuLu zB?~T~Z**RxZ+?MUB;l@;ad-daUhW6=(%hipc8{cuqWZX)7&ecS=(OcNpy#JL=ce}h z$6kj33MYe^P$yH>B{wwAFnCHHFkqKGxGo40F|PnY18Sc}cLQ>Fl_aQTcztoO7Y_!B+L&ev3;`}TA4I2l~ z&U+pUPkZbo3Rbj^zK3@Az1O&tz)9o_&wJ-S7TjMPpflKU?WKjZ6 zrXdg)Y(yf&Y#Ioe_e$#WU(rGQ(WwJhPZErROIBKH=Nyvsj%a;g#RT9?MCEj9NMNYn$^6E2=Cd?&KsUVYp%0_K=h;{>VlU+WObBNendAQtK>;`M`Rb4M z^d#gJHME$22>Auo@EHF&s=?*ju_^xP85JX&;9A?t|1-wX6#*d2Nma_c;5k*C&emV9 z>nFX$4j}BMINd#o8s>T{+J7L@QsiH$yK_|XRe?E(=Uj4~3}|6e8jtk#`fr4YVNAlC zk7heq=^x!nO8pU^|B>FCU=qPtd7XoVp$KT)4TX1|yL?#~wYK?6(a3<;B7V>y+Apr* za^$h)YXikxike>Igu~i;cZdAS2LL|}LrZNNgL$vU`yFFsmcb-at^hzMZ%@_DCeJo% z+ko0(yjQj<$+Q`anq}IqCckM8X?^Sx4~}#7xf(mA*Br-F89ze+dZQ}EN3wIdr_Ax4 zFQ&ADlZy-HrV%rxllR0;y$M42WHZ8c%5~Y4`!$P4IZADn$R>+Uc;0s@lfqb%YLX;sY=Hxj|slBNcKpQoO zHe9{&6*Aq)bB`cgGTPA?eCE4atNm(MyM-V0N$#L!+hHfR+Y?}L84t%e=r@nGpjfeo z72z`fg}wZzd{80zdSJFf9v=>{pQMINu~FSqFXEAu-{iP3=k+CqGpj$A-VNYTIlZOv z;@42>eH$U|N3XVz5Pz3zhud}?CG;+cgvH>BsLMoBq4l&gTKMBV`JV?IQN**h^Zpx8(Pd5m literal 0 HcmV?d00001 diff --git a/apps/local-ui/src/hooks/use-local-service-catalog.ts b/apps/local-ui/src/hooks/use-local-service-catalog.ts new file mode 100644 index 000000000..a4a87fa7e --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-service-catalog.ts @@ -0,0 +1,110 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { CH } from "@maple/query-engine" +import { executeLocalCompiledQuery } from "@/lib/query" +import { LOCAL_ORG_ID } from "../lib/constants" +import { boundsForRange } from "../lib/time" + +export interface ServiceCatalogFilters { + /** Exact `deployment.environment` resource attribute. */ + env?: string + /** Exact `service.namespace` resource attribute. */ + ns?: string + /** Substring match on the service name (toolbar search, client-side). */ + search?: string + /** Time-range preset key (see `TIME_RANGES`). */ + range?: string +} + +export interface ServiceCatalogEntry { + serviceName: string + serviceNamespaces: readonly string[] + deploymentEnvironments: readonly string[] + spanCount: number + errorCount: number + errorRate: number + p50LatencyMs: number + p95LatencyMs: number + p99LatencyMs: number + /** Log/trace volume from the usage rollup (0 when the service has none). */ + logCount: number +} + +export interface ServiceCatalogData { + entries: ServiceCatalogEntry[] + envFacets: Array<{ name: string; count: number }> + nsFacets: Array<{ name: string; count: number }> + totalErrorCount: number +} + +/** + * Service catalog for the services list — one query over the + * `service_overview_spans` entry-point rollup plus the usage rollup for log + * volume. Facets derive from the (small) catalog result client-side. + */ +export function useLocalServiceCatalog(filters: ServiceCatalogFilters) { + return useQuery({ + queryKey: ["local", "services", "catalog", filters], + placeholderData: keepPreviousData, + queryFn: async (): Promise => { + const { startTime, endTime } = boundsForRange(filters.range) + const params = { orgId: LOCAL_ORG_ID, startTime, endTime } + const [catalogRows, usageRows] = await Promise.all([ + executeLocalCompiledQuery( + CH.compile( + CH.serviceCatalogQuery({ + deploymentEnvironment: filters.env, + serviceNamespace: filters.ns, + limit: 200, + }), + params, + ), + ), + executeLocalCompiledQuery(CH.compile(CH.serviceUsageQuery({}), params)), + ]) + + const logsByService = new Map( + usageRows.map((row) => [row.serviceName, Number(row.totalLogCount)]), + ) + + const all = catalogRows.map((row): ServiceCatalogEntry => { + const spanCount = Number(row.estimatedSpanCount) || Number(row.spanCount) + const errorCount = Number(row.estimatedErrorCount) || Number(row.errorCount) + return { + serviceName: row.serviceName, + serviceNamespaces: row.serviceNamespaces, + deploymentEnvironments: row.deploymentEnvironments, + spanCount, + errorCount, + errorRate: spanCount > 0 ? errorCount / spanCount : 0, + p50LatencyMs: Number(row.p50LatencyMs), + p95LatencyMs: Number(row.p95LatencyMs), + p99LatencyMs: Number(row.p99LatencyMs), + logCount: logsByService.get(row.serviceName) ?? 0, + } + }) + + const entries = filters.search + ? all.filter((e) => e.serviceName.toLowerCase().includes(filters.search!.toLowerCase())) + : all + + const countBy = (pick: (e: ServiceCatalogEntry) => readonly string[]) => { + const counts = new Map() + for (const entry of all) { + for (const name of pick(entry)) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + } + + return { + entries, + envFacets: countBy((e) => e.deploymentEnvironments), + nsFacets: countBy((e) => e.serviceNamespaces), + totalErrorCount: entries.reduce((sum, e) => sum + e.errorCount, 0), + } + }, + }) +} diff --git a/apps/local-ui/src/hooks/use-local-service-detail.ts b/apps/local-ui/src/hooks/use-local-service-detail.ts new file mode 100644 index 000000000..ea2ab8e4f --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-service-detail.ts @@ -0,0 +1,116 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { CH } from "@maple/query-engine" +import { executeLocalCompiledQuery } from "@/lib/query" +import { LOCAL_ORG_ID } from "../lib/constants" +import { boundsForRange } from "../lib/time" +import { bucketSecondsForRange } from "./use-local-metrics" + +export interface ServiceOverviewStats { + spanCount: number + errorCount: number + errorRate: number + p50LatencyMs: number + p95LatencyMs: number + p99LatencyMs: number + environments: string[] + namespaces: string[] +} + +/** Golden-signal header stats for one service (entry-point spans only). */ +export function useLocalServiceOverview(serviceName: string, range: string | undefined) { + return useQuery({ + queryKey: ["local", "services", "overview", serviceName, range], + placeholderData: keepPreviousData, + queryFn: async (): Promise => { + const { startTime, endTime } = boundsForRange(range) + const rows = await executeLocalCompiledQuery( + CH.compile(CH.serviceOverviewQuery({ serviceName }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }), + ) + if (rows.length === 0) return null + let spanCount = 0 + let errorCount = 0 + // The overview groups by (namespace, env, commit); merge the slices and + // approximate latency percentiles with a span-weighted average. + let p50 = 0 + let p95 = 0 + let p99 = 0 + const environments = new Set() + const namespaces = new Set() + for (const row of rows) { + const slice = Number(row.estimatedSpanCount) || Number(row.spanCount) + spanCount += slice + errorCount += Number(row.estimatedErrorCount) || Number(row.errorCount) + p50 += Number(row.p50LatencyMs) * slice + p95 += Number(row.p95LatencyMs) * slice + p99 += Number(row.p99LatencyMs) * slice + if (row.environment) environments.add(row.environment) + if (row.serviceNamespace) namespaces.add(row.serviceNamespace) + } + return { + spanCount, + errorCount, + errorRate: spanCount > 0 ? errorCount / spanCount : 0, + p50LatencyMs: spanCount > 0 ? p50 / spanCount : 0, + p95LatencyMs: spanCount > 0 ? p95 / spanCount : 0, + p99LatencyMs: spanCount > 0 ? p99 / spanCount : 0, + environments: [...environments].sort(), + namespaces: [...namespaces].sort(), + } + }, + }) +} + +export type ServiceOperationRow = CH.ServiceOperationsSummaryOutput + +/** Top operations table for the service detail page. */ +export function useLocalServiceOperations(serviceName: string, range: string | undefined) { + return useQuery({ + queryKey: ["local", "services", "operations", serviceName, range], + placeholderData: keepPreviousData, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(range) + return executeLocalCompiledQuery( + CH.compile(CH.serviceOperationsSummaryQuery({ serviceName, limit: 25 }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }), + ) + }, + }) +} + +export interface OperationSeriesPoint { + bucket: string + spanName: string + count: number +} + +/** Per-bucket throughput for the top operations (drives the detail chart). */ +export function useLocalServiceOperationsTimeseries( + serviceName: string, + spanNames: ReadonlyArray, + range: string | undefined, +) { + return useQuery({ + queryKey: ["local", "services", "operations-ts", serviceName, spanNames, range], + enabled: spanNames.length > 0, + placeholderData: keepPreviousData, + queryFn: async (): Promise> => { + const { startTime, endTime } = boundsForRange(range) + const rows = await executeLocalCompiledQuery( + CH.compile(CH.serviceOperationsTimeseriesQuery({ serviceName, spanNames }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + bucketSeconds: bucketSecondsForRange(range), + }), + ) + return rows.map((r) => ({ bucket: r.bucket, spanName: r.spanName, count: Number(r.count) })) + }, + }) +} diff --git a/apps/local-ui/src/hooks/use-local-services.ts b/apps/local-ui/src/hooks/use-local-services.ts index e033f9fca..d9d130762 100644 --- a/apps/local-ui/src/hooks/use-local-services.ts +++ b/apps/local-ui/src/hooks/use-local-services.ts @@ -3,7 +3,7 @@ import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" import { boundsForRange } from "../lib/time" -import type { FilterOption } from "../components/filter-section" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" /** * Distinct service names in the window, shaped as filter options. Drives the diff --git a/apps/local-ui/src/hooks/use-local-sessions.ts b/apps/local-ui/src/hooks/use-local-sessions.ts index 192a85ced..554f03758 100644 --- a/apps/local-ui/src/hooks/use-local-sessions.ts +++ b/apps/local-ui/src/hooks/use-local-sessions.ts @@ -3,7 +3,7 @@ import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" import { boundsForRange } from "../lib/time" -import type { FilterOption } from "../components/filter-section" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" const PAGE_SIZE = 50 diff --git a/apps/local-ui/src/hooks/use-local-trace-facets.ts b/apps/local-ui/src/hooks/use-local-trace-facets.ts index 141512f19..362488006 100644 --- a/apps/local-ui/src/hooks/use-local-trace-facets.ts +++ b/apps/local-ui/src/hooks/use-local-trace-facets.ts @@ -3,15 +3,11 @@ import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" import { boundsForRange } from "../lib/time" -import type { FilterOption } from "../components/filter-section" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" +import type { DurationStats } from "@maple/ui/components/filters/duration-range-filter" import type { TraceFilters } from "./use-local-traces" -export interface DurationStats { - minDurationMs: number - maxDurationMs: number - p50DurationMs: number - p95DurationMs: number -} +export type { DurationStats } export interface TraceFacets { services: FilterOption[] diff --git a/apps/local-ui/src/lib/replay-format.ts b/apps/local-ui/src/lib/replay-format.ts deleted file mode 100644 index 47423a994..000000000 --- a/apps/local-ui/src/lib/replay-format.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Presentation helpers for the session-replay surfaces, mirroring the web app's -// `replay-format` so the local list/detail read identically. - -/** `1m 23s` / `45s`, or `—` for missing/zero durations. */ -export function formatSessionDuration(ms: number | null): string { - if (ms == null || ms <= 0) return "—" - const totalSeconds = Math.round(ms / 1000) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` -} - -/** Host + path for compact URL display; returns the raw input if unparseable. */ -export function hostFromUrl(url: string): string { - try { - const u = new URL(url) - return `${u.host}${u.pathname === "/" ? "" : u.pathname}` - } catch { - return url - } -} - -const AVATAR_GRADIENTS = [ - "from-rose-500/80 to-orange-400/80", - "from-violet-500/80 to-fuchsia-400/80", - "from-sky-500/80 to-cyan-400/80", - "from-emerald-500/80 to-teal-400/80", - "from-amber-500/80 to-yellow-400/80", - "from-indigo-500/80 to-blue-400/80", -] - -/** Deterministic avatar gradient for a session, keyed by a stable seed. */ -export function gradientFor(seed: string): string { - let hash = 0 - for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0 - return AVATAR_GRADIENTS[hash % AVATAR_GRADIENTS.length]! -} - -export function isMobileDevice(deviceType: string): boolean { - const d = deviceType.toLowerCase() - return d === "mobile" || d === "tablet" || d === "phone" -} diff --git a/apps/local-ui/src/views/errors-view.tsx b/apps/local-ui/src/views/errors-view.tsx new file mode 100644 index 000000000..f1346e52d --- /dev/null +++ b/apps/local-ui/src/views/errors-view.tsx @@ -0,0 +1,224 @@ +import { useState } from "react" +import { CircleWarningIcon, ChevronDownIcon } from "@maple/ui/components/icons" +import { Spinner } from "@maple/ui/components/ui/spinner" +import { formatDuration, formatNumber } from "@maple/ui/format" +import { cn } from "@maple/ui/utils" +import { + SearchableFilterSection, + SingleCheckboxFilter, + serviceColorMap, +} from "@maple/ui/components/filters/filter-section" +import { + FilterSidebarBody, + FilterSidebarFrame, + FilterSidebarHeader, +} from "@maple/ui/components/filters/filter-sidebar" +import type { CH } from "@maple/query-engine" +import { + useLocalErrorTraces, + useLocalErrorsByType, + useLocalErrorsFacets, + useLocalErrorsSummary, + type ErrorsFilters, +} from "../hooks/use-local-errors" +import { useQueryParams } from "../lib/router" +import { DEFAULT_RANGE, formatRelativeTime } from "../lib/time" +import { PageShell } from "../components/page-shell" +import { RefreshButton, TimeRangeSelect, Toolbar, ToolbarStat } from "../components/toolbar" +import { EmptyState, ErrorState, ListSkeleton } from "../components/view-states" + +interface ErrorsViewProps { + onSelectTrace: (traceId: string) => void +} + +export function ErrorsView({ onSelectTrace }: ErrorsViewProps) { + const [query, setParams] = useQueryParams() + const range = query.get("range") || DEFAULT_RANGE + const service = query.get("service") || undefined + const env = query.get("env") || undefined + const rootOnly = query.get("root") === "1" + + const filters: ErrorsFilters = { service, env, rootOnly, range } + const summary = useLocalErrorsSummary(filters) + const byType = useLocalErrorsByType(filters) + const facets = useLocalErrorsFacets(filters) + const hasActiveFilters = !!service || !!env || rootOnly + + const sidebar = ( + + setParams({ service: null, env: null, root: null })} + /> + + setParams({ root: checked ? "1" : null })} + /> + setParams({ service: vals.at(-1) ?? null })} + colorMap={serviceColorMap(facets.data?.services ?? [])} + /> + setParams({ env: vals.at(-1) ?? null })} + /> + + + ) + + const stats = summary.data + const toolbar = ( + } + stats={ + <> + + + + + {((stats?.errorRate ?? 0) * 100).toFixed(2)}% + {" "} + error rate + + + setParams({ range: next })} /> + + } + /> + ) + + return ( + + {byType.isPending ? ( + + ) : byType.isError ? ( + byType.refetch()} /> + ) : (byType.data ?? []).length === 0 ? ( + } + title={hasActiveFilters ? "No matching errors" : "No errors recorded"} + hint={ + hasActiveFilters + ? "Try widening the time range or clearing filters." + : "Errors appear when spans arrive with an Error status." + } + /> + ) : ( +
+ {(byType.data ?? []).map((row) => ( + + ))} +
+ )} +
+ ) +} + +function ErrorTypeCard({ + row, + filters, + onSelectTrace, +}: { + row: CH.ErrorsByTypeOutput + filters: ErrorsFilters + onSelectTrace: (traceId: string) => void +}) { + const [expanded, setExpanded] = useState(false) + const traces = useLocalErrorTraces(expanded ? row.fingerprintHash : undefined, filters) + + return ( +
+ + + {expanded ? ( +
+ {traces.isPending ? ( +
+ +
+ ) : traces.isError ? ( +

+ Couldn’t load traces: {String(traces.error)} +

+ ) : (traces.data ?? []).length === 0 ? ( +

+ No traces found for this error in the selected range. +

+ ) : ( +
    + {(traces.data ?? []).map((trace) => ( +
  • + +
  • + ))} +
+ )} +
+ ) : null} +
+ ) +} diff --git a/apps/local-ui/src/views/logs-view.tsx b/apps/local-ui/src/views/logs-view.tsx index f783f8401..26ddd97f7 100644 --- a/apps/local-ui/src/views/logs-view.tsx +++ b/apps/local-ui/src/views/logs-view.tsx @@ -12,8 +12,8 @@ import { useQueryParams } from "../lib/router" import { DEFAULT_RANGE } from "../lib/time" import { normalizeLog, type LocalLog } from "../lib/log-shape" import { LogDetailSheet } from "../components/log-detail-sheet" -import { FilterSection, SearchableFilterSection } from "../components/filter-section" -import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "../components/filter-sidebar" +import { FilterSection, SearchableFilterSection } from "@maple/ui/components/filters/filter-section" +import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "@maple/ui/components/filters/filter-sidebar" import { PageShell } from "../components/page-shell" import { Toolbar, ToolbarSearch, ToolbarStat, TimeRangeSelect, RefreshButton } from "../components/toolbar" import { EmptyState, ErrorState, ListSkeleton } from "../components/view-states" @@ -63,7 +63,7 @@ export function LogsView() { const hasActiveFilters = !!service || !!severity const sidebar = ( - + setParams({ service: null, severity: null })} diff --git a/apps/local-ui/src/views/metric-detail-view.tsx b/apps/local-ui/src/views/metric-detail-view.tsx new file mode 100644 index 000000000..1416a2bc8 --- /dev/null +++ b/apps/local-ui/src/views/metric-detail-view.tsx @@ -0,0 +1,189 @@ +import { useMemo } from "react" +import { ArrowLeftIcon } from "@maple/ui/components/icons" +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { QueryBuilderLineChart } from "@maple/ui/components/charts/line/query-builder-line-chart" +import { MetricTypeBadge } from "@maple/ui/components/metrics/metric-type-badge" +import { formatNumber } from "@maple/ui/format" +import { + useLocalMetricBreakdown, + useLocalMetricEntry, + useLocalMetricTimeseries, +} from "../hooks/use-local-metric-detail" +import { useQueryParams } from "../lib/router" +import { DEFAULT_RANGE, formatRelativeTime } from "../lib/time" +import { RefreshButton, TimeRangeSelect } from "../components/toolbar" +import { EmptyState, ErrorState } from "../components/view-states" + +interface MetricDetailViewProps { + metricName: string + onBack: () => void +} + +export function MetricDetailView({ metricName, onBack }: MetricDetailViewProps) { + const [query, setParams] = useQueryParams() + const range = query.get("range") || DEFAULT_RANGE + + const entryQuery = useLocalMetricEntry(metricName, range) + const entry = entryQuery.data + const timeseries = useLocalMetricTimeseries(entry, range) + const breakdown = useLocalMetricBreakdown(entry, range) + + // Pivot (bucket, groupName, value) rows into the wide shape the shared + // line chart plots: one column per series (service). + const chartData = useMemo(() => { + const rows = timeseries.data ?? [] + const byBucket = new Map>() + for (const row of rows) { + let bucketRow = byBucket.get(row.bucket) + if (!bucketRow) byBucket.set(row.bucket, (bucketRow = { bucket: row.bucket })) + bucketRow[row.groupName || "value"] = row.value + } + return [...byBucket.values()] + }, [timeseries.data]) + + const isRate = entry?.metricType === "sum" && entry.isMonotonic + + return ( +
+
+ + + {metricName} + + {entry ? : null} + {entry?.metricUnit ? ( + + {entry.metricUnit} + + ) : null} +
+ + setParams({ range: next })} /> +
+
+ +
+ {entryQuery.isPending ? ( +
+ +
+ ) : entryQuery.isError ? ( + entryQuery.refetch()} + /> + ) : !entry ? ( + + ) : ( +
+ {entry.metricDescription ? ( +

{entry.metricDescription}

+ ) : null} + +
+
+

+ {isRate ? "Rate (per second)" : "Average value"} by service +

+ + {entry.serviceNames.length.toLocaleString()} services ·{" "} + {formatNumber(entry.dataPointCount)} datapoints · last seen{" "} + {formatRelativeTime(entry.lastSeen)} + +
+ {timeseries.isPending ? ( +
+ +
+ ) : timeseries.isError ? ( + timeseries.refetch()} + /> + ) : chartData.length < 2 ? ( +
+ Not enough datapoints to chart this range. +
+ ) : ( +
+ +
+ )} +
+ +
+

Breakdown by service

+ {breakdown.isPending ? ( +
+ +
+ ) : breakdown.isError ? ( + breakdown.refetch()} + /> + ) : ( +
+ + + + Service + Avg + Sum + Datapoints + + + + {(breakdown.data ?? []).map((row) => ( + + + {row.name || "—"} + + + {formatNumber(row.avgValue)} + + + {formatNumber(row.sumValue)} + + + {row.count.toLocaleString()} + + + ))} + +
+
+ )} +
+
+ )} +
+
+ ) +} diff --git a/apps/local-ui/src/views/metrics-list-view.tsx b/apps/local-ui/src/views/metrics-list-view.tsx new file mode 100644 index 000000000..8aff8b062 --- /dev/null +++ b/apps/local-ui/src/views/metrics-list-view.tsx @@ -0,0 +1,209 @@ +import { useMemo } from "react" +import { PulseIcon } from "@maple/ui/components/icons" +import { Badge } from "@maple/ui/components/ui/badge" +import { StatSparkline } from "@maple/ui/components/charts/sparkline/stat-sparkline" +import { + METRIC_TYPE_COLORS, + MetricTypeBadge, +} from "@maple/ui/components/metrics/metric-type-badge" +import { + FilterSection, + SearchableFilterSection, + type FilterOption, +} from "@maple/ui/components/filters/filter-section" +import { + FilterSidebarBody, + FilterSidebarFrame, + FilterSidebarHeader, +} from "@maple/ui/components/filters/filter-sidebar" +import { + useLocalMetricsList, + useLocalMetricsSparklines, + useLocalMetricsSummary, + type MetricEntry, + type SparklinePoint, +} from "../hooks/use-local-metrics" +import { useQueryParams } from "../lib/router" +import { DEFAULT_RANGE } from "../lib/time" +import { PageShell } from "../components/page-shell" +import { RefreshButton, TimeRangeSelect, Toolbar, ToolbarSearch, ToolbarStat } from "../components/toolbar" +import { EmptyState, ErrorState, ListSkeleton } from "../components/view-states" + +interface MetricsListViewProps { + onSelectMetric: (metricName: string) => void +} + +export function MetricsListView({ onSelectMetric }: MetricsListViewProps) { + const [query, setParams] = useQueryParams() + const range = query.get("range") || DEFAULT_RANGE + const service = query.get("service") || undefined + const type = query.get("type") || undefined + const search = query.get("q") || undefined + + const list = useLocalMetricsList({ service, type, search, range }) + const summary = useLocalMetricsSummary({ service, range }) + const entries = list.data?.entries ?? [] + const sparklines = useLocalMetricsSparklines(entries, range) + + const totalDataPoints = (summary.data ?? []).reduce((sum, row) => sum + row.dataPointCount, 0) + const typeFacets: FilterOption[] = (summary.data ?? []) + .map((row) => ({ name: row.metricType, count: row.metricCount })) + .sort((a, b) => b.count - a.count) + + const hasActiveFilters = !!service || !!type + + const sidebar = ( + + setParams({ service: null, type: null })} + /> + + setParams({ type: vals.at(-1) ?? null })} + /> + setParams({ service: vals.at(-1) ?? null })} + /> + + + ) + + const toolbar = ( + setParams({ q: value ?? null })} + placeholder="Filter by metric name…" + /> + } + stats={ + <> + + + + setParams({ range: next })} /> + + } + /> + ) + + return ( + + {list.isPending ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : entries.length === 0 ? ( + } + title={hasActiveFilters || search ? "No matching metrics" : "No metrics received yet"} + hint={ + hasActiveFilters || search ? ( + "Try widening the time range or clearing filters." + ) : ( + <> + Point an OTLP exporter at{" "} + + /v1/metrics + {" "} + to start collecting metrics. + + ) + } + /> + ) : ( +
+ {entries.map((entry) => ( + onSelectMetric(entry.metricName)} + /> + ))} +
+ )} +
+ ) +} + +/** + * Cheap type-aware preview — mirrors the web app's browse cards: gauges and + * histograms plot the average value; counters plot datapoints per interval + * (true rate needs the window-function CTE, which must not run one-per-card; + * the detail page shows real rate). + */ +function MetricPreviewCard({ + entry, + points, + loading, + onOpen, +}: { + entry: MetricEntry + points: ReadonlyArray | undefined + loading: boolean + onOpen: () => void +}) { + const rows = useMemo( + () => + (points ?? []).map((point) => ({ + bucket: point.bucket, + v: entry.metricType === "sum" ? point.dataPointCount : point.avgValue, + })), + [entry.metricType, points], + ) + + return ( + + ) +} diff --git a/apps/local-ui/src/views/service-detail-view.tsx b/apps/local-ui/src/views/service-detail-view.tsx new file mode 100644 index 000000000..69393995c --- /dev/null +++ b/apps/local-ui/src/views/service-detail-view.tsx @@ -0,0 +1,237 @@ +import { useMemo } from "react" +import { ArrowLeftIcon } from "@maple/ui/components/icons" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +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 { QueryBuilderLineChart } from "@maple/ui/components/charts/line/query-builder-line-chart" +import { formatDuration, formatNumber } from "@maple/ui/format" +import { cn } from "@maple/ui/utils" +import { + useLocalServiceOperations, + useLocalServiceOperationsTimeseries, + useLocalServiceOverview, +} from "../hooks/use-local-service-detail" +import { navigate, useQueryParams } from "../lib/router" +import { DEFAULT_RANGE } from "../lib/time" +import { RefreshButton, TimeRangeSelect } from "../components/toolbar" +import { EmptyState, ErrorState } from "../components/view-states" + +const CHART_SERIES_LIMIT = 8 + +interface ServiceDetailViewProps { + serviceName: string + onBack: () => void +} + +export function ServiceDetailView({ serviceName, onBack }: ServiceDetailViewProps) { + const [query, setParams] = useQueryParams() + const range = query.get("range") || DEFAULT_RANGE + + const overview = useLocalServiceOverview(serviceName, range) + const operations = useLocalServiceOperations(serviceName, range) + const topSpanNames = useMemo( + () => (operations.data ?? []).slice(0, CHART_SERIES_LIMIT).map((op) => op.spanName), + [operations.data], + ) + const timeseries = useLocalServiceOperationsTimeseries(serviceName, topSpanNames, range) + + const chartData = useMemo(() => { + const byBucket = new Map>() + for (const row of timeseries.data ?? []) { + let bucketRow = byBucket.get(row.bucket) + if (!bucketRow) byBucket.set(row.bucket, (bucketRow = { bucket: row.bucket })) + bucketRow[row.spanName] = row.count + } + return [...byBucket.values()] + }, [timeseries.data]) + + const openTraces = (spanName?: string) => { + const params = new URLSearchParams() + params.set("service", serviceName) + if (range !== DEFAULT_RANGE) params.set("range", range) + if (spanName) params.set("span", spanName) + navigate("/traces", params) + } + + const stats = overview.data + + return ( +
+
+ + + + {serviceName} + + {stats?.environments.map((environment) => ( + + {environment} + + ))} +
+ + + setParams({ range: next })} /> +
+
+ +
+ {overview.isPending ? ( +
+ +
+ ) : overview.isError ? ( + overview.refetch()} + /> + ) : !stats ? ( + + ) : ( +
+
+ + 0} + /> + 0.05} + /> + + + +
+ +
+

Throughput by operation

+ {timeseries.isPending && topSpanNames.length > 0 ? ( +
+ +
+ ) : chartData.length < 2 ? ( +
+ Not enough datapoints to chart this range. +
+ ) : ( +
+ +
+ )} +
+ +
+

Top operations

+ {operations.isPending ? ( +
+ +
+ ) : operations.isError ? ( + operations.refetch()} + /> + ) : (operations.data ?? []).length === 0 ? ( +
+ No operations recorded in this range. +
+ ) : ( +
+ + + + Operation + Spans + Errors + Error rate + Avg + p50 + p95 + + + + {(operations.data ?? []).map((op) => ( + openTraces(op.spanName)} + className="cursor-pointer" + > + + {op.spanName} + + + {formatNumber(op.estimatedSpanCount || op.spanCount)} + + 0 && "text-destructive", + )} + > + {formatNumber(op.estimatedErrorCount || op.errorCount)} + + + {(op.errorRate * 100).toFixed(1)}% + + + {formatDuration(op.avgDurationMs)} + + + {formatDuration(op.p50DurationMs)} + + + {formatDuration(op.p95DurationMs)} + + + ))} + +
+
+ )} +
+
+ )} +
+
+ ) +} + +function StatCard({ label, value, danger }: { label: string; value: string; danger?: boolean }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/apps/local-ui/src/views/services-list-view.tsx b/apps/local-ui/src/views/services-list-view.tsx new file mode 100644 index 000000000..8dd38c143 --- /dev/null +++ b/apps/local-ui/src/views/services-list-view.tsx @@ -0,0 +1,166 @@ +import { DatabaseIcon } from "@maple/ui/components/icons" +import { ServiceDot } from "@maple/ui/components/service-dot" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@maple/ui/components/ui/table" +import { formatDuration, formatNumber } from "@maple/ui/format" +import { cn } from "@maple/ui/utils" +import { SearchableFilterSection } from "@maple/ui/components/filters/filter-section" +import { + FilterSidebarBody, + FilterSidebarFrame, + FilterSidebarHeader, +} from "@maple/ui/components/filters/filter-sidebar" +import { useLocalServiceCatalog, type ServiceCatalogEntry } from "../hooks/use-local-service-catalog" +import { useQueryParams } from "../lib/router" +import { DEFAULT_RANGE } from "../lib/time" +import { PageShell } from "../components/page-shell" +import { RefreshButton, TimeRangeSelect, Toolbar, ToolbarSearch, ToolbarStat } from "../components/toolbar" +import { EmptyState, ErrorState, ListSkeleton } from "../components/view-states" + +interface ServicesListViewProps { + onSelectService: (serviceName: string) => void +} + +export function ServicesListView({ onSelectService }: ServicesListViewProps) { + const [query, setParams] = useQueryParams() + const range = query.get("range") || DEFAULT_RANGE + const env = query.get("env") || undefined + const ns = query.get("ns") || undefined + const search = query.get("q") || undefined + + const catalog = useLocalServiceCatalog({ env, ns, search, range }) + const entries = catalog.data?.entries ?? [] + const hasActiveFilters = !!env || !!ns + + const sidebar = ( + + setParams({ env: null, ns: null })} + /> + + setParams({ env: vals.at(-1) ?? null })} + /> + setParams({ ns: vals.at(-1) ?? null })} + /> + + + ) + + const toolbar = ( + setParams({ q: value ?? null })} + placeholder="Filter by service name…" + /> + } + stats={ + <> + + + + setParams({ range: next })} /> + + } + /> + ) + + return ( + + {catalog.isPending ? ( + + ) : catalog.isError ? ( + catalog.refetch()} /> + ) : entries.length === 0 ? ( + } + title={hasActiveFilters || search ? "No matching services" : "No services seen yet"} + hint={ + hasActiveFilters || search + ? "Try widening the time range or clearing filters." + : "Services appear as soon as their traces arrive." + } + /> + ) : ( +
+
+ + + + Service + Spans + Errors + Error rate + p50 + p95 + Logs + + + + {entries.map((entry) => ( + onSelectService(entry.serviceName)} + /> + ))} + +
+
+
+ )} +
+ ) +} + +function ServiceRow({ entry, onSelect }: { entry: ServiceCatalogEntry; onSelect: () => void }) { + return ( + + + + + {entry.serviceName} + {entry.serviceNamespaces.length > 0 ? ( + + {entry.serviceNamespaces.join(", ")} + + ) : null} + + + {formatNumber(entry.spanCount)} + 0 && "text-destructive")} + > + {formatNumber(entry.errorCount)} + + 0.05 && "text-destructive")} + > + {(entry.errorRate * 100).toFixed(1)}% + + {formatDuration(entry.p50LatencyMs)} + {formatDuration(entry.p95LatencyMs)} + {formatNumber(entry.logCount)} + + ) +} diff --git a/apps/local-ui/src/views/session-detail-view.tsx b/apps/local-ui/src/views/session-detail-view.tsx index cdc6a9fb7..c3101fa14 100644 --- a/apps/local-ui/src/views/session-detail-view.tsx +++ b/apps/local-ui/src/views/session-detail-view.tsx @@ -22,7 +22,7 @@ import { useLocalSessionTranscript, } from "../hooks/use-local-session-detail" import { formatRelativeTime } from "../lib/time" -import { formatSessionDuration, gradientFor, hostFromUrl, isMobileDevice } from "../lib/replay-format" +import { formatDuration as formatSessionDuration, gradientFor, hostFromUrl, isMobileDevice } from "@maple/ui/lib/replay-format" import { ErrorState } from "../components/view-states" import { RefreshButton } from "../components/toolbar" diff --git a/apps/local-ui/src/views/sessions-list-view.tsx b/apps/local-ui/src/views/sessions-list-view.tsx index 5de40dc5e..332e6c29d 100644 --- a/apps/local-ui/src/views/sessions-list-view.tsx +++ b/apps/local-ui/src/views/sessions-list-view.tsx @@ -15,14 +15,14 @@ import type { SessionReplaysListOutput } from "@maple/query-engine/ch" import { useLocalSessions, useLocalSessionFacets } from "../hooks/use-local-sessions" import { useQueryParams } from "../lib/router" import { DEFAULT_RANGE, formatRelativeTime } from "../lib/time" -import { gradientFor, hostFromUrl, isMobileDevice, formatSessionDuration } from "../lib/replay-format" +import { formatDuration as formatSessionDuration, gradientFor, hostFromUrl, isMobileDevice } from "@maple/ui/lib/replay-format" import { FilterSection, SearchableFilterSection, SingleCheckboxFilter, type FilterOption, -} from "../components/filter-section" -import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "../components/filter-sidebar" +} from "@maple/ui/components/filters/filter-section" +import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "@maple/ui/components/filters/filter-sidebar" import { PageShell } from "../components/page-shell" import { Toolbar, ToolbarSearch, ToolbarStat, TimeRangeSelect, RefreshButton } from "../components/toolbar" import { EmptyState, ErrorState, ListSkeleton } from "../components/view-states" @@ -58,7 +58,7 @@ export function SessionsListView({ onSelectSession }: SessionsListViewProps) { const facetData = facets.data const sidebar = ( - + setParams({ service: null, browser: null, device: null, errors: null })} diff --git a/apps/local-ui/src/views/trace-list-view.tsx b/apps/local-ui/src/views/trace-list-view.tsx index 221dd100d..45b541903 100644 --- a/apps/local-ui/src/views/trace-list-view.tsx +++ b/apps/local-ui/src/views/trace-list-view.tsx @@ -11,9 +11,9 @@ import { useLocalTraces, type TraceFilters } from "../hooks/use-local-traces" import { useLocalTraceFacets } from "../hooks/use-local-trace-facets" import { useQueryParams } from "../lib/router" import { DEFAULT_RANGE } from "../lib/time" -import { DurationRangeFilter } from "../components/duration-range-filter" -import { FilterSection, SearchableFilterSection, SingleCheckboxFilter } from "../components/filter-section" -import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "../components/filter-sidebar" +import { DurationRangeFilter } from "@maple/ui/components/filters/duration-range-filter" +import { FilterSection, SearchableFilterSection, SingleCheckboxFilter } from "@maple/ui/components/filters/filter-section" +import { FilterSidebarBody, FilterSidebarFrame, FilterSidebarHeader } from "@maple/ui/components/filters/filter-sidebar" import { PageShell } from "../components/page-shell" import { parseAttributes } from "@maple/ui/lib/span-tree" import { Toolbar, ToolbarSearch, ToolbarStat, TimeRangeSelect, RefreshButton } from "../components/toolbar" @@ -69,7 +69,7 @@ export function TraceListView({ onSelectTrace }: TraceListViewProps) { const facetSelect = (key: string) => (vals: string[]) => setParams({ [key]: vals.at(-1) ?? null }) const sidebar = ( - + @@ -121,9 +121,14 @@ export function TraceListView({ onSelectTrace }: TraceListViewProps) { setParams({ minDur: v != null ? String(v) : null })} - onMaxChange={(v) => setParams({ maxDur: v != null ? String(v) : null })} + onRangeChange={(min, max) => + setParams({ + minDur: min != null ? String(Math.round(min)) : null, + maxDur: max != null ? String(Math.round(max)) : null, + }) + } durationStats={facets.data?.durationStats} + debounceMs={300} /> - selected: string[] - onChange: (selected: string[]) => void - defaultOpen?: boolean - maxVisible?: number - colorMap?: Record - /** Option-name → icon, rendered before the label (like colorMap's swatch). */ - getOptionIcon?: (name: string) => IconComponent | undefined -} - -interface FilterSectionProps extends FilterSectionBaseProps {} - -interface SearchableFilterSectionProps extends FilterSectionBaseProps {} - -function FilterSectionBase({ - title, - options, - selected, - onChange, - defaultOpen = true, - maxVisible = 5, - searchable, - colorMap, - getOptionIcon, -}: FilterSectionBaseProps & { searchable: boolean }) { - const [isOpen, setIsOpen] = React.useState(defaultOpen) - const [showAll, setShowAll] = React.useState(false) - const [searchText, setSearchText] = React.useState("") - const inputRef = React.useRef(null) - - const filteredOptions = - searchable && searchText - ? options.filter((o) => o.name.toLowerCase().includes(searchText.toLowerCase())) - : options - - const visibleOptions = showAll || searchText ? filteredOptions : filteredOptions.slice(0, maxVisible) - const hasMore = !searchText && filteredOptions.length > maxVisible - - const toggleOption = (name: string) => { - if (selected.includes(name)) { - onChange(selected.filter((s) => s !== name)) - } else { - onChange([...selected, name]) - } - } - - const handleOpenChange = (open: boolean) => { - setIsOpen(open) - if (!open) { - setSearchText("") - setShowAll(false) - } - } - - if (options.length === 0) { - return null - } - - return ( - - - {title} - - - - {searchable && ( - - - - - { - setSearchText(e.target.value) - setShowAll(false) - }} - placeholder={`Search ${title.toLowerCase()}...`} - /> - {searchText && ( - - { - setSearchText("") - inputRef.current?.focus() - }} - > - - - - )} - - )} -
- {visibleOptions.length === 0 ? ( -

No matches found

- ) : ( - visibleOptions.map((option) => { - const OptionIcon = getOptionIcon?.(option.name) - return ( -
- toggleOption(option.name)} - /> - - - {option.count.toLocaleString()} - -
- ) - }) - )} - {hasMore && ( - - )} -
-
-
- ) -} - -/** Option-name → deterministic service color, for Service facets' swatches. */ -export function serviceColorMap(options: ReadonlyArray): Record { - return Object.fromEntries(options.map((o) => [o.name, getServiceColor(o.name)])) -} - -export function FilterSection(props: FilterSectionProps) { - return -} - -export function SearchableFilterSection(props: SearchableFilterSectionProps) { - return -} - -interface SingleCheckboxFilterProps { - title: string - checked: boolean - onChange: (checked: boolean) => void - count?: number -} - -export function SingleCheckboxFilter({ title, checked, onChange, count }: SingleCheckboxFilterProps) { - return ( -
- onChange(val === true)} - /> - - {count !== undefined && ( - {count.toLocaleString()} - )} -
- ) -} +// Promoted to @maple/ui so the local-mode UI shares the exact same filter chrome. +export { + FilterSection, + SearchableFilterSection, + SingleCheckboxFilter, + serviceColorMap, + type FilterOption, +} from "@maple/ui/components/filters/filter-section" diff --git a/apps/web/src/components/filters/filter-sidebar.tsx b/apps/web/src/components/filters/filter-sidebar.tsx index f302a8734..82a647c60 100644 --- a/apps/web/src/components/filters/filter-sidebar.tsx +++ b/apps/web/src/components/filters/filter-sidebar.tsx @@ -1,95 +1,18 @@ -import type { ReactNode } from "react" - +// Frame/header/body/loading are promoted to @maple/ui (shared with the local-mode UI). +// FilterSidebarError stays here: it binds the app's ErrorState. import { Separator } from "@maple/ui/components/ui/separator" +import { + FilterSidebarFrame, + FilterSidebarHeader, +} from "@maple/ui/components/filters/filter-sidebar" import { ErrorState } from "@/components/common/error-state" -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import { ScrollArea } from "@maple/ui/components/ui/scroll-area" -import { cn } from "@maple/ui/utils" - -interface FilterSidebarFrameProps { - children: ReactNode - waiting?: boolean - className?: string -} - -export function FilterSidebarFrame({ children, waiting = false, className }: FilterSidebarFrameProps) { - // Width is owned by the container (PageLayout.FilterSidebar: an inline aside on desktop, a sheet - // below lg). Setting one here would fight it — and leave dead space inside the wider sheet. - return ( -
- {children} -
- ) -} - -interface FilterSidebarHeaderProps { - title?: string - canClear?: boolean - onClear?: () => void -} - -export function FilterSidebarHeader({ - title = "Filters", - canClear = false, - onClear, -}: FilterSidebarHeaderProps) { - return ( -
-

{title}

- {canClear && onClear && ( - - )} -
- ) -} -export function FilterSidebarBody({ children }: { children: ReactNode }) { - return ( - <> - -
- -
{children}
-
-
-
- - ) -} - -interface FilterSidebarLoadingProps { - sectionCount?: number -} - -export function FilterSidebarLoading({ sectionCount = 3 }: FilterSidebarLoadingProps) { - return ( - -
- -
- -
- {Array.from({ length: sectionCount }).map((_, i) => ( -
- - - - -
- ))} -
-
- ) -} +export { + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarBody, + FilterSidebarLoading, +} from "@maple/ui/components/filters/filter-sidebar" interface FilterSidebarErrorProps { error: unknown diff --git a/apps/web/src/components/metrics/metric-type-badge.tsx b/apps/web/src/components/metrics/metric-type-badge.tsx index 0f693a0ba..a40e69ab0 100644 --- a/apps/web/src/components/metrics/metric-type-badge.tsx +++ b/apps/web/src/components/metrics/metric-type-badge.tsx @@ -1,37 +1,2 @@ -import { Badge } from "@maple/ui/components/ui/badge" - -const metricTypeConfig: Record = { - sum: { - label: "Sum", - className: "bg-chart-p50/15 text-chart-p50", - }, - gauge: { - label: "Gauge", - className: "bg-severity-info/15 text-severity-info", - }, - histogram: { - label: "Histogram", - className: "bg-chart-4/15 text-chart-4", - }, - exponential_histogram: { - label: "Exp Hist", - className: "bg-primary/15 text-primary", - }, -} - -interface MetricTypeBadgeProps { - type: string -} - -export function MetricTypeBadge({ type }: MetricTypeBadgeProps) { - const config = metricTypeConfig[type] ?? { - label: type, - className: "bg-muted text-muted-foreground", - } - - return ( - - {config.label} - - ) -} +// Promoted to @maple/ui so the local-mode UI shares the exact same badge. +export { MetricTypeBadge } from "@maple/ui/components/metrics/metric-type-badge" diff --git a/apps/web/src/components/replays/replay-format.ts b/apps/web/src/components/replays/replay-format.ts index e89f8f675..00a79b13f 100644 --- a/apps/web/src/components/replays/replay-format.ts +++ b/apps/web/src/components/replays/replay-format.ts @@ -1,52 +1,18 @@ import { warehouseDateTimeToIso } from "@maple/query-engine" import type { ActionKind } from "./replay-player-context" -// Shared presentation helpers for the session-replay surfaces (list, detail, -// player, timeline). One home so the list and detail views can't drift. +// Presentation helpers for the session-replay surfaces (list, detail, player, +// timeline). The pure formatters are promoted to @maple/ui (shared with the +// local-mode UI); the warehouse-coupled window helper stays here. -/** `1m 23s` / `45s`, or `—` for missing/zero durations. */ -export function formatDuration(ms: number | null): string { - if (ms == null || ms <= 0) return "—" - const totalSeconds = Math.round(ms / 1000) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` -} - -/** Playhead clock `m:ss`. Clamps non-finite/negative input to 0. */ -export function formatClock(ms: number): string { - if (!Number.isFinite(ms) || ms < 0) ms = 0 - const totalSeconds = Math.floor(ms / 1000) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${minutes}:${seconds.toString().padStart(2, "0")}` -} - -/** Host + path for compact URL display; returns the raw input if unparseable. */ -export function hostFromUrl(url: string): string { - try { - const u = new URL(url) - return `${u.host}${u.pathname === "/" ? "" : u.pathname}` - } catch { - return url - } -} - -const AVATAR_GRADIENTS = [ - "from-rose-500/80 to-orange-400/80", - "from-violet-500/80 to-fuchsia-400/80", - "from-sky-500/80 to-cyan-400/80", - "from-emerald-500/80 to-teal-400/80", - "from-amber-500/80 to-yellow-400/80", - "from-indigo-500/80 to-blue-400/80", -] - -/** Deterministic avatar gradient for a session, keyed by a stable seed. */ -export function gradientFor(seed: string): string { - let hash = 0 - for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0 - return AVATAR_GRADIENTS[hash % AVATAR_GRADIENTS.length]! -} +export { + formatClock, + formatDuration, + formatRelativeTime, + gradientFor, + hostFromUrl, + isMobileDevice, +} from "@maple/ui/lib/replay-format" /** Marker dot colour by action kind, shared by the player and timeline tracks. */ export const MARKER_STYLES: Record = { @@ -64,17 +30,6 @@ export const MARKER_LABELS: Record = { nav: "Navigate", } -const RELATIVE_UNITS: ReadonlyArray = [ - ["year", 365 * 24 * 60 * 60 * 1000], - ["month", 30 * 24 * 60 * 60 * 1000], - ["day", 24 * 60 * 60 * 1000], - ["hour", 60 * 60 * 1000], - ["minute", 60 * 1000], - ["second", 1000], -] - -const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) - // Partition-pruning window for the session-detail warehouse queries. The replay // tables are PARTITION BY toDate(...) over a 30-day TTL, so a query filtered only // by (OrgId, SessionId/TraceId) scans the index of every daily partition. Bounding @@ -118,15 +73,3 @@ export function replayPartitionWindow( windowEnd: toWarehouseDateTime(upperMs), } } - -/** `2h ago` / `just now` for an epoch-ms instant, relative to `nowMs` (defaults to Date.now()). */ -export function formatRelativeTime(epochMs: number, nowMs: number = Date.now()): string { - if (!Number.isFinite(epochMs)) return "—" - const deltaMs = epochMs - nowMs - const abs = Math.abs(deltaMs) - if (abs < 5_000) return "just now" - for (const [unit, ms] of RELATIVE_UNITS) { - if (abs >= ms) return relativeFmt.format(Math.round(deltaMs / ms), unit) - } - return "just now" -} diff --git a/apps/web/src/components/traces/duration-range-filter.tsx b/apps/web/src/components/traces/duration-range-filter.tsx index 5ed7bdb99..d022dfdb6 100644 --- a/apps/web/src/components/traces/duration-range-filter.tsx +++ b/apps/web/src/components/traces/duration-range-filter.tsx @@ -1,206 +1,2 @@ -import * as React from "react" -import { ChevronDownIcon, XmarkIcon } from "@/components/icons" - -import { cn } from "@maple/ui/utils" -import { Input } from "@maple/ui/components/ui/input" -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" - -const COMMIT_DEBOUNCE_MS = 400 - -interface DurationRangeFilterProps { - minValue: number | undefined - maxValue: number | undefined - onRangeChange: (min: number | undefined, max: number | undefined) => void - durationStats?: { - minDurationMs: number - maxDurationMs: number - p50DurationMs: number - p95DurationMs: number - } - defaultOpen?: boolean -} - -export function DurationRangeFilter({ - minValue, - maxValue, - onRangeChange, - durationStats, - defaultOpen = false, -}: DurationRangeFilterProps) { - const hasActiveRange = minValue !== undefined || maxValue !== undefined - const [isOpen, setIsOpen] = React.useState(defaultOpen || hasActiveRange) - - // Inputs edit a local draft; the URL (and the queries behind it) only - // updates after a pause in typing, or immediately for preset clicks. - const [draft, setDraft] = React.useState({ min: toText(minValue), max: toText(maxValue) }) - const [prevRange, setPrevRange] = React.useState({ minValue, maxValue }) - if (prevRange.minValue !== minValue || prevRange.maxValue !== maxValue) { - setPrevRange({ minValue, maxValue }) - setDraft({ min: toText(minValue), max: toText(maxValue) }) - } - - const commitTimer = React.useRef | undefined>(undefined) - const cancelPendingCommit = () => { - if (commitTimer.current !== undefined) { - clearTimeout(commitTimer.current) - commitTimer.current = undefined - } - } - - const handleDraftChange = (next: { min: string; max: string }) => { - setDraft(next) - cancelPendingCommit() - commitTimer.current = setTimeout(() => { - commitTimer.current = undefined - onRangeChange(fromText(next.min), fromText(next.max)) - }, COMMIT_DEBOUNCE_MS) - } - - const applyRange = (min: number | undefined, max: number | undefined) => { - cancelPendingCommit() - setDraft({ min: toText(min), max: toText(max) }) - onRangeChange(min, max) - } - - const applyPreset = (minMs: number) => { - const rounded = Math.round(minMs) - if (minValue === rounded && maxValue === undefined) { - applyRange(undefined, undefined) - return - } - applyRange(rounded, undefined) - } - - const presets: Array<{ key: string; label: string; minMs: number; value: string }> = [] - if (durationStats && durationStats.p50DurationMs > 0) { - presets.push({ - key: "p50", - label: "> p50", - minMs: durationStats.p50DurationMs, - value: formatDuration(durationStats.p50DurationMs), - }) - } - if (durationStats && durationStats.p95DurationMs > 0) { - presets.push({ - key: "p95", - label: "> p95", - minMs: durationStats.p95DurationMs, - value: formatDuration(durationStats.p95DurationMs), - }) - } - presets.push({ key: "1s", label: "> 1s", minMs: 1000, value: "" }) - - return ( - - - Duration - - {!isOpen && hasActiveRange && ( - - {formatRange(minValue, maxValue)} - { - e.stopPropagation() - applyRange(undefined, undefined) - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - e.stopPropagation() - applyRange(undefined, undefined) - } - }} - > - - - - )} - - - - -
-
- {presets.map((preset) => { - const isActive = minValue === Math.round(preset.minMs) && maxValue === undefined - return ( - - ) - })} -
-
- handleDraftChange({ ...draft, min: e.target.value })} - /> - - handleDraftChange({ ...draft, max: e.target.value })} - /> - ms -
-
-
-
- ) -} - -function toText(value: number | undefined): string { - return value === undefined ? "" : String(value) -} - -function fromText(text: string): number | undefined { - if (text.trim() === "") return undefined - const value = Number(text) - return Number.isFinite(value) && value >= 0 ? value : undefined -} - -function formatRange(minValue: number | undefined, maxValue: number | undefined): string { - if (minValue !== undefined && maxValue !== undefined) { - return `${formatDuration(minValue)} – ${formatDuration(maxValue)}` - } - if (minValue !== undefined) { - return `≥ ${formatDuration(minValue)}` - } - return `≤ ${formatDuration(maxValue ?? 0)}` -} - -function formatDuration(ms: number): string { - if (ms < 1) { - return `${(ms * 1000).toFixed(0)}us` - } - if (ms < 1000) { - return `${ms.toFixed(1)}ms` - } - return `${(ms / 1000).toFixed(2)}s` -} +// Promoted to @maple/ui so the local-mode UI shares the exact same filter chrome. +export { DurationRangeFilter, type DurationStats } from "@maple/ui/components/filters/duration-range-filter" diff --git a/packages/ui/src/components/filters/duration-range-filter.tsx b/packages/ui/src/components/filters/duration-range-filter.tsx new file mode 100644 index 000000000..4b3ba32a3 --- /dev/null +++ b/packages/ui/src/components/filters/duration-range-filter.tsx @@ -0,0 +1,230 @@ +import * as React from "react" + +import { ChevronDownIcon, XmarkIcon } from "../icons" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible" +import { Input } from "../ui/input" +import { cn } from "../../lib/utils" + +export interface DurationStats { + minDurationMs: number + maxDurationMs: number + p50DurationMs: number + p95DurationMs: number +} + +interface DurationRangeFilterProps { + minValue: number | undefined + maxValue: number | undefined + onRangeChange: (min: number | undefined, max: number | undefined) => void + durationStats?: DurationStats + defaultOpen?: boolean + /** Pause before typed values commit. Local mode uses a shorter one since every commit re-queries chDB. */ + debounceMs?: number +} + +export function DurationRangeFilter({ + minValue, + maxValue, + onRangeChange, + durationStats, + defaultOpen = false, + debounceMs = 400, +}: DurationRangeFilterProps) { + const hasActiveRange = minValue !== undefined || maxValue !== undefined + const [isOpen, setIsOpen] = React.useState(defaultOpen || hasActiveRange) + + // Inputs edit a local draft; the caller's state (and the queries behind it) only + // updates after a pause in typing, on blur/Enter, or immediately for preset clicks. + const [draft, setDraft] = React.useState({ min: toText(minValue), max: toText(maxValue) }) + const [prevRange, setPrevRange] = React.useState({ minValue, maxValue }) + if (prevRange.minValue !== minValue || prevRange.maxValue !== maxValue) { + setPrevRange({ minValue, maxValue }) + setDraft({ min: toText(minValue), max: toText(maxValue) }) + } + + const commitTimer = React.useRef | undefined>(undefined) + const cancelPendingCommit = () => { + if (commitTimer.current !== undefined) { + clearTimeout(commitTimer.current) + commitTimer.current = undefined + } + } + + const commitDraft = (next: { min: string; max: string }) => { + cancelPendingCommit() + onRangeChange(fromText(next.min), fromText(next.max)) + } + + const handleDraftChange = (next: { min: string; max: string }) => { + setDraft(next) + cancelPendingCommit() + commitTimer.current = setTimeout(() => { + commitTimer.current = undefined + onRangeChange(fromText(next.min), fromText(next.max)) + }, debounceMs) + } + + const flushDraft = () => { + if (commitTimer.current !== undefined) { + commitDraft(draft) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + commitDraft(draft) + } + } + + const applyRange = (min: number | undefined, max: number | undefined) => { + cancelPendingCommit() + setDraft({ min: toText(min), max: toText(max) }) + onRangeChange(min, max) + } + + const applyPreset = (minMs: number) => { + const rounded = Math.round(minMs) + if (minValue === rounded && maxValue === undefined) { + applyRange(undefined, undefined) + return + } + applyRange(rounded, undefined) + } + + const presets: Array<{ key: string; label: string; minMs: number; value: string }> = [] + if (durationStats && durationStats.p50DurationMs > 0) { + presets.push({ + key: "p50", + label: "> p50", + minMs: durationStats.p50DurationMs, + value: formatDuration(durationStats.p50DurationMs), + }) + } + if (durationStats && durationStats.p95DurationMs > 0) { + presets.push({ + key: "p95", + label: "> p95", + minMs: durationStats.p95DurationMs, + value: formatDuration(durationStats.p95DurationMs), + }) + } + presets.push({ key: "1s", label: "> 1s", minMs: 1000, value: "" }) + + return ( + + + Duration + + {!isOpen && hasActiveRange && ( + + {formatRange(minValue, maxValue)} + { + e.stopPropagation() + applyRange(undefined, undefined) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + e.stopPropagation() + applyRange(undefined, undefined) + } + }} + > + + + + )} + + + + +
+
+ {presets.map((preset) => { + const isActive = minValue === Math.round(preset.minMs) && maxValue === undefined + return ( + + ) + })} +
+
+ handleDraftChange({ ...draft, min: e.target.value })} + onBlur={flushDraft} + onKeyDown={handleKeyDown} + /> + + handleDraftChange({ ...draft, max: e.target.value })} + onBlur={flushDraft} + onKeyDown={handleKeyDown} + /> + ms +
+
+
+
+ ) +} + +function toText(value: number | undefined): string { + return value === undefined ? "" : String(value) +} + +function fromText(text: string): number | undefined { + if (text.trim() === "") return undefined + const value = Number(text) + return Number.isFinite(value) && value >= 0 ? value : undefined +} + +function formatRange(minValue: number | undefined, maxValue: number | undefined): string { + if (minValue !== undefined && maxValue !== undefined) { + return `${formatDuration(minValue)} – ${formatDuration(maxValue)}` + } + if (minValue !== undefined) { + return `≥ ${formatDuration(minValue)}` + } + return `≤ ${formatDuration(maxValue ?? 0)}` +} + +function formatDuration(ms: number): string { + if (ms < 1) { + return `${(ms * 1000).toFixed(0)}us` + } + if (ms < 1000) { + return `${ms.toFixed(1)}ms` + } + return `${(ms / 1000).toFixed(2)}s` +} diff --git a/apps/local-ui/src/components/filter-section.tsx b/packages/ui/src/components/filters/filter-section.tsx similarity index 58% rename from apps/local-ui/src/components/filter-section.tsx rename to packages/ui/src/components/filters/filter-section.tsx index d5b35a174..dcbef0638 100644 --- a/apps/local-ui/src/components/filter-section.tsx +++ b/packages/ui/src/components/filters/filter-section.tsx @@ -1,18 +1,12 @@ -// Checkbox filter sections for the filter sidebar — mirrors the web app's -// `@/components/filters/filter-section` so local mode reads as the same product. - import * as React from "react" -import { ChevronDownIcon, MagnifierIcon, XmarkIcon } from "@maple/ui/components/icons" -import { cn } from "@maple/ui/utils" -import { Checkbox } from "@maple/ui/components/ui/checkbox" -import { Label } from "@maple/ui/components/ui/label" -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@maple/ui/components/ui/input-group" + +import { ChevronDownIcon, type IconComponent, MagnifierIcon, XmarkIcon } from "../icons" +import { Checkbox } from "../ui/checkbox" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible" +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "../ui/input-group" +import { Label } from "../ui/label" +import { getServiceColor } from "../../lib/colors" +import { cn } from "../../lib/utils" export interface FilterOption { name: string @@ -21,14 +15,20 @@ export interface FilterOption { interface FilterSectionBaseProps { title: string - options: FilterOption[] + options: ReadonlyArray selected: string[] onChange: (selected: string[]) => void defaultOpen?: boolean maxVisible?: number colorMap?: Record + /** Option-name → icon, rendered before the label (like colorMap's swatch). */ + getOptionIcon?: (name: string) => IconComponent | undefined } +interface FilterSectionProps extends FilterSectionBaseProps {} + +interface SearchableFilterSectionProps extends FilterSectionBaseProps {} + function FilterSectionBase({ title, options, @@ -38,6 +38,7 @@ function FilterSectionBase({ maxVisible = 5, searchable, colorMap, + getOptionIcon, }: FilterSectionBaseProps & { searchable: boolean }) { const [isOpen, setIsOpen] = React.useState(defaultOpen) const [showAll, setShowAll] = React.useState(false) @@ -74,7 +75,7 @@ function FilterSectionBase({ return ( - + {title} @@ -111,33 +112,37 @@ function FilterSectionBase({ )}
{visibleOptions.length === 0 ? ( -

No matches found

+

No matches found

) : ( - visibleOptions.map((option) => ( -
- toggleOption(option.name)} - /> - - - {option.count.toLocaleString()} - -
- )) + visibleOptions.map((option) => { + const OptionIcon = getOptionIcon?.(option.name) + return ( +
+ toggleOption(option.name)} + /> + + + {option.count.toLocaleString()} + +
+ ) + }) )} {hasMore && (
) diff --git a/packages/ui/src/components/filters/filter-sidebar.tsx b/packages/ui/src/components/filters/filter-sidebar.tsx new file mode 100644 index 000000000..257b6b64d --- /dev/null +++ b/packages/ui/src/components/filters/filter-sidebar.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from "react" + +import { ScrollArea } from "../ui/scroll-area" +import { Separator } from "../ui/separator" +import { Skeleton } from "../ui/skeleton" +import { cn } from "../../lib/utils" + +interface FilterSidebarFrameProps { + children: ReactNode + waiting?: boolean + className?: string +} + +export function FilterSidebarFrame({ children, waiting = false, className }: FilterSidebarFrameProps) { + // Width is owned by the container (e.g. the web app's PageLayout.FilterSidebar: an inline aside + // on desktop, a sheet below lg). Setting one here would fight it — callers that own their own + // layout (local mode) pass a width via className instead. + return ( +
+ {children} +
+ ) +} + +interface FilterSidebarHeaderProps { + title?: string + canClear?: boolean + onClear?: () => void +} + +export function FilterSidebarHeader({ + title = "Filters", + canClear = false, + onClear, +}: FilterSidebarHeaderProps) { + return ( +
+

{title}

+ {canClear && onClear && ( + + )} +
+ ) +} + +export function FilterSidebarBody({ children }: { children: ReactNode }) { + return ( + <> + +
+ +
{children}
+
+
+
+ + ) +} + +interface FilterSidebarLoadingProps { + sectionCount?: number +} + +export function FilterSidebarLoading({ sectionCount = 3 }: FilterSidebarLoadingProps) { + return ( + +
+ +
+ +
+ {Array.from({ length: sectionCount }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ ) +} diff --git a/packages/ui/src/components/metrics/metric-type-badge.tsx b/packages/ui/src/components/metrics/metric-type-badge.tsx new file mode 100644 index 000000000..3713fc250 --- /dev/null +++ b/packages/ui/src/components/metrics/metric-type-badge.tsx @@ -0,0 +1,45 @@ +import { Badge } from "../ui/badge" + +const metricTypeConfig: Record = { + sum: { + label: "Sum", + className: "bg-chart-p50/15 text-chart-p50", + }, + gauge: { + label: "Gauge", + className: "bg-severity-info/15 text-severity-info", + }, + histogram: { + label: "Histogram", + className: "bg-chart-4/15 text-chart-4", + }, + exponential_histogram: { + label: "Exp Hist", + className: "bg-primary/15 text-primary", + }, +} + +/** Chart color per metric type, paired with the badge palette (sparklines, previews). */ +export const METRIC_TYPE_COLORS: Record = { + sum: "var(--chart-p50)", + gauge: "var(--severity-info)", + histogram: "var(--chart-4)", + exponential_histogram: "var(--primary)", +} + +interface MetricTypeBadgeProps { + type: string +} + +export function MetricTypeBadge({ type }: MetricTypeBadgeProps) { + const config = metricTypeConfig[type] ?? { + label: type, + className: "bg-muted text-muted-foreground", + } + + return ( + + {config.label} + + ) +} diff --git a/packages/ui/src/components/toolbar.tsx b/packages/ui/src/components/toolbar.tsx new file mode 100644 index 000000000..f29677b4b --- /dev/null +++ b/packages/ui/src/components/toolbar.tsx @@ -0,0 +1,152 @@ +// Sticky page toolbar family — search + result stats + time range + refresh. +// Data-agnostic: callers own the time-range presets and the refresh action. + +import { useCallback, useRef, useState, type ReactNode } from "react" + +import { ArrowRotateClockwiseIcon, ClockIcon, MagnifierIcon, XmarkIcon } from "./icons" +import { Button } from "./ui/button" +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "./ui/input-group" +import { NativeSelect, NativeSelectOption } from "./ui/native-select" +import { cn } from "../lib/utils" + +export function Toolbar({ search, stats }: { search: ReactNode; stats: ReactNode }) { + return ( +
+ {search} +
{stats}
+
+ ) +} + +/** Manual reload button; the caller supplies the refetch action (it resolves when done). */ +export function RefreshButton({ + onRefresh, + className, +}: { + onRefresh: () => Promise + className?: string +}) { + const [spinning, setSpinning] = useState(false) + + const onClick = useCallback(() => { + setSpinning(true) + onRefresh().finally(() => setSpinning(false)) + }, [onRefresh]) + + return ( + + ) +} + +export function ToolbarSearch({ + query, + onSearch, + placeholder, + debounceMs = 300, +}: { + query: string + onSearch: (value: string | undefined) => void + placeholder: string + debounceMs?: number +}) { + const [value, setValue] = useState(query) + const debounceRef = useRef | null>(null) + + // Keep the input in sync when the param changes elsewhere (e.g. Clear all). + const [lastQuery, setLastQuery] = useState(query) + if (query !== lastQuery) { + setLastQuery(query) + setValue(query) + } + + const handleChange = useCallback( + (next: string) => { + setValue(next) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + onSearch(next.trim() || undefined) + }, debounceMs) + }, + [onSearch, debounceMs], + ) + + return ( + + + + + handleChange(e.target.value)} + placeholder={placeholder} + /> + {value && ( + + handleChange("")}> + + + + )} + + ) +} + +export function ToolbarStat({ + value, + label, + dot, + danger, +}: { + value: number + label: string + dot?: boolean + danger?: boolean +}) { + return ( + + {dot ? : null} + 0 && "text-destructive")}> + {value.toLocaleString()} + + {label} + + ) +} + +export interface TimeRangeOption { + key: string + label: string +} + +export function TimeRangeSelect({ + ranges, + value, + onChange, +}: { + ranges: ReadonlyArray + value: string + onChange: (next: string) => void +}) { + return ( +
+ + onChange(e.target.value)}> + {ranges.map((range) => ( + + {range.label} + + ))} + +
+ ) +} diff --git a/packages/ui/src/lib/replay-format.ts b/packages/ui/src/lib/replay-format.ts new file mode 100644 index 000000000..c7108202a --- /dev/null +++ b/packages/ui/src/lib/replay-format.ts @@ -0,0 +1,77 @@ +// Shared presentation helpers for the session-replay surfaces (list, detail, +// player, timeline) — used by both the web app and the local-mode UI so the +// two can't drift. Warehouse-coupled helpers (partition windows) stay in the +// web app: this package doesn't depend on @maple/query-engine. + +/** `1m 23s` / `45s`, or `—` for missing/zero durations. */ +export function formatDuration(ms: number | null): string { + if (ms == null || ms <= 0) return "—" + const totalSeconds = Math.round(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` +} + +/** Playhead clock `m:ss`. Clamps non-finite/negative input to 0. */ +export function formatClock(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) ms = 0 + const totalSeconds = Math.floor(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, "0")}` +} + +/** Host + path for compact URL display; returns the raw input if unparseable. */ +export function hostFromUrl(url: string): string { + try { + const u = new URL(url) + return `${u.host}${u.pathname === "/" ? "" : u.pathname}` + } catch { + return url + } +} + +const AVATAR_GRADIENTS = [ + "from-rose-500/80 to-orange-400/80", + "from-violet-500/80 to-fuchsia-400/80", + "from-sky-500/80 to-cyan-400/80", + "from-emerald-500/80 to-teal-400/80", + "from-amber-500/80 to-yellow-400/80", + "from-indigo-500/80 to-blue-400/80", +] + +/** Deterministic avatar gradient for a session, keyed by a stable seed. */ +export function gradientFor(seed: string): string { + let hash = 0 + for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0 + return AVATAR_GRADIENTS[hash % AVATAR_GRADIENTS.length]! +} + +/** `true` for handheld device-type strings as reported by the browser SDK. */ +export function isMobileDevice(deviceType: string): boolean { + const d = deviceType.toLowerCase() + return d === "mobile" || d === "tablet" || d === "phone" +} + +const RELATIVE_UNITS: ReadonlyArray = [ + ["year", 365 * 24 * 60 * 60 * 1000], + ["month", 30 * 24 * 60 * 60 * 1000], + ["day", 24 * 60 * 60 * 1000], + ["hour", 60 * 60 * 1000], + ["minute", 60 * 1000], + ["second", 1000], +] + +const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) + +/** `2h ago` / `just now` for an epoch-ms instant, relative to `nowMs` (defaults to Date.now()). */ +export function formatRelativeTime(epochMs: number, nowMs: number = Date.now()): string { + if (!Number.isFinite(epochMs)) return "—" + const deltaMs = epochMs - nowMs + const abs = Math.abs(deltaMs) + if (abs < 5_000) return "just now" + for (const [unit, ms] of RELATIVE_UNITS) { + if (abs >= ms) return relativeFmt.format(Math.round(deltaMs / ms), unit) + } + return "just now" +}