From 80a0e8483f79b237277b5afaf4371c91ab2c093f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 6 Oct 2026 22:29:16 +0200 Subject: [PATCH 1/4] feat(metrics): collapse the metrics page controls into one header row The page stacked three rows above the content: the header with the time range, a four-card stat rail, and a search + view toggle row. The stat cards were really a type filter, so they become a single dropdown with per-type metric counts in its menu, and search, type and view now sit in the sticky page header beside the time range. --- .../src/components/metrics/metrics-browse.tsx | 176 +++++++++--------- .../metrics/metrics-summary-cards.tsx | 78 -------- .../metrics/metrics-type-filter.tsx | 83 +++++++++ apps/web/src/routes/metrics/index.tsx | 39 +++- 4 files changed, 204 insertions(+), 172 deletions(-) delete mode 100644 apps/web/src/components/metrics/metrics-summary-cards.tsx create mode 100644 apps/web/src/components/metrics/metrics-type-filter.tsx diff --git a/apps/web/src/components/metrics/metrics-browse.tsx b/apps/web/src/components/metrics/metrics-browse.tsx index bfa1eb2b4..d104fc129 100644 --- a/apps/web/src/components/metrics/metrics-browse.tsx +++ b/apps/web/src/components/metrics/metrics-browse.tsx @@ -1,8 +1,8 @@ import * as React from "react" -import { Input } from "@maple/ui/components/ui/input" +import { SearchInput } from "@maple/ui/components/ui/search-input" import { GridIcon, MenuIcon } from "@/components/icons" -import { MetricsSummaryCards, type MetricType } from "./metrics-summary-cards" +import { MetricsTypeFilter, type MetricType } from "./metrics-type-filter" import { MetricsTable } from "./metrics-table" import { MetricPreviewGrid } from "./metric-preview-grid" import type { Metric } from "@/api/warehouse/metrics" @@ -17,32 +17,27 @@ export interface MetricsBrowsePatch { view?: MetricsBrowseView } -interface MetricsBrowseProps { +interface TimeWindowProps { startTime?: string endTime?: string timePreset?: string - q: string - type: MetricType | null - view: MetricsBrowseView - onPatch: (patch: MetricsBrowsePatch) => void - onOpenMetric: (metric: Metric) => void } -export function MetricsBrowse({ - startTime, - endTime, - timePreset, +const useBrowseTimeRange = ({ startTime, endTime, timePreset }: TimeWindowProps) => + useEffectiveTimeRange(startTime, endTime, timePreset ?? "24h") + +/** Search and type pivot, rendered in the page header so the page has one control row. */ +export function MetricsBrowseFilters({ q, type, - view, onPatch, - onOpenMetric, -}: MetricsBrowseProps) { - const { startTime: effectiveStartTime, endTime: effectiveEndTime } = useEffectiveTimeRange( - startTime, - endTime, - timePreset ?? "24h", - ) + ...time +}: TimeWindowProps & { + q: string + type: MetricType | null + onPatch: (patch: MetricsBrowsePatch) => void +}) { + const { startTime, endTime } = useBrowseTimeRange(time) // Search input stays local while typing and commits to the URL after a // pause, so the atom query (and history) aren't churned per keystroke. @@ -63,74 +58,85 @@ export function MetricsBrowse({ commitTimer.current = setTimeout(() => onPatch({ q: next }), 300) } - const deferredSearch = React.useDeferredValue(q) - const handleClearFilters = () => onPatch({ q: "", type: undefined }) - return ( -
- onPatch({ type: nextType ?? undefined })} - startTime={effectiveStartTime} - endTime={effectiveEndTime} +
+ + onPatch({ type: nextType ?? undefined })} + startTime={startTime} + endTime={endTime} /> +
+ ) +} -
- handleSearchChange(e.target.value)} - className="max-w-sm" - /> - {type && ( - - Filtered by: {type} - - )} - { - const next = values[0] - if (next === "grid" || next === "table") onPatch({ view: next }) - }} - > - - - - - - - -
+export function MetricsViewToggle({ + view, + onPatch, +}: { + view: MetricsBrowseView + onPatch: (patch: MetricsBrowsePatch) => void +}) { + return ( + { + const next = values[0] + if (next === "grid" || next === "table") onPatch({ view: next }) + }} + > + + + + + + + + ) +} - {view === "grid" ? ( - - ) : ( -
-

Available Metrics

- -
- )} -
+interface MetricsBrowseProps extends TimeWindowProps { + q: string + type: MetricType | null + view: MetricsBrowseView + onPatch: (patch: MetricsBrowsePatch) => void + onOpenMetric: (metric: Metric) => void +} + +export function MetricsBrowse({ q, type, view, onPatch, onOpenMetric, ...time }: MetricsBrowseProps) { + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = useBrowseTimeRange(time) + + const deferredSearch = React.useDeferredValue(q) + const handleClearFilters = () => onPatch({ q: "", type: undefined }) + + return view === "grid" ? ( + + ) : ( + ) } diff --git a/apps/web/src/components/metrics/metrics-summary-cards.tsx b/apps/web/src/components/metrics/metrics-summary-cards.tsx deleted file mode 100644 index a477d0310..000000000 --- a/apps/web/src/components/metrics/metrics-summary-cards.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { refreshingClass } from "@maple/ui/lib/refreshing" -import { formatNumber } from "@maple/ui/lib/format" -import { useAtomValue, useAtomRefresh } from "@/lib/effect-atom" - -import { ResultView } from "@/components/common/result-view" -import { StatRail, StatRailItem, StatRailLoading } from "@/components/common/stat-rail" -import { type ListMetricsInput } from "@/api/warehouse/metrics" -import { getMetricsSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" - -export type MetricType = ListMetricsInput["metricType"] - -const cardConfig = [ - { title: "Sum Metrics", key: "sum" as const }, - { title: "Gauge Metrics", key: "gauge" as const }, - { title: "Histogram", key: "histogram" as const }, - { title: "Exp Histogram", key: "exponential_histogram" as const }, -] - -interface MetricsSummaryCardsProps { - selectedType: MetricType | null - onSelectType: (type: MetricType | null) => void - startTime?: string - endTime?: string -} - -export function MetricsSummaryCards({ - selectedType, - onSelectType, - startTime, - endTime, -}: MetricsSummaryCardsProps) { - const summaryAtom = getMetricsSummaryResultAtom({ data: { startTime, endTime } }) - const summaryResult = useAtomValue(summaryAtom) - const refreshSummary = useAtomRefresh(summaryAtom) - - return ( - } - errorVariant="inline" - errorTitle="Failed to load metrics summary" - onRetry={refreshSummary} - > - {(response, { waiting }) => { - const summaryByType = response.data.reduce( - (acc, item) => { - acc[item.metricType] = { - metricCount: item.metricCount, - dataPointCount: item.dataPointCount, - } - return acc - }, - {} as Record, - ) - - return ( - - {cardConfig.map((card) => { - const data = summaryByType[card.key] - const isSelected = selectedType === card.key - return ( - onSelectType(isSelected ? null : card.key)} - /> - ) - })} - - ) - }} - - ) -} diff --git a/apps/web/src/components/metrics/metrics-type-filter.tsx b/apps/web/src/components/metrics/metrics-type-filter.tsx new file mode 100644 index 000000000..dc464faee --- /dev/null +++ b/apps/web/src/components/metrics/metrics-type-filter.tsx @@ -0,0 +1,83 @@ +import { Option } from "effect" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { formatNumber } from "@maple/ui/lib/format" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { useAtomValue } from "@/lib/effect-atom" + +import { type ListMetricsInput } from "@/api/warehouse/metrics" +import { getMetricsSummaryResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" + +export type MetricType = NonNullable + +const ITEMS: ReadonlyArray<{ value: MetricType | "all"; label: string }> = [ + { value: "all", label: "All types" }, + { value: "sum", label: "Sum" }, + { value: "gauge", label: "Gauge" }, + { value: "histogram", label: "Histogram" }, + { value: "exponential_histogram", label: "Exp histogram" }, +] + +/** The metric-type pivot; counts live in the menu so the toolbar stays a single quiet control. */ +export function MetricsTypeFilter({ + value, + onChange, + startTime, + endTime, +}: { + value: MetricType | null + onChange: (type: MetricType | null) => void + startTime?: string + endTime?: string +}) { + const summaryResult = useAtomValue(getMetricsSummaryResultAtom({ data: { startTime, endTime } })) + // Counts are decoration: a loading or failed summary still leaves the filter usable. + const summary = Option.getOrNull(AsyncResult.value(summaryResult))?.data ?? null + const countOf = (type: MetricType | "all") => + summary === null + ? undefined + : type === "all" + ? summary.reduce((acc, row) => acc + row.metricCount, 0) + : (summary.find((row) => row.metricType === type)?.metricCount ?? 0) + const dataPointsOf = (type: MetricType | "all") => + summary?.find((row) => row.metricType === type)?.dataPointCount + + return ( + + ) +} diff --git a/apps/web/src/routes/metrics/index.tsx b/apps/web/src/routes/metrics/index.tsx index d0fcda143..984dba25d 100644 --- a/apps/web/src/routes/metrics/index.tsx +++ b/apps/web/src/routes/metrics/index.tsx @@ -2,7 +2,12 @@ import { useNavigate, createFileRoute } from "@tanstack/react-router" import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" -import { MetricsBrowse, type MetricsBrowsePatch } from "@/components/metrics/metrics-browse" +import { + MetricsBrowse, + MetricsBrowseFilters, + MetricsViewToggle, + type MetricsBrowsePatch, +} from "@/components/metrics/metrics-browse" import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" import { sessionTimeRangeSearchMiddleware } from "@/components/time-range-picker/session-time-range" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" @@ -59,14 +64,30 @@ function MetricsPage() { - - + + } + > +
+ + +
From 733a4cf1f78864f00ef8cbaa571c7d0518ccc42d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 6 Oct 2026 23:19:00 +0200 Subject: [PATCH 2/4] fix(metrics): never disable a type on summary counts The summary can be stale across a time-range change or lag the results, and a zero All count made the filter impossible to clear. Counts are now informational only. All types also gets its data-point total title. --- .../components/metrics/metrics-type-filter.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/metrics/metrics-type-filter.tsx b/apps/web/src/components/metrics/metrics-type-filter.tsx index dc464faee..a3041b2df 100644 --- a/apps/web/src/components/metrics/metrics-type-filter.tsx +++ b/apps/web/src/components/metrics/metrics-type-filter.tsx @@ -17,7 +17,10 @@ const ITEMS: ReadonlyArray<{ value: MetricType | "all"; label: string }> = [ { value: "exponential_histogram", label: "Exp histogram" }, ] -/** The metric-type pivot; counts live in the menu so the toolbar stays a single quiet control. */ +/** + * The metric-type pivot; counts live in the menu so the toolbar stays a single quiet control. + * Counts are informational only: the summary can be stale or lag the results, so a zero never blocks a type. + */ export function MetricsTypeFilter({ value, onChange, @@ -39,7 +42,9 @@ export function MetricsTypeFilter({ ? summary.reduce((acc, row) => acc + row.metricCount, 0) : (summary.find((row) => row.metricType === type)?.metricCount ?? 0) const dataPointsOf = (type: MetricType | "all") => - summary?.find((row) => row.metricType === type)?.dataPointCount + type === "all" + ? summary?.reduce((acc, row) => acc + row.dataPointCount, 0) + : summary?.find((row) => row.metricType === type)?.dataPointCount return (