diff --git a/ui/app/workspace/dashboard/page.tsx b/ui/app/workspace/dashboard/page.tsx index ffc37226db..0a4584cc0d 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 { LogsFilterSidebar } from "@/components/filters/logsFilterSidebar"; import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; +import { ScrollArea } from "@/components/ui/scrollArea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useGetMCPAvailableFilterDataQuery, @@ -47,14 +48,6 @@ import { ProviderUsageTab } from "./components/providerUsageTab"; // Type-safe parser for chart type URL state const toChartType = (value: string): ChartType => (value === "line" ? "line" : "bar"); -// Calculate default timestamps once at module level -const DEFAULT_END_TIME = Math.floor(Date.now() / 1000); -const DEFAULT_START_TIME = (() => { - const date = new Date(); - date.setHours(date.getHours() - 24); - return Math.floor(date.getTime() / 1000); -})(); - // Predefined time periods const TIME_PERIODS = [ { label: "Last hour", value: "1h" }, @@ -64,14 +57,6 @@ const TIME_PERIODS = [ { label: "Last 30 days", value: "30d" }, ]; -const parseCsvParam = (value: string): string[] => (value ? value.split(",").filter(Boolean) : []); -const sanitizeSeriesLabels = (values?: string[]): string[] => { - if (!values) return []; - const trimmedValues = values.map((value) => value.trim()).filter((value) => value.length > 0); - - return [...new Set(trimmedValues)]; -}; - function getTimeRangeFromPeriod(period: string): { start: number; end: number } { const now = Math.floor(Date.now() / 1000); switch (period) { @@ -90,6 +75,22 @@ function getTimeRangeFromPeriod(period: string): { start: number; end: number } } } +// Calculate default timestamps once at module level +const DEFAULT_END_TIME = Math.floor(Date.now() / 1000); +const DEFAULT_START_TIME = (() => { + const date = new Date(); + date.setHours(date.getHours() - 24); + return Math.floor(date.getTime() / 1000); +})(); + +const parseCsvParam = (value: string): string[] => (value ? value.split(",").filter(Boolean) : []); +const sanitizeSeriesLabels = (values?: string[]): string[] => { + if (!values) return []; + const trimmedValues = values.map((value) => value.trim()).filter((value) => value.length > 0); + + return [...new Set(trimmedValues)]; +}; + export default function DashboardPage() { // Data states - Overview const [histogramData, setHistogramData] = useState(null); @@ -164,6 +165,7 @@ export default function DashboardPage() { routing_rule_ids: parseAsString.withDefault(""), routing_engine_used: parseAsString.withDefault(""), missing_cost_only: parseAsString.withDefault("false"), + metadata_filters: parseAsString.withDefault(""), volume_chart: parseAsString.withDefault("bar"), token_chart: parseAsString.withDefault("bar"), cost_chart: parseAsString.withDefault("bar"), @@ -198,6 +200,14 @@ export default function DashboardPage() { const selectedRoutingRuleIds = useMemo(() => parseCsvParam(urlState.routing_rule_ids), [urlState.routing_rule_ids]); const selectedRoutingEngines = useMemo(() => parseCsvParam(urlState.routing_engine_used), [urlState.routing_engine_used]); const missingCostOnly = useMemo(() => urlState.missing_cost_only === "true", [urlState.missing_cost_only]); + const metadataFilters = useMemo(() => { + if (!urlState.metadata_filters) return undefined; + try { + return JSON.parse(urlState.metadata_filters) as Record; + } catch { + return undefined; + } + }, [urlState.metadata_filters]); // MCP filter arrays const selectedMcpToolNames = useMemo(() => parseCsvParam(urlState.mcp_tool_names), [urlState.mcp_tool_names]); @@ -217,6 +227,7 @@ export default function DashboardPage() { ...(selectedRoutingRuleIds.length > 0 && { routing_rule_ids: selectedRoutingRuleIds }), ...(selectedRoutingEngines.length > 0 && { routing_engine_used: selectedRoutingEngines }), ...(missingCostOnly && { missing_cost_only: true }), + ...(metadataFilters && Object.keys(metadataFilters).length > 0 && { metadata_filters: metadataFilters }), }), [ urlState.start_time, @@ -230,6 +241,7 @@ export default function DashboardPage() { selectedRoutingRuleIds, selectedRoutingEngines, missingCostOnly, + metadataFilters, ], ); @@ -244,15 +256,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 +499,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 +514,57 @@ 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], + ); + + // 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], + ); + + const handlePeriodChange = useCallback( + (period: string | undefined) => { + if (!period) return; + const { start, end } = getTimeRangeFromPeriod(period); + setUrlState({ start_time: start, end_time: end, period }); + }, + [setUrlState], + ); + + 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: "", + }); }, [setUrlState], ); @@ -704,196 +708,201 @@ 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 */} - -
- +
+ {/* Sidebar Filters */} + + + {/* Main Content */} + + {/* Header */} +
+
+

