diff --git a/ui/app/workspace/dashboard/page.tsx b/ui/app/workspace/dashboard/page.tsx index ffc37226db..dbac77de44 100644 --- a/ui/app/workspace/dashboard/page.tsx +++ b/ui/app/workspace/dashboard/page.tsx @@ -1,5 +1,6 @@ -import { FilterPopover } from "@/components/filters/filterPopover"; +import { LogsSidebar } from "@/app/workspace/logs/views/logsSidebar"; import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; +import { ScrollArea } from "@/components/ui/scrollArea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useGetMCPAvailableFilterDataQuery, @@ -55,15 +56,6 @@ const DEFAULT_START_TIME = (() => { return Math.floor(date.getTime() / 1000); })(); -// Predefined time periods -const TIME_PERIODS = [ - { label: "Last hour", value: "1h" }, - { label: "Last 6 hours", value: "6h" }, - { label: "Last 24 hours", value: "24h" }, - { label: "Last 7 days", value: "7d" }, - { label: "Last 30 days", value: "30d" }, -]; - const parseCsvParam = (value: string): string[] => (value ? value.split(",").filter(Boolean) : []); const sanitizeSeriesLabels = (values?: string[]): string[] => { if (!values) return []; @@ -72,24 +64,6 @@ const sanitizeSeriesLabels = (values?: string[]): string[] => { return [...new Set(trimmedValues)]; }; -function getTimeRangeFromPeriod(period: string): { start: number; end: number } { - const now = Math.floor(Date.now() / 1000); - switch (period) { - case "1h": - return { start: now - 3600, end: now }; - case "6h": - return { start: now - 6 * 3600, end: now }; - case "24h": - return { start: now - 24 * 3600, end: now }; - case "7d": - return { start: now - 7 * 24 * 3600, end: now }; - case "30d": - return { start: now - 30 * 24 * 3600, end: now }; - default: - return { start: now - 24 * 3600, end: now }; - } -} - export default function DashboardPage() { // Data states - Overview const [histogramData, setHistogramData] = useState(null); @@ -244,15 +218,6 @@ export default function DashboardPage() { [urlState.start_time, urlState.end_time, selectedMcpToolNames, selectedMcpServerLabels], ); - // Date range for picker - const dateRange = useMemo( - () => ({ - from: dateUtils.fromUnixTimestamp(urlState.start_time), - to: dateUtils.fromUnixTimestamp(urlState.end_time), - }), - [urlState.start_time, urlState.end_time], - ); - // Model lists for each chart's legend (must match what the chart component actually renders) const costModels = useMemo(() => sanitizeSeriesLabels(costData?.models), [costData?.models]); const usageModels = useMemo(() => sanitizeSeriesLabels(modelData?.models), [modelData?.models]); @@ -496,33 +461,6 @@ export default function DashboardPage() { return () => window.clearTimeout(timeoutId); }, [urlState.tab, ensureOverviewDataLoaded, ensureProviderDataLoaded, ensureMcpDataLoaded, ensureRankingsDataLoaded]); - // Handle time period change - const handlePeriodChange = useCallback( - (period: string | undefined) => { - if (!period) return; - const { start, end } = getTimeRangeFromPeriod(period); - setUrlState({ - start_time: start, - end_time: end, - period, - }); - }, - [setUrlState], - ); - - // Handle custom date range change - const handleDateRangeChange = useCallback( - (range: { from?: Date; to?: Date }) => { - if (!range.from || !range.to) return; - setUrlState({ - start_time: dateUtils.toUnixTimestamp(range.from), - end_time: dateUtils.toUnixTimestamp(range.to), - period: "", // Clear period when custom range is selected - }); - }, - [setUrlState], - ); - // Tab change handler const handleTabChange = useCallback( (tab: string) => { @@ -538,29 +476,27 @@ export default function DashboardPage() { const handleModelChartToggle = useCallback((type: ChartType) => setUrlState({ model_chart: type }), [setUrlState]); const handleLatencyChartToggle = useCallback((type: ChartType) => setUrlState({ latency_chart: type }), [setUrlState]); - // Filter change handler for FilterPopover - const handleFilterChange = useCallback( - (key: keyof LogFilters, values: string[] | boolean | string) => { - const urlKeyMap: Partial> = { - providers: "providers", - models: "models", - selected_key_ids: "selected_key_ids", - virtual_key_ids: "virtual_key_ids", - objects: "objects", - status: "status", - routing_rule_ids: "routing_rule_ids", - routing_engine_used: "routing_engine_used", - missing_cost_only: "missing_cost_only", - }; - const urlKey = urlKeyMap[key]; - if (!urlKey) return; - if (typeof values === "boolean") { - setUrlState({ [urlKey]: String(values) }); - } else if (typeof values === "string") { - setUrlState({ [urlKey]: values }); - } else { - setUrlState({ [urlKey]: values.join(",") }); - } + // Adapter: converts a full LogFilters object to dashboard's CSV-based URL state + const setFilters = useCallback( + (newFilters: LogFilters) => { + setUrlState({ + start_time: newFilters.start_time ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time)) : undefined, + end_time: newFilters.end_time ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time)) : undefined, + period: "", // Clear period when filters change (custom range) + providers: (newFilters.providers || []).join(","), + models: (newFilters.models || []).join(","), + selected_key_ids: (newFilters.selected_key_ids || []).join(","), + virtual_key_ids: (newFilters.virtual_key_ids || []).join(","), + objects: (newFilters.objects || []).join(","), + status: (newFilters.status || []).join(","), + routing_rule_ids: (newFilters.routing_rule_ids || []).join(","), + routing_engine_used: (newFilters.routing_engine_used || []).join(","), + missing_cost_only: String(newFilters.missing_cost_only ?? false), + metadata_filters: + newFilters.metadata_filters && Object.keys(newFilters.metadata_filters).length > 0 + ? JSON.stringify(newFilters.metadata_filters) + : "", + }); }, [setUrlState], ); @@ -704,196 +640,192 @@ export default function DashboardPage() { }, []); return ( -
- {/* Header with time filter */} -
-
-

