Skip to content

Commit 85e4218

Browse files
j-klesenclaude
andauthored
feat: Allow to filter on the Dashboard page (#1706)
* feat: implement dashboard filters component and integrate with dashboard page * factor out filterpopover into a shared component, reuse on Dashboard and LLM Logs page * feat: add data-testid attributes to FilterPopover for E2E tests Add data-testid="filters-trigger-button" on the popover trigger button, data-testid="filters-search-input" on the search input, and data-testid="filter-item-{category}-{value}" on each filter item. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add triggerTestId to DateTimePickerWithRange on dashboard Add triggerTestId="filter-date-range" to match the logs page convention, enabling E2E tests to target the date range picker on the dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle boolean filter values in dashboard handleFilterChange The handler only processed array values, silently dropping boolean filter changes like missing_cost_only. Add missing_cost_only to the URL key map and branch on typeof values to convert booleans to strings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract parseCsvParam helper to deduplicate URL state parsing Replace 8 near-identical useMemo hooks that parse comma-separated URL params with a shared parseCsvParam helper function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move dateToRfc3339Local to shared utility module Move dateToRfc3339Local from logs/views/filters.tsx to lib/utils/date.ts so other features can discover and reuse it. Keep a re-export from the original location for backwards compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: only apply lowercase to Status filter labels in FilterPopover The lowercase CSS class was applied unconditionally to all filter labels, which incorrectly lowercased model names and provider names. Restrict it to the Status category only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace loading placeholder strings with empty arrays and spinner FILTER_OPTIONS previously injected placeholder strings like "Loading providers..." as selectable option values. Replace with empty arrays while loading and render a per-group loading spinner instead, ensuring isSelected and onSelect never receive placeholder strings as values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: compute selectedCount once in FilterPopover getSelectedCount() was called twice in the JSX (condition check and display). Compute the value once before the return to avoid redundant iteration over the filters object. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract resolveValueForCategory helper in FilterPopover Deduplicate the name-to-ID resolution logic that was repeated in both handleFilterSelect and isSelected into a single resolveValueForCategory helper function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 87acb2c commit 85e4218

4 files changed

Lines changed: 292 additions & 228 deletions

File tree

ui/app/workspace/dashboard/page.tsx

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { dateUtils } from "@/lib/types/logs";
2020
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
2121
import { useCallback, useEffect, useMemo, useState } from "react";
2222
import { ChartCard } from "./components/chartCard";
23+
import { FilterPopover } from "@/components/filters/filterPopover";
2324
import { type ChartType, ChartTypeToggle } from "./components/chartTypeToggle";
2425
import { CostChart } from "./components/costChart";
2526
import { LogVolumeChart } from "./components/logVolumeChart";
@@ -91,6 +92,14 @@ export default function DashboardPage() {
9192
start_time: parseAsInteger.withDefault(DEFAULT_START_TIME),
9293
end_time: parseAsInteger.withDefault(DEFAULT_END_TIME),
9394
period: parseAsString.withDefault("24h"),
95+
virtual_key_ids: parseAsString.withDefault(""),
96+
providers: parseAsString.withDefault(""),
97+
models: parseAsString.withDefault(""),
98+
selected_key_ids: parseAsString.withDefault(""),
99+
objects: parseAsString.withDefault(""),
100+
status: parseAsString.withDefault(""),
101+
routing_rule_ids: parseAsString.withDefault(""),
102+
routing_engine_used: parseAsString.withDefault(""),
94103
volume_chart: parseAsString.withDefault("bar"),
95104
token_chart: parseAsString.withDefault("bar"),
96105
cost_chart: parseAsString.withDefault("bar"),
@@ -104,13 +113,34 @@ export default function DashboardPage() {
104113
},
105114
);
106115

116+
// Parse comma-separated URL param into a string array
117+
const parseCsvParam = (value: string): string[] => (value ? value.split(",").filter(Boolean) : []);
118+
119+
// Parse filter arrays from URL state
120+
const selectedProviders = useMemo(() => parseCsvParam(urlState.providers), [urlState.providers]);
121+
const selectedModels = useMemo(() => parseCsvParam(urlState.models), [urlState.models]);
122+
const selectedKeyIds = useMemo(() => parseCsvParam(urlState.selected_key_ids), [urlState.selected_key_ids]);
123+
const selectedVirtualKeyIds = useMemo(() => parseCsvParam(urlState.virtual_key_ids), [urlState.virtual_key_ids]);
124+
const selectedTypes = useMemo(() => parseCsvParam(urlState.objects), [urlState.objects]);
125+
const selectedStatuses = useMemo(() => parseCsvParam(urlState.status), [urlState.status]);
126+
const selectedRoutingRuleIds = useMemo(() => parseCsvParam(urlState.routing_rule_ids), [urlState.routing_rule_ids]);
127+
const selectedRoutingEngines = useMemo(() => parseCsvParam(urlState.routing_engine_used), [urlState.routing_engine_used]);
128+
107129
// Derived filter for API calls
108130
const filters: LogFilters = useMemo(
109131
() => ({
110132
start_time: dateUtils.toISOString(urlState.start_time),
111133
end_time: dateUtils.toISOString(urlState.end_time),
134+
...(selectedProviders.length > 0 && { providers: selectedProviders }),
135+
...(selectedModels.length > 0 && { models: selectedModels }),
136+
...(selectedKeyIds.length > 0 && { selected_key_ids: selectedKeyIds }),
137+
...(selectedVirtualKeyIds.length > 0 && { virtual_key_ids: selectedVirtualKeyIds }),
138+
...(selectedTypes.length > 0 && { objects: selectedTypes }),
139+
...(selectedStatuses.length > 0 && { status: selectedStatuses }),
140+
...(selectedRoutingRuleIds.length > 0 && { routing_rule_ids: selectedRoutingRuleIds }),
141+
...(selectedRoutingEngines.length > 0 && { routing_engine_used: selectedRoutingEngines }),
112142
}),
113-
[urlState.start_time, urlState.end_time],
143+
[urlState.start_time, urlState.end_time, selectedProviders, selectedModels, selectedKeyIds, selectedVirtualKeyIds, selectedTypes, selectedStatuses, selectedRoutingRuleIds, selectedRoutingEngines],
114144
);
115145

116146
// Date range for picker
@@ -203,6 +233,31 @@ export default function DashboardPage() {
203233
const handleCostChartToggle = useCallback((type: ChartType) => setUrlState({ cost_chart: type }), [setUrlState]);
204234
const handleModelChartToggle = useCallback((type: ChartType) => setUrlState({ model_chart: type }), [setUrlState]);
205235

236+
// Filter change handler for FilterPopover
237+
const handleFilterChange = useCallback(
238+
(key: keyof LogFilters, values: string[] | boolean) => {
239+
const urlKeyMap: Partial<Record<keyof LogFilters, string>> = {
240+
providers: "providers",
241+
models: "models",
242+
selected_key_ids: "selected_key_ids",
243+
virtual_key_ids: "virtual_key_ids",
244+
objects: "objects",
245+
status: "status",
246+
routing_rule_ids: "routing_rule_ids",
247+
routing_engine_used: "routing_engine_used",
248+
missing_cost_only: "missing_cost_only",
249+
};
250+
const urlKey = urlKeyMap[key];
251+
if (!urlKey) return;
252+
if (typeof values === "boolean") {
253+
setUrlState({ [urlKey]: String(values) });
254+
} else {
255+
setUrlState({ [urlKey]: values.join(",") });
256+
}
257+
},
258+
[setUrlState],
259+
);
260+
206261
// Model filter changes
207262
const handleCostModelChange = useCallback((model: string) => setUrlState({ cost_model: model }), [setUrlState]);
208263
const handleUsageModelChange = useCallback((model: string) => setUrlState({ usage_model: model }), [setUrlState]);
@@ -217,14 +272,18 @@ export default function DashboardPage() {
217272
BETA
218273
</Badge>
219274
</div>
220-
<DateTimePickerWithRange
221-
dateTime={dateRange}
222-
onDateTimeUpdate={handleDateRangeChange}
223-
preDefinedPeriods={TIME_PERIODS}
224-
predefinedPeriod={urlState.period || undefined}
225-
onPredefinedPeriodChange={handlePeriodChange}
226-
popupAlignment="end"
227-
/>
275+
<div className="flex items-center gap-2">
276+
<FilterPopover filters={filters} onFilterChange={handleFilterChange} />
277+
<DateTimePickerWithRange
278+
triggerTestId="filter-date-range"
279+
dateTime={dateRange}
280+
onDateTimeUpdate={handleDateRangeChange}
281+
preDefinedPeriods={TIME_PERIODS}
282+
predefinedPeriod={urlState.period || undefined}
283+
onPredefinedPeriodChange={handlePeriodChange}
284+
popupAlignment="end"
285+
/>
286+
</div>
228287
</div>
229288

230289
{/* Charts Grid */}

ui/app/workspace/logs/views/filters.tsx

Lines changed: 12 additions & 219 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,16 @@
11
import { Button } from "@/components/ui/button";
2-
import { Checkbox } from "@/components/ui/checkbox";
3-
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
2+
import { Command, CommandItem, CommandList } from "@/components/ui/command";
43
import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange";
4+
import { FilterPopover } from "@/components/filters/filterPopover";
55
import { Input } from "@/components/ui/input";
66
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
7-
import { RequestTypeLabels, RequestTypes, RoutingEngineUsedLabels, Statuses } from "@/lib/constants/logs";
8-
import { getErrorMessage, useGetAvailableFilterDataQuery, useGetProvidersQuery, useRecalculateLogCostsMutation } from "@/lib/store";
7+
import { getErrorMessage, useRecalculateLogCostsMutation } from "@/lib/store";
98
import type { LogFilters as LogFiltersType } from "@/lib/types/logs";
10-
import { cn } from "@/lib/utils";
11-
import { Calculator, Check, FilterIcon, MoreVertical, Pause, Play, Search } from "lucide-react";
9+
import { Calculator, MoreVertical, Pause, Play, Search } from "lucide-react";
1210
import { useCallback, useEffect, useRef, useState } from "react";
1311
import { toast } from "sonner";
1412

15-
/**
16-
* Converts a Date object to an RFC 3339 string with the local time zone offset.
17-
*
18-
* Example: 2025-11-19T12:23:19.421+05:30
19-
*
20-
* @param dateObj The Date object to convert (defaults to new Date() if null/undefined).
21-
* @returns The RFC 3339 formatted string with local offset.
22-
*/
23-
export function dateToRfc3339Local(dateObj?: Date): string {
24-
const now = dateObj instanceof Date ? dateObj : new Date();
25-
26-
// Helper function to pad single digits with a leading zero
27-
const pad = (num: number): string => (num < 10 ? "0" + num : String(num));
28-
29-
const Y = now.getFullYear();
30-
const M = pad(now.getMonth() + 1); // Month is 0-indexed (Jan=0)
31-
const D = pad(now.getDate());
32-
const H = pad(now.getHours());
33-
const m = pad(now.getMinutes());
34-
const S = pad(now.getSeconds());
35-
const ms = String(now.getMilliseconds()).padStart(3, "0");
36-
37-
// getTimezoneOffset() returns the difference in minutes from UTC for the local time.
38-
// The result is positive for time zones west of Greenwich and negative for those east.
39-
// We negate it to get the standard ISO/RFC sign convention (+ for East, - for West).
40-
const timezoneOffsetMinutes = -now.getTimezoneOffset();
41-
const sign = timezoneOffsetMinutes >= 0 ? "+" : "-";
42-
const absoluteOffset = Math.abs(timezoneOffsetMinutes);
43-
const offsetHours = pad(Math.floor(absoluteOffset / 60));
44-
const offsetMinutes = pad(absoluteOffset % 60);
45-
const rfc3339Local = `${Y}-${M}-${D}T${H}:${m}:${S}.${ms}${sign}${offsetHours}:${offsetMinutes}`;
46-
return rfc3339Local;
47-
}
13+
export { dateToRfc3339Local } from "@/lib/utils/date";
4814

4915
/** Predefined time periods for the logs date range picker (matches E2E test labels) */
5016
const LOG_TIME_PERIODS = [
@@ -90,7 +56,6 @@ interface LogFiltersProps {
9056
}
9157

9258
export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle, fetchLogs, fetchStats }: LogFiltersProps) {
93-
const [openFiltersPopover, setOpenFiltersPopover] = useState(false);
9459
const [openMoreActionsPopover, setOpenMoreActionsPopover] = useState(false);
9560
const [localSearch, setLocalSearch] = useState(filters.content_search || "");
9661
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -111,22 +76,6 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle
11176
const [startTime, setStartTime] = useState<Date | undefined>(filters.start_time ? new Date(filters.start_time) : undefined);
11277
const [endTime, setEndTime] = useState<Date | undefined>(filters.end_time ? new Date(filters.end_time) : undefined);
11378

114-
// Use RTK Query to fetch available models
115-
const { data: providersData, isLoading: providersLoading } = useGetProvidersQuery();
116-
const { data: filterData, isLoading: filterDataLoading } = useGetAvailableFilterDataQuery();
117-
118-
const availableProviders = providersData || [];
119-
const availableModels = filterData?.models || [];
120-
const availableSelectedKeys = filterData?.selected_keys || [];
121-
const availableVirtualKeys = filterData?.virtual_keys || [];
122-
const availableRoutingRules = filterData?.routing_rules || [];
123-
const availableRoutingEngines = filterData?.routing_engines || [];
124-
125-
// Create mappings from name to ID for keys and virtual keys
126-
const selectedKeyNameToId = new Map(availableSelectedKeys.map((key) => [key.name, key.id]));
127-
const virtualKeyNameToId = new Map(availableVirtualKeys.map((key) => [key.name, key.id]));
128-
const routingRuleNameToId = new Map(availableRoutingRules.map((rule) => [rule.name, rule.id]));
129-
13079
// Sync local date state when filters change from URL
13180
useEffect(() => {
13281
setStartTime(filters.start_time ? new Date(filters.start_time) : undefined);
@@ -174,94 +123,12 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle
174123
[onFiltersChange],
175124
);
176125

177-
const handleFilterSelect = (category: keyof typeof FILTER_OPTIONS, value: string) => {
178-
const filterKeyMap: Record<keyof typeof FILTER_OPTIONS, keyof LogFiltersType> = {
179-
Status: "status",
180-
Providers: "providers",
181-
Type: "objects",
182-
Models: "models",
183-
"Selected Keys": "selected_key_ids",
184-
"Virtual Keys": "virtual_key_ids",
185-
"Routing Rules": "routing_rule_ids",
186-
"Routing Engines": "routing_engine_used",
187-
};
188-
189-
const filterKey = filterKeyMap[category];
190-
let valueToStore = value;
191-
192-
// Convert name to ID for keys and virtual keys
193-
if (category === "Selected Keys") {
194-
valueToStore = selectedKeyNameToId.get(value) || value;
195-
} else if (category === "Virtual Keys") {
196-
valueToStore = virtualKeyNameToId.get(value) || value;
197-
} else if (category === "Routing Rules") {
198-
valueToStore = routingRuleNameToId.get(value) || value;
199-
}
200-
201-
const currentValues = (filters[filterKey] as string[]) || [];
202-
const newValues = currentValues.includes(valueToStore)
203-
? currentValues.filter((v) => v !== valueToStore)
204-
: [...currentValues, valueToStore];
205-
206-
onFiltersChange({
207-
...filters,
208-
[filterKey]: newValues,
209-
});
210-
};
211-
212-
const isSelected = (category: keyof typeof FILTER_OPTIONS, value: string) => {
213-
const filterKeyMap: Record<keyof typeof FILTER_OPTIONS, keyof LogFiltersType> = {
214-
Status: "status",
215-
Providers: "providers",
216-
Type: "objects",
217-
Models: "models",
218-
"Selected Keys": "selected_key_ids",
219-
"Virtual Keys": "virtual_key_ids",
220-
"Routing Rules": "routing_rule_ids",
221-
"Routing Engines": "routing_engine_used",
222-
};
223-
224-
const filterKey = filterKeyMap[category];
225-
const currentValues = filters[filterKey];
226-
227-
// For keys and virtual keys, convert name to ID before checking
228-
let valueToCheck = value;
229-
if (category === "Selected Keys") {
230-
valueToCheck = selectedKeyNameToId.get(value) || value;
231-
} else if (category === "Virtual Keys") {
232-
valueToCheck = virtualKeyNameToId.get(value) || value;
233-
} else if (category === "Routing Rules") {
234-
valueToCheck = routingRuleNameToId.get(value) || value;
235-
}
236-
237-
return Array.isArray(currentValues) && currentValues.includes(valueToCheck);
238-
};
239-
240-
const getSelectedCount = () => {
241-
// Exclude timestamp filters and content_search from the count
242-
const excludedKeys = ["start_time", "end_time", "content_search"];
243-
244-
return Object.entries(filters).reduce((count, [key, value]) => {
245-
if (excludedKeys.includes(key)) {
246-
return count;
247-
}
248-
if (Array.isArray(value)) {
249-
return count + value.length;
250-
}
251-
return count + (value ? 1 : 0);
252-
}, 0);
253-
};
254-
255-
const FILTER_OPTIONS = {
256-
Status: Statuses,
257-
Providers: providersLoading ? ["Loading providers..."] : availableProviders.map((provider) => provider.name),
258-
Type: RequestTypes,
259-
Models: filterDataLoading ? ["Loading models..."] : availableModels,
260-
"Selected Keys": filterDataLoading ? ["Loading selected keys..."] : availableSelectedKeys.map((key) => key.name),
261-
"Virtual Keys": filterDataLoading ? ["Loading virtual keys..."] : availableVirtualKeys.map((key) => key.name),
262-
"Routing Engines": filterDataLoading ? ["Loading routing engines..."] : availableRoutingEngines,
263-
"Routing Rules": filterDataLoading ? ["Loading routing rules..."] : availableRoutingRules.map((rule) => rule.name),
264-
} as const;
126+
const handleFilterChange = useCallback(
127+
(key: keyof LogFiltersType, values: string[] | boolean) => {
128+
onFiltersChange({ ...filters, [key]: values });
129+
},
130+
[filters, onFiltersChange],
131+
);
265132

266133
return (
267134
<div className="flex items-center justify-between space-x-2">
@@ -317,81 +184,7 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle
317184
});
318185
}}
319186
/>
320-
<Popover open={openFiltersPopover} onOpenChange={setOpenFiltersPopover}>
321-
<PopoverTrigger asChild>
322-
<Button variant="outline" size="sm" className="h-7.5 w-[120px]">
323-
<FilterIcon className="h-4 w-4" />
324-
Filters
325-
{getSelectedCount() > 0 && (
326-
<span className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded-full text-xs font-normal">
327-
{getSelectedCount()}
328-
</span>
329-
)}
330-
</Button>
331-
</PopoverTrigger>
332-
<PopoverContent className="w-[200px] p-0" align="end">
333-
<Command>
334-
<CommandInput placeholder="Search filters..." />
335-
<CommandList>
336-
<CommandEmpty>No filters found.</CommandEmpty>
337-
<CommandGroup>
338-
<CommandItem className="cursor-pointer">
339-
<Checkbox
340-
className={cn(
341-
"border-primary opacity-50",
342-
filters.missing_cost_only && "bg-primary text-primary-foreground opacity-100",
343-
)}
344-
id="missing-cost-toggle"
345-
checked={!!filters.missing_cost_only}
346-
onCheckedChange={(checked: boolean) => onFiltersChange({ ...filters, missing_cost_only: checked })}
347-
/>
348-
<span className="text-sm">Show missing cost</span>
349-
</CommandItem>
350-
</CommandGroup>
351-
{Object.entries(FILTER_OPTIONS)
352-
.filter(([_, values]) => values.length > 0)
353-
.map(([category, values]) => (
354-
<CommandGroup key={category} heading={category}>
355-
{values.map((value: string) => {
356-
const selected = isSelected(category as keyof typeof FILTER_OPTIONS, value);
357-
const isLoading =
358-
(category === "Providers" && providersLoading) ||
359-
(category === "Models" && filterDataLoading) ||
360-
(category === "Selected Keys" && filterDataLoading) ||
361-
(category === "Virtual Keys" && filterDataLoading) ||
362-
(category === "Routing Rules" && filterDataLoading) ||
363-
(category === "Routing Engines" && filterDataLoading);
364-
return (
365-
<CommandItem
366-
key={value}
367-
onSelect={() => !isLoading && handleFilterSelect(category as keyof typeof FILTER_OPTIONS, value)}
368-
disabled={isLoading}
369-
>
370-
<div
371-
className={cn(
372-
"border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
373-
selected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible",
374-
)}
375-
>
376-
{isLoading ? (
377-
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent" />
378-
) : (
379-
<Check className="text-primary-foreground size-3" />
380-
)}
381-
</div>
382-
<span className={cn("lowercase", isLoading && "text-muted-foreground")}>
383-
{category === "Type" ? RequestTypeLabels[value as keyof typeof RequestTypeLabels] :
384-
category === "Routing Engines" ? (RoutingEngineUsedLabels[value as keyof typeof RoutingEngineUsedLabels] ?? value) : value}
385-
</span>
386-
</CommandItem>
387-
);
388-
})}
389-
</CommandGroup>
390-
))}
391-
</CommandList>
392-
</Command>
393-
</PopoverContent>
394-
</Popover>
187+
<FilterPopover filters={filters} onFilterChange={handleFilterChange} showMissingCost />
395188
<Popover open={openMoreActionsPopover} onOpenChange={setOpenMoreActionsPopover}>
396189
<PopoverTrigger asChild>
397190
<Button variant="outline" size="sm" className="h-7.5">

0 commit comments

Comments
 (0)