diff --git a/ui/.oxlintrc.json b/ui/.oxlintrc.json index 7c3175a9bc..ff59178b6e 100644 --- a/ui/.oxlintrc.json +++ b/ui/.oxlintrc.json @@ -6,10 +6,10 @@ "es2024": true }, "categories": { - "correctness": "error" + "correctness": "warn" }, "rules": { - "import/no-cycle": ["error", { "maxDepth": 1, "ignoreExternal": true }], + "import/no-cycle": ["warn", { "maxDepth": 1, "ignoreExternal": true }], "typescript/no-explicit-any": "warn", "typescript/ban-ts-comment": "off" }, diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx index 2a09f8f102..16883d487a 100644 --- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx +++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx @@ -16,8 +16,8 @@ interface ChartCardProps { export function ChartCard({ title, children, headerActions, loading, testId, height = "200px", className }: ChartCardProps) { if (loading) { return ( - -
+ +
{title} {headerActions && (
@@ -25,7 +25,7 @@ export function ChartCard({ title, children, headerActions, loading, testId, hei
)}
-
+
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/dashboard/utils/chartUtils.ts b/ui/app/workspace/dashboard/utils/chartUtils.ts index 9835189b2b..2177d461c7 100644 --- a/ui/app/workspace/dashboard/utils/chartUtils.ts +++ b/ui/app/workspace/dashboard/utils/chartUtils.ts @@ -80,7 +80,7 @@ export const LATENCY_COLORS = { // Shared CSS class constants for chart card headers export const CHART_HEADER_ACTIONS_CLASS = "flex min-w-0 w-full flex-col-reverse gap-2"; -export const CHART_HEADER_LEGEND_CLASS = "flex min-w-0 flex-wrap items-center gap-2 pl-2 text-xs"; +export const CHART_HEADER_LEGEND_CLASS = "flex min-h-5 min-w-0 flex-wrap items-center gap-2 pl-2 text-xs"; export const CHART_HEADER_CONTROLS_CLASS = "flex items-center justify-end gap-2"; // Chart colors diff --git a/ui/app/workspace/logs/layout.tsx b/ui/app/workspace/logs/layout.tsx index 839d6a7c9b..8bd91110a9 100644 --- a/ui/app/workspace/logs/layout.tsx +++ b/ui/app/workspace/logs/layout.tsx @@ -1,6 +1,6 @@ -import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router"; import { NoPermissionView } from "@/components/noPermissionView"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router"; import LogsPage from "./page"; function RouteComponent() { diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx index 80bd995486..7ae93e2962 100644 --- a/ui/app/workspace/logs/page.tsx +++ b/ui/app/workspace/logs/page.tsx @@ -2,9 +2,12 @@ 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 { LogsHeaderView } from "@/app/workspace/logs/views/logsHeaderView"; import { LogsDataTable } from "@/app/workspace/logs/views/logsTable"; import { LogsVolumeChart } from "@/app/workspace/logs/views/logsVolumeChart"; +import { LogsFilterSidebar } from "@/components/filters/logsFilterSidebar"; import FullPageLoader from "@/components/fullPageLoader"; +import { useColumnConfig } from "@/components/table"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -224,12 +227,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 +297,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 +828,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", @@ -854,6 +858,36 @@ export default function LogsPage() { const columns = useMemo(() => createColumns(handleDelete, hasDeleteAccess), [handleDelete, hasDeleteAccess]); + const columnIds = useMemo( + () => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean), + [columns], + ); + + const COLUMN_LABELS: Record = useMemo( + () => ({ + timestamp: "Time", + request_type: "Type", + input: "Message", + provider: "Provider", + model: "Model", + latency: "Latency", + tokens: "Tokens", + cost: "Cost", + }), + [], + ); + + const { + entries: columnEntries, + columnOrder, + columnVisibility, + columnPinning, + toggleVisibility: toggleColumnVisibility, + togglePin: toggleColumnPin, + reorder: reorderColumns, + reset: resetColumns, + } = useColumnConfig({ columnIds, paramName: "cols" }); + // Navigation for log detail sheet const selectedLogIndex = useMemo(() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1), [selectedLogId, logs]); @@ -910,14 +944,32 @@ export default function LogsPage() { ); return ( -
+
{initialLoading ? ( ) : showEmptyState ? ( ) : ( -
-
+
+ {/* Sidebar Filters */} + + + {/* Main Content */} +
+
+ +
{statCards.map((card) => ( @@ -975,9 +1027,7 @@ export default function LogsPage() { data={logs} totalItems={totalItems} loading={fetchingLogs} - filters={filters} pagination={pagination} - onFiltersChange={setFilters} onPaginationChange={setPagination} onRowClick={(row, columnId) => { if (columnId === "actions") return; @@ -986,10 +1036,14 @@ export default function LogsPage() { setSessionHighlightedLogId(null); }} liveEnabled={liveEnabled} - onLiveToggle={handleLiveToggle} isSocketConnected={isSocketConnected} - fetchLogs={fetchLogs} - fetchStats={fetchStats} + columnEntries={columnEntries} + columnOrder={columnOrder} + columnVisibility={columnVisibility} + columnPinning={columnPinning} + onToggleColumnVisibility={toggleColumnVisibility} + onTogglePin={toggleColumnPin} + onReorderColumns={reorderColumns} />
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/columns.tsx b/ui/app/workspace/logs/views/columns.tsx index 285ae2ae7f..719b0df7f6 100644 --- a/ui/app/workspace/logs/views/columns.tsx +++ b/ui/app/workspace/logs/views/columns.tsx @@ -177,16 +177,18 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess ), + size: 230, cell: ({ row }) => { const timestamp = row.original.timestamp; const date = timestamp ? new Date(timestamp) : null; const isValid = date && date.toString() !== "Invalid Date"; - return
{isValid ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "N/A"}
; + return
{isValid ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "N/A"}
; }, }, { id: "request_type", header: "Type", + size: 120, cell: ({ row }) => { return ( @@ -198,11 +200,13 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess { accessorKey: "input", header: "Message", + size: 440, cell: ({ row }) => , }, { accessorKey: "provider", header: "Provider", + size: 160, cell: ({ row }) => { const provider = row.original.provider as ProviderName; return ( @@ -216,7 +220,8 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess { accessorKey: "model", header: "Model", - cell: ({ row }) =>
{row.original.model || "N/A"}
, + size: 160, + cell: ({ row }) =>
{row.original.model || "N/A"}
, }, { accessorKey: "latency", @@ -226,6 +231,7 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess ), + size: 140, cell: ({ row }) => { const latency = row.original.latency; return ( @@ -243,6 +249,7 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess ), + size: 220, cell: ({ row }) => { const tokenUsage = row.original.token_usage; if (!tokenUsage) { @@ -251,7 +258,7 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess return (
-
+
{tokenUsage.total_tokens.toLocaleString()}{" "} {tokenUsage.completion_tokens != null && tokenUsage.prompt_tokens != null ? `(${tokenUsage.prompt_tokens.toLocaleString()}+${tokenUsage.completion_tokens.toLocaleString()})` @@ -269,6 +276,7 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess ), + size: 120, cell: ({ row }) => { if (!row.original.cost) { return
N/A
; @@ -285,6 +293,7 @@ export const createColumns = (onDelete: (log: LogEntry) => void, hasDeleteAccess const actionsColumn: ColumnDef = { id: "actions", + size: 72, cell: ({ row }) => { const log = row.original; return ( 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/logsHeaderView.tsx similarity index 79% rename from ui/app/workspace/logs/views/filters.tsx rename to ui/app/workspace/logs/views/logsHeaderView.tsx index 68df53c49d..848c49e342 100644 --- a/ui/app/workspace/logs/views/filters.tsx +++ b/ui/app/workspace/logs/views/logsHeaderView.tsx @@ -1,4 +1,4 @@ -import { FilterPopover } from "@/components/filters/filterPopover"; +import { ColumnConfigDropdown, type ColumnConfigEntry } from "@/components/table"; import { Button } from "@/components/ui/button"; import { Command, CommandItem, CommandList } from "@/components/ui/command"; import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; @@ -10,9 +10,6 @@ import { Calculator, MoreVertical, Pause, Play, Search } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; 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" }, @@ -46,22 +43,46 @@ function getRangeForPeriod(period: string): { from: Date; to: Date } { return { from, to }; } -interface LogFiltersProps { +interface LogsHeaderViewProps { filters: LogFiltersType; onFiltersChange: (filters: LogFiltersType) => void; liveEnabled: boolean; onLiveToggle: (enabled: boolean) => void; fetchLogs: () => Promise; fetchStats: () => Promise; + /** Column config for the ColumnConfigDropdown */ + columnEntries: ColumnConfigEntry[]; + columnLabels: Record; + onToggleColumnVisibility: (id: string) => void; + onResetColumns: () => void; } -export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle, fetchLogs, fetchStats }: LogFiltersProps) { +export function LogsHeaderView({ + filters, + onFiltersChange, + liveEnabled, + onLiveToggle, + fetchLogs, + fetchStats, + columnEntries, + columnLabels, + onToggleColumnVisibility, + onResetColumns, +}: LogsHeaderViewProps) { const [openMoreActionsPopover, setOpenMoreActionsPopover] = useState(false); const [localSearch, setLocalSearch] = useState(filters.content_search || ""); const searchTimeoutRef = useRef(undefined); 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 +93,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 () => { @@ -110,7 +121,6 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle (value: string) => { setLocalSearch(value); - // Clear existing timeout if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } @@ -118,36 +128,13 @@ export function LogFilters({ filters, onFiltersChange, liveEnabled, onLiveToggle // Use filtersRef.current so search is applied on top of current filters (search within filtered results) searchTimeoutRef.current = setTimeout(() => { onFiltersChange({ ...filtersRef.current, content_search: value }); - }, 500); // 500ms debounce + }, 500); }, [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 ( -
+
@@ -226,6 +204,7 @@ 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..638cf41ae9 100644 --- a/ui/app/workspace/logs/views/logsTable.tsx +++ b/ui/app/workspace/logs/views/logsTable.tsx @@ -1,49 +1,40 @@ -import { Button } from "@/components/ui/button"; import { buildPinStyle, - ColumnConfigDropdown, + type ColumnConfigEntry, DraggableColumnHeader, PIN_SHADOW_LEFT, PIN_SHADOW_RIGHT, - useColumnConfig, useHeaderCellRefs, usePinOffsets, } from "@/components/table"; +import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { useTablePageSize } from "@/hooks/useTablePageSize"; -import type { LogEntry, LogFilters, Pagination } from "@/lib/types/logs"; +import type { LogEntry, Pagination } from "@/lib/types/logs"; import { cn } from "@/lib/utils"; +import type { ColumnOrderState, ColumnPinningState, VisibilityState } from "@tanstack/react-table"; +import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight, Pause, RefreshCw, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table"; -import { LogFilters as LogFiltersComponent } from "./filters"; - -const COLUMN_LABELS: Record = { - timestamp: "Time", - request_type: "Type", - input: "Message", - provider: "Provider", - model: "Model", - latency: "Latency", - tokens: "Tokens", - cost: "Cost", -}; interface DataTableProps { columns: ColumnDef[]; data: LogEntry[]; totalItems: number; loading?: boolean; - filters: LogFilters; pagination: Pagination; - onFiltersChange: (filters: LogFilters) => void; onPaginationChange: (pagination: Pagination) => void; onRowClick?: (log: LogEntry, columnId: string) => void; isSocketConnected: boolean; liveEnabled: boolean; - onLiveToggle: (enabled: boolean) => void; - fetchLogs: () => Promise; - fetchStats: () => Promise; + /** Column config — computed by the parent via useColumnConfig */ + columnEntries: ColumnConfigEntry[]; + columnOrder: ColumnOrderState; + columnVisibility: VisibilityState; + columnPinning: ColumnPinningState; + onToggleColumnVisibility: (id: string) => void; + onTogglePin: (id: string, side: "left" | "right") => void; + onReorderColumns: (entries: ColumnConfigEntry[]) => void; } export function LogsDataTable({ @@ -51,34 +42,25 @@ export function LogsDataTable({ data, totalItems, loading = false, - filters, pagination, - onFiltersChange, onPaginationChange, onRowClick, isSocketConnected, liveEnabled, - onLiveToggle, - fetchLogs, - fetchStats, + columnEntries, + columnOrder, + columnVisibility, + columnPinning, + onToggleColumnVisibility, + onTogglePin, + onReorderColumns, }: DataTableProps) { const [sorting, setSorting] = useState([{ id: pagination.sort_by, desc: pagination.order === "desc" }]); const tableContainerRef = useRef(null); const calculatedPageSize = useTablePageSize(tableContainerRef); - const columnIds = useMemo( - () => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean), - [columns], - ); - const fixedColumnIds = useMemo(() => new Set([]), []); - // Column config: order, visibility, pinning — persisted in URL - const { entries, columnOrder, columnVisibility, columnPinning, toggleVisibility, togglePin, reorder, reset } = useColumnConfig({ - columnIds, - paramName: "cols", - }); - // Measure actual header cell widths for pixel-perfect pin offsets const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs(); const pinOffsets = usePinOffsets(headerCellRefs, columnPinning); @@ -87,20 +69,18 @@ export function LogsDataTable({ const lastLeftPinId = columnPinning.left?.at(-1); const firstRightPinId = columnPinning.right?.at(0); - const columnLabels = useMemo(() => ({ ...COLUMN_LABELS }), []); - // Handle native drag-and-drop reorder const handleColumnDrop = useCallback( (draggedId: string, targetId: string) => { - const newEntries = [...entries]; + const newEntries = [...columnEntries]; const draggedIdx = newEntries.findIndex((e) => e.id === draggedId); const targetIdx = newEntries.findIndex((e) => e.id === targetId); if (draggedIdx === -1 || targetIdx === -1) return; const [moved] = newEntries.splice(draggedIdx, 1); newEntries.splice(targetIdx, 0, moved); - reorder(newEntries); + onReorderColumns(newEntries); }, - [entries, reorder], + [columnEntries, onReorderColumns], ); // Refs to avoid stale closures in the page size effect @@ -164,20 +144,6 @@ export function LogsDataTable({ return (
-
-
- -
- -
-
@@ -197,8 +163,8 @@ export function LogsDataTable({ header.column.id === lastLeftPinId && PIN_SHADOW_LEFT, header.column.id === firstRightPinId && PIN_SHADOW_RIGHT, )} - onHide={toggleVisibility} - onPin={togglePin} + onHide={onToggleColumnVisibility} + onPin={onTogglePin} onDrop={handleColumnDrop} cellRef={setHeaderCellRef(header.column.id)} /> @@ -209,7 +175,7 @@ export function LogsDataTable({ {loading ? ( - +
Loading logs... @@ -245,12 +211,14 @@ export function LogsDataTable({ {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={buildPinStyle(cell.column, pinOffsets)} + 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, 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/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx index b1891ee60f..0f8df331eb 100644 --- a/ui/app/workspace/mcp-logs/page.tsx +++ b/ui/app/workspace/mcp-logs/page.tsx @@ -1,3 +1,5 @@ +import { MCPFilterSidebar } from "@/components/filters/mcpFilterSidebar"; +import { useColumnConfig } from "@/components/table"; import FullPageLoader from "@/components/fullPageLoader"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardContent } from "@/components/ui/card"; @@ -12,6 +14,7 @@ import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQuery import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createMCPColumns } from "./views/columns"; import { MCPEmptyState } from "./views/emptyState"; +import { McpHeaderView } from "./views/mcpHeaderView"; import { MCPLogDetailSheet } from "./views/mcpLogDetailsSheet"; import { MCPLogsDataTable } from "./views/mcpLogsTable"; @@ -446,6 +449,33 @@ export default function MCPLogsPage() { const columns = useMemo(() => createMCPColumns(handleDelete, hasDeleteAccess), [handleDelete, hasDeleteAccess]); + const columnIds = useMemo( + () => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean), + [columns], + ); + + const MCP_COLUMN_LABELS: Record = useMemo( + () => ({ + timestamp: "Time", + tool_name: "Tool Name", + server_label: "Server", + latency: "Latency", + cost: "Cost", + }), + [], + ); + + const { + entries: columnEntries, + columnOrder, + columnVisibility, + columnPinning, + toggleVisibility: toggleColumnVisibility, + togglePin: toggleColumnPin, + reorder: reorderColumns, + reset: resetColumns, + } = useColumnConfig({ columnIds, paramName: "mcp_cols", fixedColumns: { left: [], right: [] } }); + // Navigation for log detail sheet const selectedLogIndex = useMemo(() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1), [selectedLogId, logs]); @@ -516,38 +546,54 @@ export default function MCPLogsPage() { } /> ) : ( -
-
- {/* Quick Stats */} -
- {statCards.map((card) => ( - - -
-
{card.title}
-
{card.value}
-
-
-
- ))} +
+ {/* Sidebar Filters */} + + + {/* Main Content */} +
+
+
+ {/* Quick Stats */} +
+
+ {statCards.map((card) => ( + + +
+
{card.title}
+
{card.value}
+
+
+
+ ))} +
- {/* Error Alert */} - {error && ( - - - {error} - - )} + {/* Error Alert */} + {error && ( + + + {error} + + )} +
{ if (columnId === "actions") return; @@ -555,9 +601,13 @@ export default function MCPLogsPage() { }} isSocketConnected={isSocketConnected} liveEnabled={liveEnabled} - onLiveToggle={handleLiveToggle} - fetchLogs={fetchLogs} - fetchStats={fetchStats} + columnEntries={columnEntries} + columnOrder={columnOrder} + columnVisibility={columnVisibility} + columnPinning={columnPinning} + onToggleColumnVisibility={toggleColumnVisibility} + onTogglePin={toggleColumnPin} + onReorderColumns={reorderColumns} />
diff --git a/ui/app/workspace/mcp-logs/views/columns.tsx b/ui/app/workspace/mcp-logs/views/columns.tsx index 6032485fc0..e4d07678e2 100644 --- a/ui/app/workspace/mcp-logs/views/columns.tsx +++ b/ui/app/workspace/mcp-logs/views/columns.tsx @@ -38,11 +38,11 @@ export const createMCPColumns = ( ), - size: 180, + size: 230, cell: ({ row }) => { const timestamp = row.original.timestamp; const date = new Date(timestamp); - return
{isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}
; + return
{isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}
; }, }, { @@ -97,6 +97,7 @@ export const createMCPColumns = ( }, { id: "actions", + size: 72, cell: ({ row }) => { const log = row.original; return ( diff --git a/ui/app/workspace/mcp-logs/views/filters.tsx b/ui/app/workspace/mcp-logs/views/filters.tsx deleted file mode 100644 index f7d3444232..0000000000 --- a/ui/app/workspace/mcp-logs/views/filters.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Statuses } from "@/lib/constants/logs"; -import { useGetMCPLogsFilterDataQuery } from "@/lib/store"; -import type { MCPToolLogFilters } from "@/lib/types/logs"; -import { cn } from "@/lib/utils"; -import { Check, FilterIcon, Pause, Play, Search } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; - -interface MCPLogFiltersProps { - filters: MCPToolLogFilters; - onFiltersChange: (filters: MCPToolLogFilters) => void; - liveEnabled: boolean; - onLiveToggle: (enabled: boolean) => void; -} - -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])); - - // Keep filtersRef in sync with filters prop - useEffect(() => { - filtersRef.current = filters; - }, [filters]); - - // Sync localSearch when filters.content_search changes externally - useEffect(() => { - 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); - }, [filters.start_time, filters.end_time]); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, []); - - const handleSearchChange = useCallback( - (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 - }, - [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 ( -
- -
- - handleSearchChange(e.target.value)} - /> -
- - { - setStartTime(p.from); - setEndTime(p.to); - onFiltersChange({ - ...filters, - start_time: p.from?.toISOString(), - end_time: p.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/mcpHeaderView.tsx b/ui/app/workspace/mcp-logs/views/mcpHeaderView.tsx new file mode 100644 index 0000000000..4888e5afe3 --- /dev/null +++ b/ui/app/workspace/mcp-logs/views/mcpHeaderView.tsx @@ -0,0 +1,161 @@ +import { ColumnConfigDropdown, type ColumnConfigEntry } from "@/components/table"; +import { Button } from "@/components/ui/button"; +import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange"; +import { Input } from "@/components/ui/input"; +import type { MCPToolLogFilters } from "@/lib/types/logs"; +import { Pause, Play, Search } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const LOG_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" }, +]; + +function getRangeForPeriod(period: string): { from: Date; to: Date } { + const to = new Date(); + const from = new Date(to.getTime()); + switch (period) { + case "1h": + from.setHours(from.getHours() - 1); + break; + case "6h": + from.setHours(from.getHours() - 6); + break; + case "24h": + from.setHours(from.getHours() - 24); + break; + case "7d": + from.setDate(from.getDate() - 7); + break; + case "30d": + from.setDate(from.getDate() - 30); + break; + default: + from.setHours(from.getHours() - 24); + } + return { from, to }; +} + +interface McpHeaderViewProps { + filters: MCPToolLogFilters; + onFiltersChange: (filters: MCPToolLogFilters) => void; + liveEnabled: boolean; + onLiveToggle: (enabled: boolean) => void; + /** Column config for the ColumnConfigDropdown */ + columnEntries: ColumnConfigEntry[]; + columnLabels: Record; + onToggleColumnVisibility: (id: string) => void; + onResetColumns: () => void; +} + +export function McpHeaderView({ + filters, + onFiltersChange, + liveEnabled, + onLiveToggle, + columnEntries, + columnLabels, + onToggleColumnVisibility, + onResetColumns, +}: McpHeaderViewProps) { + const [localSearch, setLocalSearch] = useState(filters.content_search || ""); + 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); + const searchTimeoutRef = useRef | undefined>(undefined); + const filtersRef = useRef(filters); + + // Keep filtersRef in sync with filters prop + useEffect(() => { + filtersRef.current = filters; + }, [filters]); + + // Sync localSearch when filters.content_search changes externally + useEffect(() => { + setLocalSearch(filters.content_search || ""); + }, [filters.content_search]); + + 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 () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + + const handleSearchChange = useCallback( + (value: string) => { + setLocalSearch(value); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + searchTimeoutRef.current = setTimeout(() => { + onFiltersChange({ ...filtersRef.current, content_search: value }); + }, 500); + }, + [onFiltersChange], + ); + + return ( +
+ +
+ + handleSearchChange(e.target.value)} + /> +
+ { + 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/mcp-logs/views/mcpLogsTable.tsx b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx index 5b5337e97d..080b5383bd 100644 --- a/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx +++ b/ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx @@ -1,45 +1,39 @@ -import { Button } from "@/components/ui/button"; import { buildPinStyle, - ColumnConfigDropdown, + type ColumnConfigEntry, DraggableColumnHeader, PIN_SHADOW_LEFT, PIN_SHADOW_RIGHT, - useColumnConfig, 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 type { MCPToolLogEntry, Pagination } from "@/lib/types/logs"; import { cn } from "@/lib/utils"; +import type { ColumnOrderState, ColumnPinningState, VisibilityState } from "@tanstack/react-table"; import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight, Pause, RefreshCw, X } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { MCPLogFilters } from "./filters"; - -const COLUMN_LABELS: Record = { - timestamp: "Time", - tool_name: "Tool Name", - server_label: "Server", - latency: "Latency", - cost: "Cost", -}; interface DataTableProps { columns: ColumnDef[]; data: MCPToolLogEntry[]; totalItems: number; loading?: boolean; - filters: MCPToolLogFilters; pagination: Pagination; - onFiltersChange: (filters: MCPToolLogFilters) => void; onPaginationChange: (pagination: Pagination) => void; onRowClick?: (log: MCPToolLogEntry, columnId: string) => void; isSocketConnected: boolean; liveEnabled: boolean; - onLiveToggle: (enabled: boolean) => void; - fetchLogs: () => Promise; - fetchStats: () => Promise; + /** Column config — computed by the parent via useColumnConfig */ + columnEntries: ColumnConfigEntry[]; + columnOrder: ColumnOrderState; + columnVisibility: VisibilityState; + columnPinning: ColumnPinningState; + onToggleColumnVisibility: (id: string) => void; + onTogglePin: (id: string, side: "left" | "right") => void; + onReorderColumns: (entries: ColumnConfigEntry[]) => void; } export function MCPLogsDataTable({ @@ -47,32 +41,23 @@ export function MCPLogsDataTable({ data, totalItems, loading = false, - filters, pagination, - onFiltersChange, onPaginationChange, onRowClick, isSocketConnected, liveEnabled, - onLiveToggle, + columnEntries, + columnOrder, + columnVisibility, + columnPinning, + onToggleColumnVisibility, + onTogglePin, + onReorderColumns, }: DataTableProps) { const [sorting, setSorting] = useState([{ id: pagination.sort_by, desc: pagination.order === "desc" }]); - // Derive all column IDs from column definitions - const columnIds = useMemo( - () => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean), - [columns], - ); - const fixedColumnIds = useMemo(() => new Set([]), []); - // Column config: order, visibility, pinning — persisted in URL - const { entries, columnOrder, columnVisibility, columnPinning, toggleVisibility, togglePin, reorder, reset } = useColumnConfig({ - columnIds, - paramName: "mcp_cols", - fixedColumns: { left: [], right: [] }, - }); - // Measure actual header cell widths for pixel-perfect pin offsets const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs(); const pinOffsets = usePinOffsets(headerCellRefs, columnPinning); @@ -84,15 +69,15 @@ export function MCPLogsDataTable({ // Handle native drag-and-drop reorder const handleColumnDrop = useCallback( (draggedId: string, targetId: string) => { - const newEntries = [...entries]; + const newEntries = [...columnEntries]; const draggedIdx = newEntries.findIndex((e) => e.id === draggedId); const targetIdx = newEntries.findIndex((e) => e.id === targetId); if (draggedIdx === -1 || targetIdx === -1) return; const [moved] = newEntries.splice(draggedIdx, 1); newEntries.splice(targetIdx, 0, moved); - reorder(newEntries); + onReorderColumns(newEntries); }, - [entries, reorder], + [columnEntries, onReorderColumns], ); const handleSortingChange = (updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => { @@ -143,145 +128,142 @@ 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..84784a59d9 --- /dev/null +++ b/ui/components/filters/logsFilterSidebar.tsx @@ -0,0 +1,776 @@ +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, PanelLeftClose, PanelLeftOpen, RotateCcw } from "lucide-react"; +import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const COLLAPSE_STORAGE_KEY = "logs-filter-sidebar-collapsed"; + +// --------------------------------------------------------------------------- +// LogsSidebar – orchestrator +// --------------------------------------------------------------------------- + +interface LogsSidebarProps { + filters: LogFilters; + onFiltersChange: (filters: LogFilters) => void; +} + +export function LogsFilterSidebar({ filters, onFiltersChange }: LogsSidebarProps) { + const [collapsed, setCollapsed] = useState(false); + + // Load persisted collapsed state on mount + useEffect(() => { + if (typeof window === "undefined") return; + const stored = window.localStorage.getItem(COLLAPSE_STORAGE_KEY); + if (stored === "true") setCollapsed(true); + }, []); + + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + if (typeof window !== "undefined") { + window.localStorage.setItem(COLLAPSE_STORAGE_KEY, String(next)); + } + return next; + }); + }, []); + + 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]); + + // Collapsed: thin rail with vertical "Filters" label — whole rail is clickable to expand + if (collapsed) { + return ( + + ); + } + + 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..3da3178578 --- /dev/null +++ b/ui/components/filters/mcpFilterSidebar.tsx @@ -0,0 +1,375 @@ +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, PanelLeftClose, PanelLeftOpen, RotateCcw } from "lucide-react"; +import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const COLLAPSE_STORAGE_KEY = "mcp-filter-sidebar-collapsed"; + +// --------------------------------------------------------------------------- +// MCPFilterSidebar – orchestrator +// --------------------------------------------------------------------------- + +interface MCPFilterSidebarProps { + filters: MCPToolLogFilters; + onFiltersChange: (filters: MCPToolLogFilters) => void; +} + +export function MCPFilterSidebar({ filters, onFiltersChange }: MCPFilterSidebarProps) { + const [collapsed, setCollapsed] = useState(false); + + // Load persisted collapsed state on mount + useEffect(() => { + if (typeof window === "undefined") return; + const stored = window.localStorage.getItem(COLLAPSE_STORAGE_KEY); + if (stored === "true") setCollapsed(true); + }, []); + + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + if (typeof window !== "undefined") { + window.localStorage.setItem(COLLAPSE_STORAGE_KEY, String(next)); + } + return next; + }); + }, []); + + 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]); + + // Collapsed: thin rail with vertical "Filters" label — whole rail is clickable to expand + if (collapsed) { + return ( + + ); + } + + 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 ( - diff --git a/ui/components/table/draggableColumnHeader.tsx b/ui/components/table/draggableColumnHeader.tsx index 6d8ec84d36..d5a60f1429 100644 --- a/ui/components/table/draggableColumnHeader.tsx +++ b/ui/components/table/draggableColumnHeader.tsx @@ -38,10 +38,11 @@ export function DraggableColumnHeader({ const [isDropTarget, setIsDropTarget] = useState(false); const pinned = header.column.getIsPinned(); + const size = header.getSize(); return (