+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..a3041b2df
--- /dev/null
+++ b/apps/web/src/components/metrics/metrics-type-filter.tsx
@@ -0,0 +1,84 @@
+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.
+ * 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,
+ 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") =>
+ type === "all"
+ ? summary?.reduce((acc, row) => acc + row.dataPointCount, 0)
+ : summary?.find((row) => row.metricType === type)?.dataPointCount
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx
index ff51946ef..ec5dd3c15 100644
--- a/apps/web/src/routes/analytics/index.tsx
+++ b/apps/web/src/routes/analytics/index.tsx
@@ -179,7 +179,22 @@ function WebAnalyticsPage() {
-
+ {/* View tabs share the header row with the range controls on wide
+ screens, same as Hosts; the header stacks them on narrow ones. */}
+
+
+
+ Overview
+
+
+ AI traffic
+
+
+
+ }
+ >
{/* Ahead of the range controls, because it is the one number
on the page they do not govern: "right now" is its own
@@ -218,14 +233,6 @@ function WebAnalyticsPage() {
/>
- {/* A page-width tab bar, same as Alerts: a pill beside the time
- controls read as one more filter and was easy to miss. */}
-
-
- Overview
- AI traffic
-
-