Dashboard

-
-
- - {(urlState.tab === "overview" || urlState.tab === "provider-usage" || urlState.tab === "rankings") && ( - - )} - {urlState.tab === "mcp" && mcpFilterData && ( -
- {mcpFilterData.tool_names?.length > 0 && ( - { - if (value === "all") { - setUrlState({ mcp_tool_names: "" }); - } else { - setUrlState({ mcp_tool_names: value }); - } - }} - placeholder="All Tools" - data-testid="dashboard-mcp-tool-filter" - /> - )} - {mcpFilterData.server_labels?.length > 0 && ( - { - if (value === "all") { - setUrlState({ mcp_server_labels: "" }); - } else { - setUrlState({ mcp_server_labels: value }); - } - }} - placeholder="All Servers" - data-testid="dashboard-mcp-server-filter" - /> - )} -
- )} - -
-
- - {/* Tabs */} - - - - Overview - - - Provider Usage - - - Model Rankings - - - MCP usage - - - User Rankings - - - - {/* Overview Tab */} - -
- -
-
- - {/* Provider Usage Tab */} - -
- +
+ {/* Sidebar Filters */} + + + {/* Main Content */} + + {/* Header */} +
+
+

Dashboard

- - - {/* Model Rankings Tab */} - -
- + + {urlState.tab === "mcp" && mcpFilterData && ( +
+ {mcpFilterData.tool_names?.length > 0 && ( + { + if (value === "all") { + setUrlState({ mcp_tool_names: "" }); + } else { + setUrlState({ mcp_tool_names: value }); + } + }} + placeholder="All Tools" + data-testid="dashboard-mcp-tool-filter" + /> + )} + {mcpFilterData.server_labels?.length > 0 && ( + { + if (value === "all") { + setUrlState({ mcp_server_labels: "" }); + } else { + setUrlState({ mcp_server_labels: value }); + } + }} + placeholder="All Servers" + data-testid="dashboard-mcp-server-filter" + /> + )} +
+ )}
-
- - {/* MCP Tab */} - -
- -
-
+
- {/* User Rankings Tab (Enterprise) */} - - - - +
+ {/* Tabs */} + + + + Overview + + + Provider Usage + + + Model Rankings + + + MCP usage + + + User Rankings + + + + {/* Overview Tab */} + +
+ +
+
+ + {/* Provider Usage Tab */} + +
+ +
+
+ + {/* Model Rankings Tab */} + +
+ +
+
+ + {/* MCP Tab */} + +
+ +
+
+ + {/* User Rankings Tab (Enterprise) */} + + + +
+
+
); } \ No newline at end of file diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx index 80bd995486..e65cef71ea 100644 --- a/ui/app/workspace/logs/page.tsx +++ b/ui/app/workspace/logs/page.tsx @@ -2,6 +2,7 @@ import { LogDetailSheet } from "@/app/workspace/logs/sheets/logDetailsSheet"; import { SessionDetailsSheet } from "@/app/workspace/logs/sheets/sessionDetailsSheet"; import { createColumns } from "@/app/workspace/logs/views/columns"; import { EmptyState } from "@/app/workspace/logs/views/emptyState"; +import { LogsSidebar } from "@/app/workspace/logs/views/logsSidebar"; import { LogsDataTable } from "@/app/workspace/logs/views/logsTable"; import { LogsVolumeChart } from "@/app/workspace/logs/views/logsVolumeChart"; import FullPageLoader from "@/components/fullPageLoader"; @@ -224,12 +225,12 @@ export default function LogsPage() { missing_cost_only: urlState.missing_cost_only, metadata_filters: urlState.metadata_filters ? (() => { - try { - return JSON.parse(urlState.metadata_filters); - } catch { - return undefined; - } - })() + try { + return JSON.parse(urlState.metadata_filters); + } catch { + return undefined; + } + })() : undefined, }), // Only re-derive filters when filter-related URL params change (not pagination) @@ -294,7 +295,7 @@ export default function LogsPage() { content_search: newFilters.content_search || "", start_time: newFilters.start_time ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time)) : undefined, end_time: newFilters.end_time ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time)) : undefined, - missing_cost_only: newFilters.missing_cost_only ?? filters.missing_cost_only ?? false, + missing_cost_only: newFilters.missing_cost_only ?? false, metadata_filters: newFilters.metadata_filters ? JSON.stringify(newFilters.metadata_filters) : "", offset: 0, }); @@ -825,7 +826,8 @@ export default function LogsPage() { title: "Success Rate", value: fetchingStats ? : stats ? `${stats.success_rate.toFixed(2)}%` : "-", icon: , - description: "Success rate as perceived by the system. Each fallback counts as a separate attempt. Retries on the same request are counted as one attempt.", + description: + "Success rate as perceived by the system. Each fallback counts as a separate attempt. Retries on the same request are counted as one attempt.", }, { title: "User Success Rate", @@ -910,14 +912,18 @@ export default function LogsPage() { ); return ( -
+
{initialLoading ? ( ) : showEmptyState ? ( ) : ( -
-
+
+ {/* Sidebar Filters */} + + + {/* Main Content */} +
{statCards.map((card) => ( @@ -990,6 +996,7 @@ export default function LogsPage() { isSocketConnected={isSocketConnected} fetchLogs={fetchLogs} fetchStats={fetchStats} + sidebarFilters />
diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 338688b876..4d52f726df 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -33,8 +33,8 @@ import { } from "@/lib/constants/logs"; import { LogEntry } from "@/lib/types/logs"; import { Link } from "@tanstack/react-router"; -import { Clipboard, Loader2, MoreVertical, Trash2 } from "lucide-react"; import { addMilliseconds, format } from "date-fns"; +import { Clipboard, Loader2, MoreVertical, Trash2 } from "lucide-react"; import type { ReactNode } from "react"; import { toast } from "sonner"; import BlockHeader from "../views/blockHeader"; diff --git a/ui/app/workspace/logs/views/emptyState.tsx b/ui/app/workspace/logs/views/emptyState.tsx index 1f0d0082d3..036cb87af5 100644 --- a/ui/app/workspace/logs/views/emptyState.tsx +++ b/ui/app/workspace/logs/views/emptyState.tsx @@ -3,8 +3,8 @@ import { Button } from "@/components/ui/button"; import { CodeEditor } from "@/components/ui/codeEditor"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getExampleBaseUrl } from "@/lib/utils/port"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { getExampleBaseUrl } from "@/lib/utils/port"; import { AlertTriangle, Copy } from "lucide-react"; import { useMemo, useState } from "react"; @@ -251,7 +251,7 @@ const result = await chain.invoke({ input: "What is LangChain?" });`, )} -
+

Integrate under 60 seconds

diff --git a/ui/app/workspace/logs/views/filters.tsx b/ui/app/workspace/logs/views/filters.tsx index 68df53c49d..8ec7ad7318 100644 --- a/ui/app/workspace/logs/views/filters.tsx +++ b/ui/app/workspace/logs/views/filters.tsx @@ -53,9 +53,19 @@ interface LogFiltersProps { onLiveToggle: (enabled: boolean) => void; fetchLogs: () => Promise; fetchStats: () => Promise; + /** When true, hide FilterPopover and DateTimePicker (they live in the sidebar instead) */ + hidePopoverFilters?: boolean; } -export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle, fetchLogs, fetchStats }: LogFiltersProps) { +export function LogFilters({ + filters, + onFiltersChange, + liveEnabled, + onLiveToggle, + fetchLogs, + fetchStats, + hidePopoverFilters, +}: LogFiltersProps) { const [openMoreActionsPopover, setOpenMoreActionsPopover] = useState(false); const [localSearch, setLocalSearch] = useState(filters.content_search || ""); const searchTimeoutRef = useRef(undefined); @@ -147,7 +157,7 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle ); return ( -
+
- { - setStartTime(p.from); - setEndTime(p.to); - onFiltersChange({ - ...filters, - start_time: p.from?.toISOString(), - end_time: p.to?.toISOString(), - }); - }} - preDefinedPeriods={LOG_TIME_PERIODS} - onPredefinedPeriodChange={(periodValue) => { - if (!periodValue) return; - const { from, to } = getRangeForPeriod(periodValue); - setStartTime(from); - setEndTime(to); - onFiltersChange({ - ...filters, - start_time: from.toISOString(), - end_time: to.toISOString(), - }); - }} - /> - + {!hidePopoverFilters && ( + <> + { + setStartTime(p.from); + setEndTime(p.to); + onFiltersChange({ + ...filters, + start_time: p.from?.toISOString(), + end_time: p.to?.toISOString(), + }); + }} + preDefinedPeriods={LOG_TIME_PERIODS} + onPredefinedPeriodChange={(periodValue) => { + if (!periodValue) return; + const { from, to } = getRangeForPeriod(periodValue); + setStartTime(from); + setEndTime(to); + onFiltersChange({ + ...filters, + start_time: from.toISOString(), + end_time: to.toISOString(), + }); + }} + /> + + + )} - diff --git a/ui/app/workspace/logs/views/logsSidebar.tsx b/ui/app/workspace/logs/views/logsSidebar.tsx new file mode 100644 index 0000000000..8faaf1f9d8 --- /dev/null +++ b/ui/app/workspace/logs/views/logsSidebar.tsx @@ -0,0 +1,735 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scrollArea"; +import { Skeleton } from "@/components/ui/skeleton"; +import { RequestTypeLabels, RequestTypes, RoutingEngineUsedLabels, Statuses } from "@/lib/constants/logs"; +import { useGetAvailableFilterDataQuery, useGetProvidersQuery } from "@/lib/store"; +import type { LogFilters } from "@/lib/types/logs"; +import { cn } from "@/lib/utils"; +import { ChevronDown, RotateCcw } from "lucide-react"; +import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +// --------------------------------------------------------------------------- +// LogsSidebar – orchestrator +// --------------------------------------------------------------------------- + +interface LogsSidebarProps { + filters: LogFilters; + onFiltersChange: (filters: LogFilters) => void; +} + +export function LogsSidebar({ filters, onFiltersChange }: LogsSidebarProps) { + const activeFilterCount = useMemo(() => { + const excludedKeys = ["start_time", "end_time", "content_search", "metadata_filters"]; + let count = Object.entries(filters).reduce((c, [key, value]) => { + if (excludedKeys.includes(key)) return c; + if (Array.isArray(value)) return c + value.length; + return c + (value ? 1 : 0); + }, 0); + if (filters.metadata_filters) { + count += Object.keys(filters.metadata_filters).length; + } + return count; + }, [filters]); + + const handleReset = useCallback(() => { + onFiltersChange({ + start_time: filters.start_time, + end_time: filters.end_time, + }); + }, [filters.start_time, filters.end_time, onFiltersChange]); + + return ( +
+ {/* Header */} +
+ Filters + {activeFilterCount > 0 && ( + + )} +
+ + {/* Scrollable filter sections */} + +
+ {/* First 2 open by default */} + + + {/* Rest closed unless they have active filters */} + + + + + + + + + + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared helpers & primitives +// --------------------------------------------------------------------------- + +function groupByName(items: { name: string; id: string }[]) { + const map = new Map(); + for (const item of items) { + const ids = map.get(item.name) || []; + ids.push(item.id); + map.set(item.name, ids); + } + return map; +} + +function dedup(items: { name: string }[]) { + return [...new Map(items.map((i) => [i.name, i])).values()].map((i) => i.name); +} + +/** Shared props every individual filter component receives. */ +interface FilterComponentProps { + filters: LogFilters; + onFiltersChange: (filters: LogFilters) => void; + defaultOpen?: boolean; +} + +// --------------------------------------------------------------------------- +// FilterSection – collapsible wrapper +// --------------------------------------------------------------------------- + +function FilterSectionSkeleton({ rows = 3 }: { rows?: number }) { + return ( + <> + {Array.from({ length: rows }).map((_, i) => ( +
+ + +
+ ))} + + ); +} + +function FilterSection({ + title, + children, + defaultOpen = false, + loading = false, + onOpenChange, + testId, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + loading?: boolean; + onOpenChange?: (open: boolean) => void; + testId?: string; +}) { + const [open, setOpen] = useState(defaultOpen); + + // Force open when defaultOpen flips to true (e.g. a filter in this section becomes active) + useEffect(() => { + if (defaultOpen) setOpen(true); + }, [defaultOpen]); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }; + + return ( + + + + {title} + + +
{loading ? : children}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// CheckboxFilterItem – single checkbox row +// --------------------------------------------------------------------------- + +function CheckboxFilterItem({ + label, + checked, + onCheckedChange, + labelClassName, + testId, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + labelClassName?: string; + testId?: string; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// SearchableCheckboxList – list of checkbox rows with a search input. +// Caller passes `inputRef` to control focus (see `useAutoFocusOnOpen`). +// --------------------------------------------------------------------------- + +function useAutoFocusOnOpen(isOpen: boolean) { + const ref = useRef(null); + useEffect(() => { + if (isOpen) ref.current?.focus({ preventScroll: true }); + }, [isOpen]); + return ref; +} + +function SearchableCheckboxList({ + items, + isSelected, + onToggle, + placeholder = "Search...", + inputRef, + testIdPrefix, +}: { + items: { key: string; label: string }[]; + isSelected: (key: string) => boolean; + onToggle: (key: string) => void; + placeholder?: string; + inputRef?: Ref; + testIdPrefix?: string; +}) { + const [query, setQuery] = useState(""); + const normalized = query.trim().toLowerCase(); + const filtered = normalized ? items.filter((item) => item.label.toLowerCase().includes(normalized)) : items; + + return ( + <> +
+ setQuery(e.target.value)} + placeholder={placeholder} + className="h-8 border-0 text-xs" + data-testid={testIdPrefix ? `${testIdPrefix}-search` : undefined} + /> +
+ {filtered.map((item) => ( + onToggle(item.key)} + testId={testIdPrefix ? `${testIdPrefix}-checkbox-${item.key}` : undefined} + /> + ))} + {filtered.length === 0 &&
No results
} + + ); +} + +// --------------------------------------------------------------------------- +// StatusFilter +// --------------------------------------------------------------------------- + +function StatusFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.status || []).length > 0; + return ( + + {Statuses.map((status) => ( + { + const current = filters.status || []; + const next = current.includes(status) ? current.filter((s) => s !== status) : [...current, status]; + onFiltersChange({ ...filters, status: next }); + }} + testId={`status-filter-checkbox-${status}`} + /> + ))} + + ); +} + +// --------------------------------------------------------------------------- +// ProvidersFilter – fetches providers internally +// --------------------------------------------------------------------------- + +function ProvidersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.providers || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: providersData, isUninitialized, isLoading } = useGetProvidersQuery(undefined, { skip: !opened && !hasActive }); + const availableProviders = providersData || []; + + // Hide only if data was fetched (not loading) and came back empty + if (!isUninitialized && !isLoading && availableProviders.length === 0 && !hasActive) return null; + + return ( + + ({ key: p.name, label: p.name }))} + isSelected={(name) => (filters.providers || []).includes(name)} + onToggle={(name) => { + const current = filters.providers || []; + const next = current.includes(name) ? current.filter((p) => p !== name) : [...current, name]; + onFiltersChange({ ...filters, providers: next }); + }} + testIdPrefix="providers-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// TypeFilter +// --------------------------------------------------------------------------- + +function TypeFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.objects || []).length > 0; + return ( + + {RequestTypes.map((type) => { + const label = RequestTypeLabels[type as keyof typeof RequestTypeLabels] ?? type; + return ( + { + const current = filters.objects || []; + const next = current.includes(type) ? current.filter((t) => t !== type) : [...current, type]; + onFiltersChange({ ...filters, objects: next }); + }} + testId={`type-filter-checkbox-${type}`} + /> + ); + })} + + ); +} + +// --------------------------------------------------------------------------- +// ModelsFilter – fetches available models internally +// --------------------------------------------------------------------------- + +function ModelsFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.models || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableModels = filterData?.models || []; + + if (!isUninitialized && !isLoading && availableModels.length === 0 && !hasActive) return null; + + return ( + + ({ key: m, label: m }))} + isSelected={(model) => (filters.models || []).includes(model)} + onToggle={(model) => { + const current = filters.models || []; + const next = current.includes(model) ? current.filter((m) => m !== model) : [...current, model]; + onFiltersChange({ ...filters, models: next }); + }} + testIdPrefix="models-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// AliasesFilter – fetches available aliases internally +// --------------------------------------------------------------------------- + +function AliasesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.aliases || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableAliases = filterData?.aliases || []; + + if (!isUninitialized && !isLoading && availableAliases.length === 0 && !hasActive) return null; + + return ( + + ({ key: a, label: a }))} + isSelected={(alias) => (filters.aliases || []).includes(alias)} + onToggle={(alias) => { + const current = filters.aliases || []; + const next = current.includes(alias) ? current.filter((a) => a !== alias) : [...current, alias]; + onFiltersChange({ ...filters, aliases: next }); + }} + testIdPrefix="aliases-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// SelectedKeysFilter – fetches keys, resolves name→IDs for deduplication +// --------------------------------------------------------------------------- + +function SelectedKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.selected_key_ids || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableSelectedKeys = filterData?.selected_keys || []; + const nameToIds = useMemo(() => groupByName(availableSelectedKeys), [availableSelectedKeys]); + + if (!isUninitialized && !isLoading && availableSelectedKeys.length === 0 && !hasActive) return null; + + const toggle = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.selected_key_ids || []; + const allSelected = resolvedIds.every((id) => current.includes(id)); + const next = allSelected + ? current.filter((v) => !resolvedIds.includes(v)) + : [...current, ...resolvedIds.filter((id) => !current.includes(id))]; + onFiltersChange({ ...filters, selected_key_ids: next }); + }; + + const isSelected = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.selected_key_ids || []; + return resolvedIds.every((id) => current.includes(id)); + }; + + return ( + + ({ key: name, label: name }))} + isSelected={isSelected} + onToggle={toggle} + testIdPrefix="selected-keys-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// VirtualKeysFilter +// --------------------------------------------------------------------------- + +function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.virtual_key_ids || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableVirtualKeys = filterData?.virtual_keys || []; + const nameToIds = useMemo(() => groupByName(availableVirtualKeys), [availableVirtualKeys]); + + if (!isUninitialized && !isLoading && availableVirtualKeys.length === 0 && !hasActive) return null; + + const toggle = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.virtual_key_ids || []; + const allSelected = resolvedIds.every((id) => current.includes(id)); + const next = allSelected + ? current.filter((v) => !resolvedIds.includes(v)) + : [...current, ...resolvedIds.filter((id) => !current.includes(id))]; + onFiltersChange({ ...filters, virtual_key_ids: next }); + }; + + const isSelected = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.virtual_key_ids || []; + return resolvedIds.every((id) => current.includes(id)); + }; + + return ( + + ({ key: name, label: name }))} + isSelected={isSelected} + onToggle={toggle} + testIdPrefix="virtual-keys-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// RoutingEnginesFilter +// --------------------------------------------------------------------------- + +function RoutingEnginesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.routing_engine_used || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableRoutingEngines = filterData?.routing_engines || []; + + if (!isUninitialized && !isLoading && availableRoutingEngines.length === 0 && !hasActive) return null; + + return ( + + ({ + key: engine, + label: RoutingEngineUsedLabels[engine as keyof typeof RoutingEngineUsedLabels] ?? engine, + }))} + isSelected={(engine) => (filters.routing_engine_used || []).includes(engine)} + onToggle={(engine) => { + const current = filters.routing_engine_used || []; + const next = current.includes(engine) ? current.filter((e) => e !== engine) : [...current, engine]; + onFiltersChange({ ...filters, routing_engine_used: next }); + }} + testIdPrefix="routing-engines-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// RoutingRulesFilter +// --------------------------------------------------------------------------- + +function RoutingRulesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.routing_rule_ids || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableRoutingRules = filterData?.routing_rules || []; + const nameToIds = useMemo(() => groupByName(availableRoutingRules), [availableRoutingRules]); + + if (!isUninitialized && !isLoading && availableRoutingRules.length === 0 && !hasActive) return null; + + const toggle = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.routing_rule_ids || []; + const allSelected = resolvedIds.every((id) => current.includes(id)); + const next = allSelected + ? current.filter((v) => !resolvedIds.includes(v)) + : [...current, ...resolvedIds.filter((id) => !current.includes(id))]; + onFiltersChange({ ...filters, routing_rule_ids: next }); + }; + + const isSelected = (name: string) => { + const resolvedIds = nameToIds.get(name) || [name]; + const current = filters.routing_rule_ids || []; + return resolvedIds.every((id) => current.includes(id)); + }; + + return ( + + ({ key: name, label: name }))} + isSelected={isSelected} + onToggle={toggle} + testIdPrefix="routing-rules-filter" + /> + + ); +} + +// --------------------------------------------------------------------------- +// SessionFilter +// --------------------------------------------------------------------------- + +function SessionFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = !!filters.parent_request_id; + return ( + +
+ onFiltersChange({ ...filters, parent_request_id: e.target.value })} + placeholder="Parent request ID" + className="h-8 text-sm" + data-testid="session-filter-input" + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// CostFilter +// --------------------------------------------------------------------------- + +function CostFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = !!filters.missing_cost_only; + return ( + + onFiltersChange({ ...filters, missing_cost_only: !!checked })} + testId="cost-filter-missing-only-checkbox" + /> + + ); +} + +// --------------------------------------------------------------------------- +// MetadataFilters – fetches metadata keys internally +// --------------------------------------------------------------------------- + +function MetadataFilters({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = !!filters.metadata_filters && Object.keys(filters.metadata_filters).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableMetadataKeys = filterData?.metadata_keys || {}; + const [customInputs, setCustomInputs] = useState>({}); + + const handleChange = useCallback( + (metadataKey: string, value: string | undefined) => { + const current = { ...(filters.metadata_filters || {}) }; + if (value === undefined) { + delete current[metadataKey]; + } else { + current[metadataKey] = value; + } + onFiltersChange({ + ...filters, + metadata_filters: Object.keys(current).length > 0 ? current : undefined, + }); + }, + [filters, onFiltersChange], + ); + + const entries = Object.entries(availableMetadataKeys); + const isEmpty = !isUninitialized && !isLoading && entries.length === 0 && !hasActive; + + return ( + + {isEmpty ? ( +
No metadata keys
+ ) : ( + entries.map(([metadataKey, values]) => ( +
+
{metadataKey}
+ {values.map((value: string) => ( + { + const currentValue = filters.metadata_filters?.[metadataKey]; + handleChange(metadataKey, currentValue === value ? undefined : value); + }} + testId={`metadata-${metadataKey}-filter-checkbox-${value}`} + /> + ))} +
+ { + const newVal = e.target.value; + setCustomInputs((prev) => ({ ...prev, [metadataKey]: newVal })); + if (newVal === "" && filters.metadata_filters?.[metadataKey]) { + handleChange(metadataKey, undefined); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" && customInputs[metadataKey]?.trim()) { + handleChange(metadataKey, customInputs[metadataKey].trim()); + } + }} + data-testid={`metadata-${metadataKey}-filter-custom-input`} + /> +
+
+ )) + )} +
+ ); +} \ No newline at end of file diff --git a/ui/app/workspace/logs/views/logsTable.tsx b/ui/app/workspace/logs/views/logsTable.tsx index e94a0f120b..7253a6f128 100644 --- a/ui/app/workspace/logs/views/logsTable.tsx +++ b/ui/app/workspace/logs/views/logsTable.tsx @@ -44,6 +44,8 @@ interface DataTableProps { onLiveToggle: (enabled: boolean) => void; fetchLogs: () => Promise; fetchStats: () => Promise; + /** When true, filters are rendered in a sidebar — hide them from the table header */ + sidebarFilters?: boolean; } export function LogsDataTable({ @@ -61,6 +63,7 @@ export function LogsDataTable({ onLiveToggle, fetchLogs, fetchStats, + sidebarFilters = false, }: DataTableProps) { const [sorting, setSorting] = useState([{ id: pagination.sort_by, desc: pagination.order === "desc" }]); const tableContainerRef = useRef(null); @@ -165,16 +168,30 @@ export function LogsDataTable({ return (
-
- -
+ {sidebarFilters ? ( +
+ +
+ ) : ( +
+ +
+ )}
diff --git a/ui/app/workspace/logs/views/logsVolumeChart.tsx b/ui/app/workspace/logs/views/logsVolumeChart.tsx index 424f45746d..7257b1175b 100644 --- a/ui/app/workspace/logs/views/logsVolumeChart.tsx +++ b/ui/app/workspace/logs/views/logsVolumeChart.tsx @@ -318,7 +318,7 @@ export function LogsVolumeChart({
-
+
diff --git a/ui/components/table/columnConfigDropdown.tsx b/ui/components/table/columnConfigDropdown.tsx index 04f32566fb..dc093bb58a 100644 --- a/ui/components/table/columnConfigDropdown.tsx +++ b/ui/components/table/columnConfigDropdown.tsx @@ -1,8 +1,8 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import type { ColumnConfigEntry } from "./hooks/useColumnConfig"; import { Columns3, RotateCcw } from "lucide-react"; +import type { ColumnConfigEntry } from "./hooks/useColumnConfig"; interface ColumnConfigDropdownProps { entries: ColumnConfigEntry[]; @@ -22,7 +22,7 @@ export function ColumnConfigDropdown({ entries, labels = {}, onToggleVisibility, return ( -