diff --git a/apps/web/src/components/integrations/github-integration-card.tsx b/apps/web/src/components/integrations/github-integration-card.tsx index 70a57cb32..923fa0d6f 100644 --- a/apps/web/src/components/integrations/github-integration-card.tsx +++ b/apps/web/src/components/integrations/github-integration-card.tsx @@ -714,13 +714,7 @@ function BranchSelector({ setTracked(repo.trackedBranch ?? repo.branches.find((b) => b.isDefault)?.name ?? null) }, [repo.trackedBranch, repo.branches]) - // Nothing to offer until branches have synced. - if (repo.branches.length === 0) return null - - const filtered = query - ? repo.branches.filter((b) => b.name.toLowerCase().includes(query.toLowerCase())) - : repo.branches - + // Every hook runs before the early return: a new repo syncs in with zero branches. const [commit, saving] = useAsyncAction(async (name: string) => { const prev = tracked setTracked(name) @@ -728,6 +722,13 @@ function BranchSelector({ await onSelect(name).catch(() => setTracked(prev)) // revert on failure }) + // Nothing to offer until branches have synced. + if (repo.branches.length === 0) return null + + const filtered = query + ? repo.branches.filter((b) => b.name.toLowerCase().includes(query.toLowerCase())) + : repo.branches + function pick(name: string) { if (name === tracked) { setOpen(false) 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..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 - -
@@ -240,7 +247,12 @@ function WebAnalyticsPage() { size="xs" mono className="h-auto px-1.5 py-0.5 transition-colors hover:text-foreground" - render={