/** * @file Dashboard.tsx * @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions; a recent event's right-click opens its session's `SessionRowMenu`. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/dataScope` * - `../components/StatCard` * - `../components/AgentCard` * - `../components/StatusBadge` * - `../components/EmptyState` * - `../components/Tip` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `Dashboard` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Dashboard** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useState, useCallback, useSyncExternalStore, useMemo, useRef, lazy, Suspense, type ReactNode, } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, FolderOpen, Bot, Zap, DollarSign, Activity, ArrowRight, RefreshCw, GitBranch, ChevronDown, ChevronRight, CircleDot, Server, HardDrive, Plug, Cpu, BarChart3, ShieldCheck, Database, Search, Download, Clock, AlertCircle, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { useDataScope } from "../lib/dataScope"; import { StatCard } from "../components/StatCard"; import { AgentCard } from "../components/AgentCard"; import { useContextMenu } from "../components/ContextMenu"; import { SessionRowMenu } from "../components/SessionRowMenu"; import { NeedsYouStrip } from "../components/NeedsYouStrip"; import { RouteErrorBoundary } from "../components/RouteErrorBoundary"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { Tip } from "../components/Tip"; import { Skeleton } from "../components/Skeleton"; import { Select } from "../components/Select"; import { AiToolIcon } from "../components/AiToolIcon"; import { useAiTools } from "../hooks/useAiTools"; import { spendPace } from "../lib/burnRate"; import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange"; import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; import { statusFromEventType } from "../lib/event-grouping"; import type { BudgetStatus, Stats, Agent, DashboardEvent, WSMessage, WorkflowData, Session, Analytics as AnalyticsData, CostResult, } from "../lib/types"; interface SystemInfo { db: { path: string; size: number; counts: Record; pragmas: { journal_mode: string; synchronous: number; auto_vacuum: number; encoding: string; foreign_keys: number; busy_timeout: number; }; load_stats: { m5: number; m15: number; h1: number }; }; hooks: { installed: boolean; path: string; hooks: Record }; server: { uptime: number; node_version: string; platform: string; ws_connections: number; memory: { rss: number; heapTotal: number; heapUsed: number; external: number; /** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */ heapLimit?: number; }; cpu_load: number[]; arch: string; total_mem: number; free_mem: number; cpus: number; }; transcript_cache: { size: number; maxSize: number; hits: number; misses: number; keys: string[]; }; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } // ── Analytics tab (merged from the former standalone Analytics page) ───────── function ChartTooltip({ x, y, children }: { x: number; y: number; children: ReactNode }) { const nearRight = x > window.innerWidth - 200; return (
{children}
); } function useTooltip() { const [tooltip, setTooltip] = useState<{ x: number; y: number; content: ReactNode; } | null>(null); const show = (e: React.MouseEvent, content: ReactNode) => { setTooltip({ x: e.clientX, y: e.clientY, content }); }; const move = (e: React.MouseEvent) => { setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY }); }; const hide = () => setTooltip(null); const node = tooltip ? ( {tooltip.content} ) : null; return { show, move, hide, node }; } /** Heatmap cell fill: the page surface mixed toward the accent on a log * scale. Built from theme tokens so the ramp reads surface → accent in every * theme; the old fixed indigo ramp went near-black on light themes. Exported * for its test. */ export function cellColor(count: number, max: number) { if (count === 0) return "rgb(var(--surface-0))"; const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1); // Floor at 20% so a single event still reads as distinct from an empty day. const pct = Math.round(20 + 80 * t); return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`; } function Heatmap({ weeks }: { weeks: Array> }) { const { show, move, hide, node } = useTooltip(); const { t, i18n } = useTranslation(["analytics", "common"]); const locale = i18n.resolvedLanguage ?? i18n.language; const monthLabels = useMemo( () => Array.from({ length: 12 }, (_, month) => new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1)) ), [locale] ); const dayNames = useMemo( () => Array.from({ length: 7 }, (_, day) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(2026, 0, 4 + day) // Jan 4, 2026 is Sunday ) ), [locale] ); // Labels on the left: Sun (0), Tue (2), Thu (4) const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""]; const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1); // Compute month label positions accurately. A label is ~3 columns wide (each // column is 16px, a 3-letter month ≈ 22px). The very first month is often a // stub - the 52-week window starts mid-month, so e.g. "May" sits at col 0 and // "Jun" at col 1 and they overlap. Drop the *stub* (the earlier, partial one) // rather than the full month after it, so the next label isn't pushed away. const monthPositions = useMemo(() => { const MIN_LABEL_GAP = 3; // columns a label needs to itself const starts: Array<{ label: string; col: number }> = []; let prevMonth = -1; weeks.forEach((week, wi) => { const firstCell = week[0]; if (!firstCell) return; const parts = firstCell.date.split("-").map(Number); const m = (parts[1] || 1) - 1; // 0-indexed month if (m !== prevMonth) { starts.push({ label: monthLabels[m] ?? "", col: wi }); prevMonth = m; } }); // Drop a label when the *next* month starts too soon after it (it would // collide). Suppressing the earlier label leaves the later, full month in // place - no gap is introduced. return starts.filter((s, i) => { const next = starts[i + 1]; return !next || next.col - s.col >= MIN_LABEL_GAP; }); }, [weeks, monthLabels]); return (
{node} {/* Month labels */}
{monthPositions.map((mp, i) => (
{mp.label}
))}
{/* Day labels */}
{dayLabels.map((d, i) => (
{d}
))}
{/* Cells */} {weeks.map((week, wi) => (
{week.map((cell) => (
{ const parts = cell.date.split("-").map(Number); const y = parts[0] || 0; const m = (parts[1] || 1) - 1; const d = parts[2] || 1; const date = new Date(y, m, d, 12); const dow = date.getDay(); show( e, <> {dayNames[dow] ?? ""}, {cell.date} {t("eventCountLabel", { count: cell.count })} ); }} onMouseMove={move} onMouseLeave={hide} style={{ width: 13, height: 13, borderRadius: 2, backgroundColor: cellColor(cell.count, maxCount), border: "1px solid rgb(var(--border) / 0.4)", flexShrink: 0, cursor: "default", }} /> ))}
))}
{/* Legend */}
{t("less")} {[0, 0.25, 0.5, 0.75, 1].map((f) => { const v = Math.round(f * maxCount); return (
); })} {t("more")}
); } function AnalyticsSparkline({ data, color = "rgb(var(--accent))", }: { data: Array<{ date: string; count: number }>; color?: string; }) { const { t } = useTranslation("analytics"); const { show, move, hide, node } = useTooltip(); const max = Math.max(...data.map((d) => d.count), 1); return (
{node} {data.map(({ date, count }) => (
show( e, <> {date} {t("eventCountLabel", { count })} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } function CostTrendLine({ data, color = "rgb(var(--status-success))", }: { data: Array<{ date: string; cost: number }>; color?: string; }) { const { show, move, hide, node } = useTooltip(); if (data.length === 0) return null; const width = 320; const height = 88; const padX = 8; const padY = 8; const min = Math.min(...data.map((d) => d.cost), 0); const max = Math.max(...data.map((d) => d.cost), 0); const span = Math.max(max - min, 0.0001); const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0; const points = data.map(({ date, cost }, i) => { const x = padX + i * step; const y = height - padY - ((cost - min) / span) * (height - padY * 2); return { date, cost, x, y }; }); const linePoints = points.map((p) => `${p.x},${p.y}`).join(" "); const firstX = points[0]?.x ?? padX; const lastX = points[points.length - 1]?.x ?? padX; const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`; return (
{node} {points.map((point) => ( show( e, <> {point.date} {fmtCostFull(point.cost)} ) } onMouseMove={move} onMouseLeave={hide} /> ))}
); } function BarRow({ label, count, max, color = "bg-accent", pct, }: { label: string; count: number; max: number; color?: string; pct?: number; }) { const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0; return (
{label}
{fmt(count)}
); } function CostBarRow({ label, cost, max, color = "bg-status-success", }: { label: string; cost: number; max: number; color?: string; }) { const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0; return (
{label}
{fmtCost(cost)}
); } function DonutChart({ segments, formatTotal, }: { segments: Array<{ label: string; value: number; color: string }>; formatTotal?: (total: number) => string; }) { const { t } = useTranslation(["analytics", "common"]); const { show, move, hide, node } = useTooltip(); const total = segments.reduce((s, g) => s + g.value, 0); if (total === 0) return
{t("common:noData")}
; const r = 52; const cx = 64; const cy = 64; const stroke = 18; const circumference = 2 * Math.PI * r; // offset starts at circumference/4 (top of circle) and decrements by each segment's arc. // strokeDashoffset = offset (not negated) is the correct formula for starting at 12 o'clock. let offset = circumference / 4; return (
{node} {segments.map(({ label, value, color }, i) => { const dash = (value / total) * circumference; const gap = circumference - dash; const pct = Math.round((value / total) * 100); const currentOffset = offset; offset -= dash; return ( show( e, <> {label} {pct}% ) } onMouseMove={move} onMouseLeave={hide} /> ); })} {(formatTotal ?? fmt)(total)} {t("common:total_lower")}
{segments.map(({ label, value, color }) => (
{label} {Math.round((value / total) * 100)}%
))}
); } /** A single chart-card placeholder: title bar, chart body, two footer lines. * Uses the shared pulsing so the whole grid shimmers while data * loads (mirrors the chart-card layout so nothing jumps when data arrives). */ function ChartCardSkeleton({ className = "", bodyH = "h-32", }: { className?: string; bodyH?: string; }) { return (
); } /** Pulsing placeholder for the Analytics chart region (heatmap + sparkline row, * tab bar, and the three-up chart grid) shown while the analytics payload is * still being fetched. */ function AnalyticsChartsSkeleton() { return (
); } function AnalyticsTab() { const { t, i18n } = useTranslation("analytics"); const [data, setData] = useState(null); const [costData, setCostData] = useState(null); const [budgets, setBudgets] = useState([]); const [lastUpdate, setLastUpdate] = useState(new Date()); const [loading, setLoading] = useState(true); // `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`). // The URL is the state: `?sub=` is read on every render and written back. const [params, setParams] = useSearchParams(); const subParam = params.get("sub"); const analyticsSubTab: "tokens" | "cost" | "workflow" | "productivity" = subParam === "tokens" || subParam === "workflow" || subParam === "productivity" ? subParam : "cost"; const setAnalyticsSubTab = (sub: typeof analyticsSubTab) => setParams( (prev) => { const next = new URLSearchParams(prev); next.set("tab", "analytics"); if (sub === "cost") next.delete("sub"); else next.set("sub", sub); return next; }, { replace: true } ); // Global data scope; a change re-runs `load` (api injects the `sources` param). const [scope] = useDataScope(); // One AI tool's sessions only ("" = every tool); a change re-runs `load`. const [tool, setTool] = useState(""); const aiTools = useAiTools(); // The date range, kept in the URL (`?range=`, plus `from`/`to` when custom). // Headline numbers, cost/token cards and by-model/by-tool follow it; the // 52-week and 30-day charts keep their own windows. const preset = parsePreset(params.get("range")); const customFrom = params.get("from") ?? ""; const customTo = params.get("to") ?? ""; const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo }); const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]); const setRangeParam = (patch: Record) => setParams( (p) => { for (const [k, v] of Object.entries(patch)) { if (v) p.set(k, v); else p.delete(k); } return p; }, { replace: true } ); const [loadError, setLoadError] = useState(null); // Responses can land out of order (a slow poll after a fast refresh); only // the newest request may write state. const loadSeq = useRef(0); const load = useCallback(async () => { const seq = ++loadSeq.current; try { const [result, cost, budgetRes] = await Promise.all([ api.analytics.get({ tool, range }), api.pricing.totalCost({ tool, range }).catch(() => null), api.budgets.status().catch(() => null), ]); if (seq !== loadSeq.current) return; setData(result); setCostData(cost); setLoadError(null); if (budgetRes) setBudgets(budgetRes.budgets); setLastUpdate(new Date()); } catch (err) { if (seq === loadSeq.current) setLoadError(err instanceof Error ? err.message : t("dashboard:failedLoad")); } finally { if (seq === loadSeq.current) setLoading(false); } }, [scope, tool, range]); useEffect(() => { load(); }, [load]); useLiveRefresh(load, { intervalMs: 15000, match: (msg) => msg.type === "session_created" || msg.type === "session_updated" || msg.type === "new_event" || msg.type === "agent_created" || isRemoteDataRefreshMessage(msg), }); function handleExport() { if (!data) return; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } // Format a local Date object as a YYYY-MM-DD string for heatmap lookups function localDateStr(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // Server now returns dates grouped by the user's local timezone (via tz_offset), // so we can use them directly without conversion. const dailyMap: Record = {}; for (const d of data?.daily_events ?? []) { dailyMap[d.date] = (dailyMap[d.date] ?? 0) + d.count; } // Build heatmap: 52 weeks × 7 days // Align start to Sunday (day 0) so row indices match day-of-week labels correctly. const today = new Date(); today.setHours(12, 0, 0, 0); // Normalize to noon const startDate = new Date(today); startDate.setDate(today.getDate() - 364); // Exactly 52 weeks ago // Roll back to the previous Sunday const startDow = startDate.getDay(); startDate.setDate(startDate.getDate() - startDow); const weeks: Array> = []; for (let w = 0; w < 53; w++) { const week: Array<{ date: string; count: number }> = []; for (let d = 0; d < 7; d++) { const cell = new Date(startDate); cell.setDate(startDate.getDate() + w * 7 + d); if (cell > today) break; const dateStr = localDateStr(cell); week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 }); } if (week.length > 0) weeks.push(week); } // Last 30 days for sparkline const last30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, count: dailyMap[dateStr] ?? 0 }; }); // Convert daily_sessions - server already returns local dates const dailySessionsLocal: Array<{ date: string; count: number }> = []; const sessMap: Record = {}; for (const d of data?.daily_sessions ?? []) { sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count; } for (const [date, count] of Object.entries(sessMap)) { dailySessionsLocal.push({ date, count }); } dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date)); // Calendar days, zero-filled: "last 30/7 days" means days, not active days. const sessionsLast30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, count: sessMap[dateStr] ?? 0 }; }); const maxSessionsDay = Math.max(1, ...sessionsLast30.map((d) => d.count)); // Convert daily_costs - server already returns local dates const dailyCostsLocal: Array<{ date: string; cost: number }> = []; const costMap: Record = {}; for (const d of costData?.daily_costs ?? []) { costMap[d.date] = (costMap[d.date] ?? 0) + d.cost; } for (const [date, cost] of Object.entries(costMap)) { dailyCostsLocal.push({ date, cost }); } dailyCostsLocal.sort((a, b) => a.date.localeCompare(b.date)); const dailyCostLast30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (29 - i)); const dateStr = localDateStr(d); return { date: dateStr, cost: costMap[dateStr] ?? 0 }; }); const peakCostDay = dailyCostLast30.reduce( (max, curr) => (curr.cost > max.cost ? curr : max), dailyCostLast30[0] ?? { date: "", cost: 0 } ); const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0); const pace = spendPace(dailyCostsLocal, localDateStr(today)); const costBreakdown = [...(costData?.breakdown ?? [])] .filter((b) => b.cost > 0) .sort((a, b) => b.cost - a.cost); const locale = i18n.resolvedLanguage ?? i18n.language; const weekdayOrder = [1, 2, 3, 4, 5, 6, 0]; // 2026-01-04 is a Sunday; a LOCAL date, so the label never shifts a day // west of UTC. 30 days hold some weekdays 5 times and others 4, so the bar // is the average per occurrence, not the sum. const weekdayCosts = weekdayOrder.map((dow) => { const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format( new Date(2026, 0, 4 + dow, 12) ); const days = dailyCostLast30.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow); const cost = days.length ? days.reduce((sum, day) => sum + day.cost, 0) / days.length : 0; return { label, cost }; }); const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1); const totalTokens = (data?.tokens.total_input ?? 0) + (data?.tokens.total_output ?? 0) + (data?.tokens.total_cache_read ?? 0) + (data?.tokens.total_cache_write ?? 0); const tokenMixSegments = [ { label: t("common:token.input"), value: data?.tokens.total_input ?? 0, color: "rgb(var(--syn-builtin))", }, { label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "rgb(var(--status-success))", }, { label: t("common:token.cacheRead"), value: data?.tokens.total_cache_read ?? 0, color: "rgb(var(--syn-keyword))", }, { label: t("common:token.cacheWrite"), value: data?.tokens.total_cache_write ?? 0, color: "rgb(var(--status-warning))", }, ].filter((s) => s.value > 0); const maxToolCount = data?.tool_usage[0]?.count ?? 1; const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1; const maxEventTypeCount = data?.event_types[0]?.count ?? 1; // Hit rate = cache reads over every prompt token (fresh input + cache read + // cache write); output tokens are never cacheable. const promptTokens = (data?.tokens.total_input ?? 0) + (data?.tokens.total_cache_read ?? 0) + (data?.tokens.total_cache_write ?? 0); const cacheHitPct = promptTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / promptTokens) * 100) : 0; // The headline excludes cache reads, which dwarf everything else and cost a tenth. const nonCacheReadTokens = totalTokens - (data?.tokens.total_cache_read ?? 0); const sessionOutcomeSegments = [ { label: t("common:status.completed"), value: data?.sessions_by_status?.completed ?? 0, color: "rgb(var(--syn-keyword))", }, { label: t("common:status.active"), value: data?.sessions_by_status?.active ?? 0, color: "rgb(var(--status-success))", }, { label: t("common:status.error"), value: data?.sessions_by_status?.error ?? 0, color: "rgb(var(--status-danger))", }, { label: t("common:status.abandoned"), value: data?.sessions_by_status?.abandoned ?? 0, color: "rgb(var(--status-warning))", }, ].filter((s) => s.value > 0); const agentStatusSegments = [ { label: t("common:status.completed"), value: data?.agents_by_status?.completed ?? 0, color: "rgb(var(--syn-keyword))", }, { label: t("common:status.working"), value: data?.agents_by_status?.working ?? 0, color: "rgb(var(--status-success))", }, { label: t("common:status.waiting"), value: data?.agents_by_status?.waiting ?? 0, color: "rgb(var(--status-warning))", }, { label: t("common:status.error"), value: data?.agents_by_status?.error ?? 0, color: "rgb(var(--status-danger))", }, ].filter((s) => s.value > 0); const EVENT_TYPE_COLORS: Record = { PreToolUse: "bg-status-success", PostToolUse: "bg-syntax-builtin", Stop: "bg-syntax-keyword", SubagentStop: "bg-status-warning", Notification: "bg-syntax-number", }; return (
{/* Toolbar */}
{lastUpdate.toLocaleTimeString()}
setRangeParam({ from: e.target.value })} aria-label={t("rangeFrom")} title={t("rangeFrom")} /> setRangeParam({ to: e.target.value })} aria-label={t("rangeTo")} title={t("rangeTo")} /> )}