Dashboard

- - - {/* Provider Usage Tab */} - -
- + -
-
- - {/* Model Rankings Tab */} - -
- -
-
- - {/* MCP Tab */} - -
- + {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" + /> + )} +
+ )} +
- +
- {/* 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..eb20ac6c43 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 { LogsFilterSidebar } from "@/components/filters/logsFilterSidebar"; 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) => ( 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..036101e532 100644 --- a/ui/app/workspace/logs/views/filters.tsx +++ b/ui/app/workspace/logs/views/filters.tsx @@ -1,4 +1,3 @@ -import { FilterPopover } from "@/components/filters/filterPopover"; import { Button } from "@/components/ui/button"; import { Command, CommandItem, CommandList } from "@/components/ui/command"; import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; @@ -12,7 +11,6 @@ import { toast } from "sonner"; export { dateToRfc3339Local } from "@/lib/utils/date"; -/** Predefined time periods for the logs date range picker (matches E2E test labels) */ const LOG_TIME_PERIODS = [ { label: "Last hour", value: "1h" }, { label: "Last 6 hours", value: "6h" }, @@ -62,6 +60,14 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle const filtersRef = useRef(filters); const [recalculateCosts] = useRecalculateLogCostsMutation(); + const [startTime, setStartTime] = useState(filters.start_time ? new Date(filters.start_time) : undefined); + const [endTime, setEndTime] = useState(filters.end_time ? new Date(filters.end_time) : undefined); + + useEffect(() => { + setStartTime(filters.start_time ? new Date(filters.start_time) : undefined); + setEndTime(filters.end_time ? new Date(filters.end_time) : undefined); + }, [filters.start_time, filters.end_time]); + // Keep filtersRef in sync so debounced search always merges with latest filters (search within filtered results) useEffect(() => { filtersRef.current = filters; @@ -72,16 +78,6 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle setLocalSearch(filters.content_search || ""); }, [filters.content_search]); - // Convert ISO strings from filters to Date objects for the DateTimePicker - const [startTime, setStartTime] = useState(filters.start_time ? new Date(filters.start_time) : undefined); - const [endTime, setEndTime] = useState(filters.end_time ? new Date(filters.end_time) : undefined); - - // Sync local date state when filters change from URL - useEffect(() => { - setStartTime(filters.start_time ? new Date(filters.start_time) : undefined); - setEndTime(filters.end_time ? new Date(filters.end_time) : undefined); - }, [filters.start_time, filters.end_time]); - // Cleanup timeout on unmount useEffect(() => { return () => { @@ -123,31 +119,8 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle [onFiltersChange], ); - const handleFilterChange = useCallback( - (key: keyof LogFiltersType, values: string[] | boolean | string) => { - onFiltersChange({ ...filters, [key]: values }); - }, - [filters, onFiltersChange], - ); - - const handleMetadataFilterChange = 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], - ); - return ( -
+
@@ -228,4 +192,4 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle
); -} \ 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..fccd6a8082 100644 --- a/ui/app/workspace/logs/views/logsTable.tsx +++ b/ui/app/workspace/logs/views/logsTable.tsx @@ -165,7 +165,7 @@ export function LogsDataTable({ return (
-
+
-
+
diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx index b1891ee60f..bfc8aa9a84 100644 --- a/ui/app/workspace/mcp-logs/page.tsx +++ b/ui/app/workspace/mcp-logs/page.tsx @@ -1,3 +1,4 @@ +import { MCPFilterSidebar } from "@/components/filters/mcpFilterSidebar"; import FullPageLoader from "@/components/fullPageLoader"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardContent } from "@/components/ui/card"; @@ -516,29 +517,35 @@ export default function MCPLogsPage() { } /> ) : ( -
-
+
+ {/* Sidebar Filters */} + + + {/* Main Content */} +
{/* Quick Stats */} -
- {statCards.map((card) => ( - - -
-
{card.title}
-
{card.value}
-
-
-
- ))} -
+
+
+ {statCards.map((card) => ( + + +
+
{card.title}
+
{card.value}
+
+
+
+ ))} +
- {/* Error Alert */} - {error && ( - - - {error} - - )} + {/* Error Alert */} + {error && ( + + + {error} + + )} +
void; @@ -18,24 +46,11 @@ interface MCPLogFiltersProps { } export function MCPLogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle }: MCPLogFiltersProps) { - const [openFiltersPopover, setOpenFiltersPopover] = useState(false); const [localSearch, setLocalSearch] = useState(filters.content_search || ""); - const searchTimeoutRef = useRef | undefined>(undefined); - const filtersRef = useRef(filters); - - // Convert ISO strings from filters to Date objects for the DateTimePicker const [startTime, setStartTime] = useState(filters.start_time ? new Date(filters.start_time) : undefined); const [endTime, setEndTime] = useState(filters.end_time ? new Date(filters.end_time) : undefined); - - // Use RTK Query to fetch available filter data - const { data: filterData, isLoading: filterDataLoading } = useGetMCPLogsFilterDataQuery(); - - const availableToolNames = filterData?.tool_names || []; - const availableServerLabels = filterData?.server_labels || []; - const availableVirtualKeys = filterData?.virtual_keys || []; - - // Create mapping from name to ID for virtual keys - const virtualKeyNameToId = new Map(availableVirtualKeys.map((key) => [key.name, key.id])); + const searchTimeoutRef = useRef | undefined>(undefined); + const filtersRef = useRef(filters); // Keep filtersRef in sync with filters prop useEffect(() => { @@ -47,7 +62,6 @@ export function MCPLogFilters({ filters, onFiltersChange, liveEnabled, onLiveTog setLocalSearch(filters.content_search || ""); }, [filters.content_search]); - // Sync local date state when filters change from URL useEffect(() => { setStartTime(filters.start_time ? new Date(filters.start_time) : undefined); setEndTime(filters.end_time ? new Date(filters.end_time) : undefined); @@ -66,90 +80,19 @@ export function MCPLogFilters({ filters, onFiltersChange, liveEnabled, onLiveTog (value: string) => { setLocalSearch(value); - // Clear existing timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } - // Set new timeout - use filtersRef.current to avoid stale closure searchTimeoutRef.current = setTimeout(() => { onFiltersChange({ ...filtersRef.current, content_search: value }); - }, 500); // 500ms debounce + }, 500); }, [onFiltersChange], ); - const handleFilterSelect = (category: keyof typeof FILTER_OPTIONS, value: string) => { - const filterKeyMap: Record = { - Status: "status", - "Tool Names": "tool_names", - Servers: "server_labels", - "Virtual Keys": "virtual_key_ids", - }; - - const filterKey = filterKeyMap[category]; - let valueToStore = value; - - // Convert name to ID for virtual keys - if (category === "Virtual Keys") { - valueToStore = virtualKeyNameToId.get(value) || value; - } - - const currentValues = (filters[filterKey] as string[]) || []; - const newValues = currentValues.includes(valueToStore) - ? currentValues.filter((v) => v !== valueToStore) - : [...currentValues, valueToStore]; - - onFiltersChange({ - ...filters, - [filterKey]: newValues, - }); - }; - - const isSelected = (category: keyof typeof FILTER_OPTIONS, value: string) => { - const filterKeyMap: Record = { - Status: "status", - "Tool Names": "tool_names", - Servers: "server_labels", - "Virtual Keys": "virtual_key_ids", - }; - - const filterKey = filterKeyMap[category]; - const currentValues = filters[filterKey]; - - // For virtual keys, convert name to ID before checking - let valueToCheck = value; - if (category === "Virtual Keys") { - valueToCheck = virtualKeyNameToId.get(value) || value; - } - - return Array.isArray(currentValues) && currentValues.includes(valueToCheck); - }; - - const getSelectedCount = () => { - // Exclude timestamp filters and content_search from the count - const excludedKeys = ["start_time", "end_time", "content_search"]; - - return Object.entries(filters).reduce((count, [key, value]) => { - if (excludedKeys.includes(key)) { - return count; - } - if (Array.isArray(value)) { - return count + value.length; - } - return count + (value ? 1 : 0); - }, 0); - }; - - const FILTER_OPTIONS = { - Status: Statuses, - "Tool Names": filterDataLoading ? ["Loading..."] : availableToolNames, - Servers: filterDataLoading ? ["Loading..."] : availableServerLabels, - "Virtual Keys": filterDataLoading ? ["Loading virtual keys..."] : availableVirtualKeys.map((key) => key.name), - } as const; - return ( -
+
- { setStartTime(p.from); setEndTime(p.to); @@ -188,62 +127,19 @@ export function MCPLogFilters({ filters, onFiltersChange, liveEnabled, onLiveTog 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(), + }); + }} /> - - - - - - - - - No filters found. - {Object.entries(FILTER_OPTIONS) - .filter(([_, values]) => values.length > 0) - .map(([category, values]) => ( - - {values.map((value) => { - const selected = isSelected(category as keyof typeof FILTER_OPTIONS, value); - const isLoading = - (category === "Tool Names" && filterDataLoading) || - (category === "Servers" && filterDataLoading) || - (category === "Virtual Keys" && filterDataLoading); - return ( - !isLoading && handleFilterSelect(category as keyof typeof FILTER_OPTIONS, value)} - disabled={isLoading} - > -
- {isLoading ? ( -
- ) : ( - - )} -
- {value} - - ); - })} - - ))} - - - -
); -} \ No newline at end of file +} diff --git a/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx index 5b5337e97d..3cba9f2f7d 100644 --- a/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx +++ b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/ui/button"; import { buildPinStyle, ColumnConfigDropdown, @@ -9,6 +8,7 @@ import { useHeaderCellRefs, usePinOffsets, } from "@/components/table"; +import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import type { MCPToolLogEntry, MCPToolLogFilters, Pagination } from "@/lib/types/logs"; import { cn } from "@/lib/utils"; @@ -143,145 +143,148 @@ export function MCPLogsDataTable({ }; return ( -
+
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {loading ? ( - - -
- - Loading logs... -
-
-
- ) : ( - <> - +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {loading ? ( +
- {!isSocketConnected ? ( - <> - - Not connected to socket, please refresh the page. - - ) : liveEnabled ? ( - <> - - Listening for logs... - - ) : ( - <> - - Live updates paused - - )} + + Loading logs...
- {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const pinned = cell.column.getIsPinned(); - return ( - onRowClick?.(row.original, cell.column.id)} - key={cell.id} - style={buildPinStyle(cell.column, pinOffsets)} - className={cn( - pinned && "bg-card", - cell.column.id === lastLeftPinId && PIN_SHADOW_LEFT, - cell.column.id === firstRightPinId && PIN_SHADOW_RIGHT, - "group-hover/table-row:bg-[#f7f7f7] dark:group-hover/table-row:bg-[#232327]", - )} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - )) - ) : ( - - - No results found. Try adjusting your filters and/or time range. + ) : ( + <> + + +
+ {!isSocketConnected ? ( + <> + + Not connected to socket, please refresh the page. + + ) : liveEnabled ? ( + <> + + Listening for logs... + + ) : ( + <> + + Live updates paused + + )} +
- )} - - )} -
-
-
- - {/* Pagination Footer */} -
-
- {startItemDisplay.toLocaleString()}-{endItemDisplay.toLocaleString()} of {totalItems.toLocaleString()} entries + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const pinned = cell.column.getIsPinned(); + const size = cell.column.getSize(); + return ( + onRowClick?.(row.original, cell.column.id)} + key={cell.id} + style={{ width: size, minWidth: size, maxWidth: size, ...buildPinStyle(cell.column, pinOffsets) }} + className={cn( + "overflow-hidden", + pinned && "bg-card", + cell.column.id === lastLeftPinId && PIN_SHADOW_LEFT, + cell.column.id === firstRightPinId && PIN_SHADOW_RIGHT, + "group-hover/table-row:bg-[#f7f7f7] dark:group-hover/table-row:bg-[#232327]", + )} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )) + ) : ( + + + No results found. Try adjusting your filters and/or time range. + + + )} + + )} + +
+ {/* Pagination Footer */} +
+
+ {startItemDisplay.toLocaleString()}-{endItemDisplay.toLocaleString()} of {totalItems.toLocaleString()} entries +
-
- +
+ -
- Page - {currentPage} - of {totalPages} -
+
+ Page + {currentPage} + of {totalPages} +
- + +
diff --git a/ui/components/filters/filterPopover.tsx b/ui/components/filters/filterPopover.tsx deleted file mode 100644 index 887e15cdcb..0000000000 --- a/ui/components/filters/filterPopover.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { RequestTypeLabels, RequestTypes, RoutingEngineUsedLabels, Statuses } from "@/lib/constants/logs"; -import { useGetAvailableFilterDataQuery, useGetProvidersQuery } from "@/lib/store"; -import type { LogFilters as LogFiltersType } from "@/lib/types/logs"; -import { cn } from "@/lib/utils"; -import { Check, FilterIcon } from "lucide-react"; -import { useState } from "react"; - -interface FilterPopoverProps { - filters: LogFiltersType; - onFilterChange: (key: keyof LogFiltersType, values: string[] | boolean | string) => void; - onMetadataFilterChange?: (metadataKey: string, value: string | undefined) => void; - showMissingCost?: boolean; - showParentRequestIdFilter?: boolean; -} - -export function FilterPopover({ - filters, - onFilterChange, - onMetadataFilterChange, - showMissingCost, - showParentRequestIdFilter = true, -}: FilterPopoverProps) { - const [open, setOpen] = useState(false); - const [customMetadataInputs, setCustomMetadataInputs] = useState>({}); - - const { data: providersData, isLoading: providersLoading } = useGetProvidersQuery(); - const { data: filterData, isLoading: filterDataLoading } = useGetAvailableFilterDataQuery(); - - const availableProviders = providersData || []; - const availableModels = filterData?.models || []; - const availableAliases = filterData?.aliases || []; - const availableSelectedKeys = filterData?.selected_keys || []; - const availableVirtualKeys = filterData?.virtual_keys || []; - const availableRoutingRules = filterData?.routing_rules || []; - const availableRoutingEngines = filterData?.routing_engines || []; - const availableMetadataKeys = filterData?.metadata_keys || {}; - - // Create mappings from name to ALL matching IDs (handles duplicate names from deleted keys) - const 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; - }; - const selectedKeyNameToIds = groupByName(availableSelectedKeys); - const virtualKeyNameToIds = groupByName(availableVirtualKeys); - const routingRuleNameToIds = groupByName(availableRoutingRules); - - // Deduplicate by name to avoid React key collisions (e.g. multiple deleted keys with the same name) - const dedup = (items: { name: string }[]) => [...new Map(items.map((i) => [i.name, i])).values()].map((i) => i.name); - - const FILTER_OPTIONS: Record = { - Status: [...Statuses], - Providers: providersLoading ? [] : availableProviders.map((provider) => provider.name), - Type: [...RequestTypes], - Models: filterDataLoading ? [] : availableModels, - Aliases: filterDataLoading ? [] : availableAliases, - "Selected Keys": filterDataLoading ? [] : dedup(availableSelectedKeys), - "Virtual Keys": filterDataLoading ? [] : dedup(availableVirtualKeys), - "Routing Engines": filterDataLoading ? [] : availableRoutingEngines, - "Routing Rules": filterDataLoading ? [] : dedup(availableRoutingRules), - }; - - // Add dynamic metadata categories - for (const [metadataKey, values] of Object.entries(availableMetadataKeys)) { - FILTER_OPTIONS[`Metadata: ${metadataKey}`] = values; - } - - const isCategoryLoading = (category: string) => - (category === "Providers" && providersLoading) || - (category !== "Status" && category !== "Type" && category !== "Providers" && !category.startsWith("Metadata: ") && filterDataLoading); - - const filterKeyMap: Record = { - Status: "status", - Providers: "providers", - Type: "objects", - Models: "models", - Aliases: "aliases", - "Selected Keys": "selected_key_ids", - "Virtual Keys": "virtual_key_ids", - "Routing Rules": "routing_rule_ids", - "Routing Engines": "routing_engine_used", - }; - - // Resolves a display name to all matching IDs for key/rule categories - const resolveValuesForCategory = (category: string, value: string): string[] => { - if (category === "Selected Keys") return selectedKeyNameToIds.get(value) || [value]; - if (category === "Virtual Keys") return virtualKeyNameToIds.get(value) || [value]; - if (category === "Routing Rules") return routingRuleNameToIds.get(value) || [value]; - return [value]; - }; - - const handleFilterSelect = (category: string, value: string) => { - // Handle metadata categories - if (category.startsWith("Metadata: ")) { - const metadataKey = category.replace("Metadata: ", ""); - const currentValue = filters.metadata_filters?.[metadataKey]; - const predefinedValues = FILTER_OPTIONS[category] || []; - - if (currentValue === value) { - // Deselect - clear the filter and the draft - onMetadataFilterChange?.(metadataKey, undefined); - setCustomMetadataInputs((prev) => { - const updated = { ...prev }; - delete updated[category]; - return updated; - }); - } else { - // Select - onMetadataFilterChange?.(metadataKey, value); - // Only clear draft if selecting a predefined value (not custom input submission) - if (predefinedValues.includes(value)) { - setCustomMetadataInputs((prev) => { - const updated = { ...prev }; - delete updated[category]; - return updated; - }); - } - } - return; - } - - const filterKey = filterKeyMap[category]; - const resolvedIds = resolveValuesForCategory(category, value); - - const currentValues = (filters[filterKey] as string[]) || []; - // Check if ALL resolved IDs are already selected (toggle all together) - const allSelected = resolvedIds.every((id) => currentValues.includes(id)); - const newValues = allSelected - ? currentValues.filter((v) => !resolvedIds.includes(v)) - : [...currentValues, ...resolvedIds.filter((id) => !currentValues.includes(id))]; - - onFilterChange(filterKey, newValues); - }; - - const isSelected = (category: string, value: string) => { - // Handle metadata categories - if (category.startsWith("Metadata: ")) { - const metadataKey = category.replace("Metadata: ", ""); - return filters.metadata_filters?.[metadataKey] === value; - } - - const filterKey = filterKeyMap[category]; - const currentValues = filters[filterKey]; - const resolvedIds = resolveValuesForCategory(category, value); - - return Array.isArray(currentValues) && resolvedIds.every((id) => currentValues.includes(id)); - }; - - // Count unique visible names for ID-based categories (avoids inflated badge when - // multiple backing IDs share the same display name due to deduplication). - const countUniqueNames = (ids: string[], nameToIds: Map): number => { - const seen = new Set(); - for (const [name, mappedIds] of nameToIds) { - if (mappedIds.some((id) => ids.includes(id))) { - seen.add(name); - } - } - return seen.size; - }; - const dedupedCountKeys: Record> = { - selected_key_ids: selectedKeyNameToIds, - virtual_key_ids: virtualKeyNameToIds, - routing_rule_ids: routingRuleNameToIds, - }; - - const excludedKeys = ["start_time", "end_time", "content_search", "metadata_filters"]; - const selectedCount = - Object.entries(filters).reduce((count, [key, value]) => { - if (excludedKeys.includes(key)) { - return count; - } - if (Array.isArray(value)) { - const nameMap = dedupedCountKeys[key]; - return count + (nameMap ? countUniqueNames(value, nameMap) : value.length); - } - return count + (value ? 1 : 0); - }, 0) + (filters.metadata_filters ? Object.keys(filters.metadata_filters).length : 0); - - return ( - - - - - - - - - No filters found. - {showParentRequestIdFilter && ( - -
- onFilterChange("parent_request_id", e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - placeholder="Parent request ID" - className="h-8" - data-testid="session-parent-request-id-input" - /> -
-
- )} - {showMissingCost && ( - - - onFilterChange("missing_cost_only", checked)} - /> - Show missing cost - - - )} - {Object.entries(FILTER_OPTIONS) - .filter(([category, values]) => values.length > 0 || isCategoryLoading(category)) - .map(([category, values]) => ( - - {isCategoryLoading(category) && values.length === 0 ? ( - -
-
-
- Loading... - - ) : ( - values.map((value: string) => { - const selected = isSelected(category, value); - return ( - handleFilterSelect(category, value)} - > -
- -
- - {category === "Type" - ? RequestTypeLabels[value as keyof typeof RequestTypeLabels] - : category === "Routing Engines" - ? (RoutingEngineUsedLabels[value as keyof typeof RoutingEngineUsedLabels] ?? value) - : value} - -
- ); - }) - )} - {category.startsWith("Metadata: ") && - (() => { - const metadataKey = category.replace("Metadata: ", ""); - const activeValue = filters.metadata_filters?.[metadataKey]; - const isCustom = activeValue && !values.includes(activeValue); - const displayValue = customMetadataInputs[category] ?? (isCustom ? activeValue : ""); - return ( -
- { - const newVal = e.target.value; - setCustomMetadataInputs((prev) => ({ ...prev, [category]: newVal })); - if (newVal === "" && isCustom) { - onMetadataFilterChange?.(metadataKey, undefined); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter" && customMetadataInputs[category]?.trim()) { - handleFilterSelect(category, customMetadataInputs[category].trim()); - } - e.stopPropagation(); - }} - onClick={(e) => e.stopPropagation()} - /> -
- ); - })()} - - ))} - - - - - ); -} \ No newline at end of file diff --git a/ui/components/filters/logsFilterSidebar.tsx b/ui/components/filters/logsFilterSidebar.tsx new file mode 100644 index 0000000000..3464a9fdac --- /dev/null +++ b/ui/components/filters/logsFilterSidebar.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 LogsFilterSidebar({ 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/components/filters/mcpFilterSidebar.tsx b/ui/components/filters/mcpFilterSidebar.tsx new file mode 100644 index 0000000000..4b47726e42 --- /dev/null +++ b/ui/components/filters/mcpFilterSidebar.tsx @@ -0,0 +1,328 @@ +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 { Statuses } from "@/lib/constants/logs"; +import { useGetMCPLogsFilterDataQuery } from "@/lib/store"; +import type { MCPToolLogFilters } from "@/lib/types/logs"; +import { cn } from "@/lib/utils"; +import { ChevronDown, RotateCcw } from "lucide-react"; +import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +// --------------------------------------------------------------------------- +// MCPFilterSidebar – orchestrator +// --------------------------------------------------------------------------- + +interface MCPFilterSidebarProps { + filters: MCPToolLogFilters; + onFiltersChange: (filters: MCPToolLogFilters) => void; +} + +export function MCPFilterSidebar({ filters, onFiltersChange }: MCPFilterSidebarProps) { + const activeFilterCount = useMemo(() => { + const excludedKeys = ["start_time", "end_time", "content_search"]; + 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); + 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 +// --------------------------------------------------------------------------- + +interface FilterComponentProps { + filters: MCPToolLogFilters; + onFiltersChange: (filters: MCPToolLogFilters) => 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, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + loading?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const [open, setOpen] = useState(defaultOpen); + + useEffect(() => { + if (defaultOpen) setOpen(true); + }, [defaultOpen]); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }; + + return ( + + + + {title} + + +
{loading ? : children}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// CheckboxFilterItem +// --------------------------------------------------------------------------- + +function CheckboxFilterItem({ + label, + checked, + onCheckedChange, + labelClassName, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + labelClassName?: 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, +}: { + items: { key: string; label: string }[]; + isSelected: (key: string) => boolean; + onToggle: (key: string) => void; + placeholder?: string; + inputRef?: Ref; +}) { + 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" + /> +
+ {filtered.map((item) => ( + onToggle(item.key)} /> + ))} + {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 }); + }} + /> + ))} + + ); +} + +// --------------------------------------------------------------------------- +// ToolNamesFilter – fetches tool names; skips while closed & inactive +// --------------------------------------------------------------------------- + +function ToolNamesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.tool_names || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableToolNames = filterData?.tool_names || []; + + if (!isUninitialized && !isLoading && availableToolNames.length === 0 && !hasActive) return null; + + return ( + + ({ key: name, label: name }))} + isSelected={(name) => (filters.tool_names || []).includes(name)} + onToggle={(name) => { + const current = filters.tool_names || []; + const next = current.includes(name) ? current.filter((n) => n !== name) : [...current, name]; + onFiltersChange({ ...filters, tool_names: next }); + }} + /> + + ); +} + +// --------------------------------------------------------------------------- +// ServersFilter – fetches server labels; skips while closed & inactive +// --------------------------------------------------------------------------- + +function ServersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = (filters.server_labels || []).length > 0; + const [opened, setOpened] = useState(defaultOpen || hasActive); + const searchInputRef = useAutoFocusOnOpen(opened); + const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableServerLabels = filterData?.server_labels || []; + + if (!isUninitialized && !isLoading && availableServerLabels.length === 0 && !hasActive) return null; + + return ( + + ({ key: label, label }))} + isSelected={(label) => (filters.server_labels || []).includes(label)} + onToggle={(label) => { + const current = filters.server_labels || []; + const next = current.includes(label) ? current.filter((l) => l !== label) : [...current, label]; + onFiltersChange({ ...filters, server_labels: next }); + }} + /> + + ); +} + +// --------------------------------------------------------------------------- +// VirtualKeysFilter – fetches virtual keys; maps name→ID +// --------------------------------------------------------------------------- + +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 } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive }); + const availableVirtualKeys = filterData?.virtual_keys || []; + const nameToId = useMemo(() => new Map(availableVirtualKeys.map((key) => [key.name, key.id])), [availableVirtualKeys]); + + if (!isUninitialized && !isLoading && availableVirtualKeys.length === 0 && !hasActive) return null; + + const isSelected = (name: string) => { + const id = nameToId.get(name) || name; + return (filters.virtual_key_ids || []).includes(id); + }; + + const toggle = (name: string) => { + const id = nameToId.get(name) || name; + const current = filters.virtual_key_ids || []; + const next = current.includes(id) ? current.filter((v) => v !== id) : [...current, id]; + onFiltersChange({ ...filters, virtual_key_ids: next }); + }; + + return ( + + ({ key: key.name, label: key.name }))} + isSelected={isSelected} + onToggle={toggle} + /> + + ); +} \ No newline at end of file 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 ( -