diff --git a/framework/go.mod b/framework/go.mod index 0e7a47bee9..88654fd81b 100644 --- a/framework/go.mod +++ b/framework/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/framework -go 1.26.1 +go 1.26.2 require ( cloud.google.com/go/storage v1.61.3 diff --git a/plugins/compat/go.mod b/plugins/compat/go.mod index 9d0466d0a5..b4deca7657 100644 --- a/plugins/compat/go.mod +++ b/plugins/compat/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/compat -go 1.26.1 +go 1.26.2 require ( github.com/maximhq/bifrost/core v1.5.2 diff --git a/plugins/governance/go.mod b/plugins/governance/go.mod index 0abf0caa70..61409c6b68 100644 --- a/plugins/governance/go.mod +++ b/plugins/governance/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/governance -go 1.26.1 +go 1.26.2 require gorm.io/gorm v1.31.1 diff --git a/plugins/jsonparser/go.mod b/plugins/jsonparser/go.mod index 42171926ad..883e837587 100644 --- a/plugins/jsonparser/go.mod +++ b/plugins/jsonparser/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/jsonparser -go 1.26.1 +go 1.26.2 require github.com/maximhq/bifrost/core v1.5.2 diff --git a/plugins/logging/go.mod b/plugins/logging/go.mod index a0b0c5c2b4..eaf8b6f4c5 100644 --- a/plugins/logging/go.mod +++ b/plugins/logging/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/logging -go 1.26.1 +go 1.26.2 require ( github.com/bytedance/sonic v1.15.0 diff --git a/plugins/maxim/go.mod b/plugins/maxim/go.mod index 1c27aa1b93..44a6544b01 100644 --- a/plugins/maxim/go.mod +++ b/plugins/maxim/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/maxim -go 1.26.1 +go 1.26.2 require ( github.com/maximhq/bifrost/core v1.5.2 diff --git a/plugins/mocker/go.mod b/plugins/mocker/go.mod index bcf0aebc61..dd40feefbf 100644 --- a/plugins/mocker/go.mod +++ b/plugins/mocker/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/mocker -go 1.26.1 +go 1.26.2 require ( github.com/jaswdr/faker/v2 v2.8.0 diff --git a/plugins/otel/go.mod b/plugins/otel/go.mod index 69c9fddb3a..7595e08f75 100644 --- a/plugins/otel/go.mod +++ b/plugins/otel/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/otel -go 1.26.1 +go 1.26.2 require ( github.com/maximhq/bifrost/core v1.5.2 diff --git a/plugins/semanticcache/go.mod b/plugins/semanticcache/go.mod index c65f88d4e1..aa0f81afc5 100644 --- a/plugins/semanticcache/go.mod +++ b/plugins/semanticcache/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/semanticcache -go 1.26.1 +go 1.26.2 require ( github.com/cespare/xxhash/v2 v2.3.0 diff --git a/plugins/telemetry/go.mod b/plugins/telemetry/go.mod index 6313ac1894..f110b0517c 100644 --- a/plugins/telemetry/go.mod +++ b/plugins/telemetry/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/plugins/telemetry -go 1.26.1 +go 1.26.2 require ( github.com/maximhq/bifrost/core v1.5.2 diff --git a/transports/go.mod b/transports/go.mod index 4366567848..211edb90a4 100644 --- a/transports/go.mod +++ b/transports/go.mod @@ -1,6 +1,6 @@ module github.com/maximhq/bifrost/transports -go 1.26.1 +go 1.26.2 require ( github.com/andybalholm/brotli v1.2.0 diff --git a/ui/app/workspace/dashboard/components/charts/chartCard.tsx b/ui/app/workspace/dashboard/components/charts/chartCard.tsx index 16883d487a..d2929c01b4 100644 --- a/ui/app/workspace/dashboard/components/charts/chartCard.tsx +++ b/ui/app/workspace/dashboard/components/charts/chartCard.tsx @@ -4,45 +4,68 @@ import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; interface ChartCardProps { - title: string; - children: ReactNode; - headerActions?: ReactNode; - loading?: boolean; - testId?: string; - height?: string; - className?: string; + title: string; + children: ReactNode; + headerActions?: ReactNode; + loading?: boolean; + testId?: string; + height?: string; + className?: string; } -export function ChartCard({ title, children, headerActions, loading, testId, height = "200px", className }: ChartCardProps) { - if (loading) { - return ( - -
- {title} - {headerActions && ( -
- {headerActions} -
- )} -
-
- -
-
- ); - } +export function ChartCard({ + title, + children, + headerActions, + loading, + testId, + height = "200px", + className, +}: ChartCardProps) { + if (loading) { + return ( + +
+ {title} + {headerActions && ( +
+ {headerActions} +
+ )} +
+
+ +
+
+ ); + } - return ( - -
- {title} - {headerActions && ( -
- {headerActions} -
- )} -
-
{children}
-
- ); -} \ No newline at end of file + return ( + +
+ {title} + {headerActions && ( +
+ {headerActions} +
+ )} +
+
{children}
+
+ ); +} diff --git a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx index 13cd3d0ab2..d35b79e847 100644 --- a/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx +++ b/ui/app/workspace/dashboard/components/charts/logVolumeChart.tsx @@ -1,156 +1,219 @@ import type { LogsHistogramResponse } from "@/lib/types/logs"; import { useMemo } from "react"; -import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { CHART_COLORS, formatFullTimestamp, formatTimestamp } from "../../utils/chartUtils"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + CHART_COLORS, + formatFullTimestamp, + formatTimestamp, +} from "../../utils/chartUtils"; import { ChartErrorBoundary } from "./chartErrorBoundary"; import type { ChartType } from "./chartTypeToggle"; interface LogVolumeChartProps { - data: LogsHistogramResponse | null; - chartType: ChartType; - startTime: number; - endTime: number; + data: LogsHistogramResponse | null; + chartType: ChartType; + startTime: number; + endTime: number; } -function CustomTooltip({ active, payload }: any) { - if (!active || !payload || !payload.length) return null; +type LogVolumeDataPoint = { + timestamp: string; + count: number; + success: number; + error: number; + index: number; + formattedTime: string; +}; - const data = payload[0]?.payload; - if (!data) return null; +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ payload?: LogVolumeDataPoint }>; +} + +function CustomTooltip({ active, payload }: CustomTooltipProps) { + if (!active || !payload || !payload.length) return null; - return ( -
-
{formatFullTimestamp(data.timestamp)}
-
-
- - - Success - - {data.success.toLocaleString()} -
-
- - - Error - - {data.error.toLocaleString()} -
-
-
- ); + const data = payload[0]?.payload; + if (!data) return null; + + return ( +
+
+ {formatFullTimestamp(data.timestamp)} +
+
+
+ + + Success + + + {data.success.toLocaleString()} + +
+
+ + + Error + + + {data.error.toLocaleString()} + +
+
+
+ ); } -export function LogVolumeChart({ data, chartType, startTime, endTime }: LogVolumeChartProps) { - const chartData = useMemo(() => { - if (!data?.buckets || !data.bucket_size_seconds) { - return []; - } +export function LogVolumeChart({ + data, + chartType, + startTime, + endTime, +}: LogVolumeChartProps) { + const chartData = useMemo(() => { + if (!data?.buckets || !data.bucket_size_seconds) { + return []; + } - return data.buckets.map((bucket, index) => ({ - ...bucket, - index, - formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds), - })); - }, [data]); + return data.buckets.map((bucket, index) => ({ + ...bucket, + index, + formattedTime: formatTimestamp( + bucket.timestamp, + data.bucket_size_seconds, + ), + })); + }, [data]); - if (!data?.buckets || chartData.length === 0) { - return
No data available
; - } + if (!data?.buckets || chartData.length === 0) { + return ( +
+ No data available +
+ ); + } - const commonProps = { - data: chartData, - margin: { top: 6, right: 4, left: 12, bottom: 0 }, - }; + const commonProps = { + data: chartData, + margin: { top: 6, right: 4, left: 12, bottom: 0 }, + }; - return ( - - - {chartType === "bar" ? ( - - - chartData[Math.round(idx)]?.formattedTime || ""} - interval="preserveStartEnd" - /> - v.toLocaleString()} - domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} - allowDataOverflow={false} - /> - } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> - - - - ) : ( - - - chartData[Math.round(idx)]?.formattedTime || ""} - interval="preserveStartEnd" - /> - v.toLocaleString()} - domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} - allowDataOverflow={false} - /> - } /> - - - - )} - - - ); -} \ No newline at end of file + return ( + + + {chartType === "bar" ? ( + + + + chartData[Math.round(idx)]?.formattedTime || "" + } + interval="preserveStartEnd" + /> + v.toLocaleString()} + domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} + allowDataOverflow={false} + /> + } + cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} + /> + + + + ) : ( + + + + chartData[Math.round(idx)]?.formattedTime || "" + } + interval="preserveStartEnd" + /> + v.toLocaleString()} + domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} + allowDataOverflow={false} + /> + } /> + + + + )} + + + ); +} diff --git a/ui/app/workspace/dashboard/components/overviewTab.tsx b/ui/app/workspace/dashboard/components/overviewTab.tsx index 5fdeff2b91..d3e6793677 100644 --- a/ui/app/workspace/dashboard/components/overviewTab.tsx +++ b/ui/app/workspace/dashboard/components/overviewTab.tsx @@ -1,18 +1,22 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import type { - CostHistogramResponse, - LatencyHistogramResponse, - LogsHistogramResponse, - ModelHistogramResponse, - TokenHistogramResponse, + CostHistogramResponse, + LatencyHistogramResponse, + LogsHistogramResponse, + ModelHistogramResponse, + TokenHistogramResponse, } from "@/lib/types/logs"; import { - CHART_COLORS, - CHART_HEADER_ACTIONS_CLASS, - CHART_HEADER_CONTROLS_CLASS, - CHART_HEADER_LEGEND_CLASS, - LATENCY_COLORS, - getModelColor, + CHART_COLORS, + CHART_HEADER_ACTIONS_CLASS, + CHART_HEADER_CONTROLS_CLASS, + CHART_HEADER_LEGEND_CLASS, + LATENCY_COLORS, + getModelColor, } from "../utils/chartUtils"; import CacheTokenMeterChart from "./charts/cacheTokenMeterChart"; import { ChartCard } from "./charts/chartCard"; @@ -25,323 +29,454 @@ import { ModelUsageChart } from "./charts/modelUsageChart"; import { TokenUsageChart } from "./charts/tokenUsageChart"; export interface OverviewTabProps { - // Data - histogramData: LogsHistogramResponse | null; - tokenData: TokenHistogramResponse | null; - costData: CostHistogramResponse | null; - modelData: ModelHistogramResponse | null; - latencyData: LatencyHistogramResponse | null; + // Data + histogramData: LogsHistogramResponse | null; + tokenData: TokenHistogramResponse | null; + costData: CostHistogramResponse | null; + modelData: ModelHistogramResponse | null; + latencyData: LatencyHistogramResponse | null; - // Loading states - loadingHistogram: boolean; - loadingTokens: boolean; - loadingCost: boolean; - loadingModels: boolean; - loadingLatency: boolean; + // Loading states + loadingHistogram: boolean; + loadingTokens: boolean; + loadingCost: boolean; + loadingModels: boolean; + loadingLatency: boolean; - // Time range - startTime: number; - endTime: number; + // Time range + startTime: number; + endTime: number; - // Chart types - volumeChartType: ChartType; - tokenChartType: ChartType; - costChartType: ChartType; - modelChartType: ChartType; - latencyChartType: ChartType; + // Chart types + volumeChartType: ChartType; + tokenChartType: ChartType; + costChartType: ChartType; + modelChartType: ChartType; + latencyChartType: ChartType; - // Model selections - costModel: string; - usageModel: string; + // Model selections + costModel: string; + usageModel: string; - // Derived model lists - costModels: string[]; - usageModels: string[]; - availableModels: string[]; + // Derived model lists + costModels: string[]; + usageModels: string[]; + availableModels: string[]; - // Chart type toggle callbacks - onVolumeChartToggle: (type: ChartType) => void; - onTokenChartToggle: (type: ChartType) => void; - onCostChartToggle: (type: ChartType) => void; - onModelChartToggle: (type: ChartType) => void; - onLatencyChartToggle: (type: ChartType) => void; + // Chart type toggle callbacks + onVolumeChartToggle: (type: ChartType) => void; + onTokenChartToggle: (type: ChartType) => void; + onCostChartToggle: (type: ChartType) => void; + onModelChartToggle: (type: ChartType) => void; + onLatencyChartToggle: (type: ChartType) => void; - // Filter callbacks - onCostModelChange: (model: string) => void; - onUsageModelChange: (model: string) => void; + // Filter callbacks + onCostModelChange: (model: string) => void; + onUsageModelChange: (model: string) => void; } export function OverviewTab({ - histogramData, - tokenData, - costData, - modelData, - latencyData, - loadingHistogram, - loadingTokens, - loadingCost, - loadingModels, - loadingLatency, - startTime, - endTime, - volumeChartType, - tokenChartType, - costChartType, - modelChartType, - latencyChartType, - costModel, - usageModel, - costModels, - usageModels, - availableModels, - onVolumeChartToggle, - onTokenChartToggle, - onCostChartToggle, - onModelChartToggle, - onLatencyChartToggle, - onCostModelChange, - onUsageModelChange, + histogramData, + tokenData, + costData, + modelData, + latencyData, + loadingHistogram, + loadingTokens, + loadingCost, + loadingModels, + loadingLatency, + startTime, + endTime, + volumeChartType, + tokenChartType, + costChartType, + modelChartType, + latencyChartType, + costModel, + usageModel, + costModels, + usageModels, + availableModels, + onVolumeChartToggle, + onTokenChartToggle, + onCostChartToggle, + onModelChartToggle, + onLatencyChartToggle, + onCostModelChange, + onUsageModelChange, }: OverviewTabProps) { - return ( - <> - {/* Charts Grid */} -
- {/* Log Volume Chart */} - -
- - - Success - - - - Error - -
-
- -
-
- } - > - - + return ( + <> + {/* Charts Grid */} +
+ {/* Log Volume Chart */} + +
+ + + Success + + + + Error + +
+
+ +
+
+ } + > + + - {/* Token Usage Chart */} - -
- - - Input - - - - Output - - - - Cached - -
-
- -
- - } - > - -
+ {/* Token Usage Chart */} + +
+ + + Input + + + + Output + + + + Cached + +
+
+ +
+ + } + > + +
- {/* Cache Hit Rate Meter */} - - - + {/* Cache Hit Rate Meter */} + + + - {/* Cost Chart */} - -
- {costModel === "all" ? ( - costModels.length > 0 && ( - <> - - - - - {costModels[0]} - - - {costModels[0]} - - {costModels.length > 1 && ( - - - - +{costModels.length - 1} more - - - -
- {costModels.slice(1).map((model, idx) => ( - - - {model} - - ))} -
-
-
- )} - - ) - ) : ( - - - - - {costModel} - - - {costModel} - - )} -
-
- - -
- - } - > - -
+ {/* Cost Chart */} + +
+ {costModel === "all" ? ( + costModels.length > 0 && ( + <> + + + + + + {costModels[0]} + + + + {costModels[0]} + + {costModels.length > 1 && ( + + + + +{costModels.length - 1} more + + + +
+ {costModels.slice(1).map((model, idx) => ( + + + {model} + + ))} +
+
+
+ )} + + ) + ) : ( + + + + + + {costModel} + + + + {costModel} + + )} +
+
+ + +
+ + } + > + +
- {/* Model Usage Chart */} - -
- {usageModel === "all" ? ( - usageModels.length > 0 && ( - <> - - - - - {usageModels[0]} - - - {usageModels[0]} - - {usageModels.length > 1 && ( - - - - +{usageModels.length - 1} more - - - -
- {usageModels.slice(1).map((model, idx) => ( - - - {model} - - ))} -
-
-
- )} - - ) - ) : ( - <> - - - Success - - - - Error - - - )} -
-
- - -
- - } - > - -
+ {/* Model Usage Chart */} + +
+ {usageModel === "all" ? ( + usageModels.length > 0 && ( + <> + + + + + + {usageModels[0]} + + + + {usageModels[0]} + + {usageModels.length > 1 && ( + + + + +{usageModels.length - 1} more + + + +
+ {usageModels.slice(1).map((model, idx) => ( + + + {model} + + ))} +
+
+
+ )} + + ) + ) : ( + <> + + + Success + + + + Error + + + )} +
+
+ + +
+ + } + > + +
- {/* Latency Chart */} - -
- - - Avg - - - - P90 - - - - P95 - - - - P99 - -
-
- -
- - } - > - -
- - - ); -} \ No newline at end of file + {/* Latency Chart */} + +
+ + + Avg + + + + P90 + + + + P95 + + + + P99 + +
+
+ +
+ + } + > + +
+ + + ); +} diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx index af26b87b39..230404bdec 100644 --- a/ui/app/workspace/logs/page.tsx +++ b/ui/app/workspace/logs/page.tsx @@ -11,1090 +11,1309 @@ 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"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useWebSocket } from "@/hooks/useWebSocket"; import { - getErrorMessage, - useDeleteLogsMutation, - useGetAvailableFilterDataQuery, - useLazyGetLogsHistogramQuery, - useLazyGetLogsQuery, - useLazyGetLogsStatsQuery, + getErrorMessage, + useDeleteLogsMutation, + useGetAvailableFilterDataQuery, + useLazyGetLogsHistogramQuery, + useLazyGetLogsQuery, + useLazyGetLogsStatsQuery, } from "@/lib/store"; import { useLazyGetLogByIdQuery } from "@/lib/store/apis/logsApi"; import type { - ChatMessage, - ChatMessageContent, - ContentBlock, - LogEntry, - LogFilters, - LogsHistogramResponse, - LogStats, - Pagination, + ChatMessage, + ChatMessageContent, + ContentBlock, + LogEntry, + LogFilters, + LogsHistogramResponse, + LogStats, + Pagination, } from "@/lib/types/logs"; import { dateUtils } from "@/lib/types/logs"; import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import NumberFlow from "@number-flow/react"; -import { AlertCircle, BarChart, CheckCircle, Clock, DollarSign, Hash, Info } from "lucide-react"; -import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { + AlertCircle, + BarChart, + CheckCircle, + Clock, + DollarSign, + Hash, + Info, +} from "lucide-react"; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + useQueryStates, +} from "nuqs"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export default function LogsPage() { - const [logs, setLogs] = useState([]); - const [totalItems, setTotalItems] = useState(0); // changes with filters - const [stats, setStats] = useState(null); - const [histogram, setHistogram] = useState(null); - const [initialLoading, setInitialLoading] = useState(true); // on initial load - const [fetchingLogs, setFetchingLogs] = useState(false); // on pagination/filters change - const [fetchingStats, setFetchingStats] = useState(false); // on stats fetch - const [fetchingHistogram, setFetchingHistogram] = useState(false); // on histogram fetch - const [error, setError] = useState(null); - const [showEmptyState, setShowEmptyState] = useState(false); - - const hasDeleteAccess = useRbac(RbacResource.Logs, RbacOperation.Delete); - - // RTK Query lazy hooks for manual triggering - const [triggerGetLogs] = useLazyGetLogsQuery(); - const [triggerGetStats] = useLazyGetLogsStatsQuery(); - const [triggerGetHistogram] = useLazyGetLogsHistogramQuery(); - const [deleteLogs] = useDeleteLogsMutation(); - - const [selectedSessionId, setSelectedSessionId] = useState(null); - const [sessionHighlightedLogId, setSessionHighlightedLogId] = useState(null); - // Stable handler so SessionDetailsSheet's loadSessionPage useCallback doesn't - // recreate on every parent re-render. Without this, every live WebSocket log - // tick would re-render LogsPage, hand the sheet a fresh inline arrow, recreate - // loadSessionPage, and trip the reset effect — wiping sessionLogs and - // refetching from offset 0 while the sheet is open. - const handleSessionSheetOpenChange = useCallback((open: boolean) => { - if (!open) { - setSelectedSessionId(null); - setSessionHighlightedLogId(null); - } - }, []); - const [isChartOpen, setIsChartOpen] = useState(true); - const [triggerGetLogById] = useLazyGetLogByIdQuery(); - const [fetchedLog, setFetchedLog] = useState(null); - - // Debouncing for streaming updates (client-side) - const streamingUpdateTimeouts = useRef>>(new Map()); - - // Track if user has manually modified the time range - const userModifiedTimeRange = useRef(false); - - // Capture initial defaults on mount to detect shared URLs with custom time ranges - const initialDefaults = useRef(dateUtils.getDefaultTimeRange()); - - // Memoize default time range to prevent recalculation on every render - // This is crucial to avoid triggering refetches when the sheet opens/closes - const defaultTimeRange = useMemo(() => dateUtils.getDefaultTimeRange(), []); - - // Get fresh default time range for refresh logic - const getDefaultTimeRange = () => dateUtils.getDefaultTimeRange(); - - // URL state management with nuqs - all filters and pagination in URL - const [urlState, setUrlState] = useQueryStates( - { - parent_request_id: parseAsString.withDefault(""), - providers: parseAsArrayOf(parseAsString).withDefault([]), - models: parseAsArrayOf(parseAsString).withDefault([]), - aliases: parseAsArrayOf(parseAsString).withDefault([]), - status: parseAsArrayOf(parseAsString).withDefault([]), - objects: parseAsArrayOf(parseAsString).withDefault([]), - selected_key_ids: parseAsArrayOf(parseAsString).withDefault([]), - virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]), - routing_rule_ids: parseAsArrayOf(parseAsString).withDefault([]), - routing_engine_used: parseAsArrayOf(parseAsString).withDefault([]), - user_ids: parseAsArrayOf(parseAsString).withDefault([]), - team_ids: parseAsArrayOf(parseAsString).withDefault([]), - customer_ids: parseAsArrayOf(parseAsString).withDefault([]), - business_unit_ids: parseAsArrayOf(parseAsString).withDefault([]), - content_search: parseAsString.withDefault(""), - start_time: parseAsInteger.withDefault(defaultTimeRange.startTime), - end_time: parseAsInteger.withDefault(defaultTimeRange.endTime), - limit: parseAsInteger.withDefault(25), // Default fallback, actual value calculated based on table height - offset: parseAsInteger.withDefault(0), - sort_by: parseAsString.withDefault("timestamp"), - order: parseAsString.withDefault("desc"), - live_enabled: parseAsBoolean.withDefault(true), - missing_cost_only: parseAsBoolean.withDefault(false), - metadata_filters: parseAsString.withDefault(""), - selected_log: parseAsString.withDefault(""), - }, - { - history: "push", - shallow: false, - }, - ); - - // Derive selectedLog: find in current logs array, or fetch by ID from API - const selectedLogId = urlState.selected_log || null; - const selectedLogFromData = useMemo( - () => (selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null), - [selectedLogId, logs], - ); - - const activeLogFetchId = useRef(null); - useEffect(() => { - if (!selectedLogId || selectedLogFromData) { - setFetchedLog(null); - activeLogFetchId.current = null; - return; - } - // Track which log ID this fetch is for to prevent stale responses - const fetchId = selectedLogId; - activeLogFetchId.current = fetchId; - triggerGetLogById(selectedLogId).then((result) => { - if (activeLogFetchId.current === fetchId) { - if (result.data) { - setFetchedLog(result.data); - } else if (result.error) { - setError(getErrorMessage(result.error)); - } - } - }); - }, [selectedLogId, selectedLogFromData, triggerGetLogById]); - - const selectedLog = selectedLogFromData ?? fetchedLog; - - // Refresh time range defaults on page focus/visibility - useEffect(() => { - const refreshDefaultsIfStale = () => { - // Skip refresh if user has manually modified the time range - if (userModifiedTimeRange.current) { - return; - } - - // Check if current time range matches the initial defaults (within tolerance) - const startTimeDiff = Math.abs(urlState.start_time - initialDefaults.current.startTime); - const endTimeDiff = Math.abs(urlState.end_time - initialDefaults.current.endTime); - const tolerance = 5; // 5 seconds tolerance for slight timing differences - - // Only refresh if current values match the initial defaults - // This preserves shared URLs with custom time ranges - if (startTimeDiff <= tolerance && endTimeDiff <= tolerance) { - const defaults = getDefaultTimeRange(); - const currentEndDiff = Math.abs(urlState.end_time - defaults.endTime); - // If end time is more than 5 minutes old, refresh both - if (currentEndDiff > 300) { - setUrlState({ - start_time: defaults.startTime, - end_time: defaults.endTime, - }); - // Update baseline so subsequent focus events compare against refreshed defaults - initialDefaults.current.startTime = defaults.startTime; - initialDefaults.current.endTime = defaults.endTime; - } - } - }; - - const handleVisibilityChange = () => { - if (!document.hidden) { - refreshDefaultsIfStale(); - } - }; - - const handleFocus = () => { - refreshDefaultsIfStale(); - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("focus", handleFocus); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("focus", handleFocus); - }; - }, [urlState.start_time, urlState.end_time, setUrlState]); - - // Convert URL state to filters and pagination for API calls - const filters: LogFilters = useMemo( - () => ({ - parent_request_id: urlState.parent_request_id, - providers: urlState.providers, - models: urlState.models, - aliases: urlState.aliases, - status: urlState.status, - objects: urlState.objects, - selected_key_ids: urlState.selected_key_ids, - virtual_key_ids: urlState.virtual_key_ids, - routing_rule_ids: urlState.routing_rule_ids, - routing_engine_used: urlState.routing_engine_used, - user_ids: urlState.user_ids, - team_ids: urlState.team_ids, - customer_ids: urlState.customer_ids, - business_unit_ids: urlState.business_unit_ids, - content_search: urlState.content_search, - start_time: dateUtils.toISOString(urlState.start_time), - end_time: dateUtils.toISOString(urlState.end_time), - missing_cost_only: urlState.missing_cost_only, - metadata_filters: urlState.metadata_filters - ? (() => { - try { - return JSON.parse(urlState.metadata_filters); - } catch { - return undefined; - } - })() - : undefined, - }), - // Only re-derive filters when filter-related URL params change (not pagination) - [ - urlState.providers, - urlState.models, - urlState.aliases, - urlState.status, - urlState.objects, - urlState.selected_key_ids, - urlState.virtual_key_ids, - urlState.routing_rule_ids, - urlState.routing_engine_used, - urlState.user_ids, - urlState.team_ids, - urlState.customer_ids, - urlState.business_unit_ids, - urlState.content_search, - urlState.parent_request_id, - urlState.start_time, - urlState.end_time, - urlState.missing_cost_only, - urlState.metadata_filters, - ], - ); - - const pagination: Pagination = useMemo( - () => ({ - limit: urlState.limit, - offset: urlState.offset, - sort_by: urlState.sort_by as "timestamp" | "latency" | "tokens" | "cost", - order: urlState.order as "asc" | "desc", - }), - [urlState.limit, urlState.offset, urlState.sort_by, urlState.order], - ); - - const liveEnabled = urlState.live_enabled; - - // Helper to update filters in URL - const setFilters = useCallback( - (newFilters: LogFilters) => { - // Mark time range as user-modified only if start_time or end_time actually changed - if (newFilters.start_time !== filters.start_time || newFilters.end_time !== filters.end_time) { - userModifiedTimeRange.current = true; - } - - setUrlState({ - parent_request_id: newFilters.parent_request_id || "", - providers: newFilters.providers || [], - models: newFilters.models || [], - aliases: newFilters.aliases || [], - status: newFilters.status || [], - objects: newFilters.objects || [], - selected_key_ids: newFilters.selected_key_ids || [], - virtual_key_ids: newFilters.virtual_key_ids || [], - routing_rule_ids: newFilters.routing_rule_ids || [], - routing_engine_used: newFilters.routing_engine_used || [], - user_ids: newFilters.user_ids || [], - team_ids: newFilters.team_ids || [], - customer_ids: newFilters.customer_ids || [], - business_unit_ids: newFilters.business_unit_ids || [], - 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 ?? false, - metadata_filters: newFilters.metadata_filters ? JSON.stringify(newFilters.metadata_filters) : "", - offset: 0, - }); - }, - [setUrlState, filters], - ); - - // Helper to update pagination in URL - const setPagination = useCallback( - (newPagination: Pagination) => { - setUrlState({ - limit: newPagination.limit, - offset: newPagination.offset, - sort_by: newPagination.sort_by, - order: newPagination.order, - }); - }, - [setUrlState], - ); - - // Handler for time range changes from the volume chart - const handleTimeRangeChange = useCallback( - (startTime: number, endTime: number) => { - setUrlState({ - start_time: startTime, - end_time: endTime, - offset: 0, - }); - }, - [setUrlState], - ); - - // Handler for resetting zoom to default 24h view - const handleResetZoom = useCallback(() => { - const now = Math.floor(Date.now() / 1000); - const twentyFourHoursAgo = now - 24 * 60 * 60; - setUrlState({ - start_time: twentyFourHoursAgo, - end_time: now, - offset: 0, - }); - }, [setUrlState]); - - // Check if user has zoomed (time range is different from default 24h) - const isZoomed = useMemo(() => { - const currentRange = urlState.end_time - urlState.start_time; - const defaultRange = 24 * 60 * 60; // 24 hours in seconds - // Consider zoomed if range is less than 90% of default (to account for minor differences) - return currentRange < defaultRange * 0.9; - }, [urlState.start_time, urlState.end_time]); - - const latest = useRef({ logs, filters, pagination, showEmptyState, liveEnabled }); - useEffect(() => { - latest.current = { logs, filters, pagination, showEmptyState, liveEnabled }; - }, [logs, filters, pagination, showEmptyState, liveEnabled]); - - const handleFilterByParentRequestId = useCallback( - (parentRequestId: string) => { - setSelectedSessionId(null); - setSessionHighlightedLogId(null); - setUrlState({ selected_log: "" }, { history: "replace" }); - setFilters({ - ...filters, - parent_request_id: parentRequestId, - }); - }, - [filters, setFilters], - ); - - const handleDelete = useCallback( - async (log: LogEntry) => { - try { - await deleteLogs({ ids: [log.id] }).unwrap(); - setLogs((prevLogs) => prevLogs.filter((l) => l.id !== log.id)); - setTotalItems((prev) => prev - 1); - // Clear selected log if it was the deleted one - if (urlState.selected_log === log.id) { - setUrlState({ selected_log: "" }); - } - } catch (error) { - setError(getErrorMessage(error)); - } - }, - [deleteLogs, urlState.selected_log, setUrlState], - ); - - const handleLogMessage = useCallback((log: LogEntry, operation: "create" | "update") => { - const { logs, filters, pagination, showEmptyState, liveEnabled } = latest.current; - // If we were in empty state, exit it since we now have logs - if (showEmptyState) { - setShowEmptyState(false); - } - - if (operation === "create") { - // Handle new log creation - // Only prepend the new log if we're on the first page and sorted by timestamp desc - if (pagination.offset === 0 && pagination.sort_by === "timestamp" && pagination.order === "desc") { - // Check if the log matches current filters - if (!matchesFilters(log, filters, !liveEnabled)) { - return; - } - - setLogs((prevLogs: LogEntry[]) => { - // Check if log already exists (prevent duplicates) - if (prevLogs.some((existingLog) => existingLog.id === log.id)) { - return prevLogs; - } - - // Remove the last log if we're at the page limit - const updatedLogs = [log, ...prevLogs]; - if (updatedLogs.length > pagination.limit) { - updatedLogs.pop(); - } - return updatedLogs; - }); - - // Update fetchedLog if it matches (for real-time detail sheet updates when log is not on current page) - setFetchedLog((prev) => { - if (prev && prev.id === log.id) { - return log; - } - return prev; - }); - - setTotalItems((prev: number) => prev + 1); - } - } else if (operation === "update") { - // Handle log updates with debouncing for streaming - - // Check if the log exists in our current list - const logExists = logs.some((existingLog) => existingLog.id === log.id); - - if (!logExists) { - // Fallback: if log doesn't exist, treat as create (e.g., user was on different page when created) - if (pagination.offset === 0 && pagination.sort_by === "timestamp" && pagination.order === "desc") { - // Check if the log matches current filters - if (matchesFilters(log, filters, !liveEnabled)) { - setLogs((prevLogs: LogEntry[]) => { - // Double-check it doesn't exist (race condition protection) - if (prevLogs.some((existingLog) => existingLog.id === log.id)) { - return prevLogs.map((existingLog) => (existingLog.id === log.id ? log : existingLog)); - } - - // Add as new log - const updatedLogs = [log, ...prevLogs]; - if (updatedLogs.length > pagination.limit) { - updatedLogs.pop(); - } - return updatedLogs; - }); - } - } - } else { - // Normal update flow for existing logs - if (log.stream) { - // For streaming logs, debounce updates to avoid UI thrashing - const existingTimeout = streamingUpdateTimeouts.current.get(log.id); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - - const timeout = setTimeout(() => { - updateExistingLog(log); - streamingUpdateTimeouts.current.delete(log.id); - }, 100); // 100ms debounce for streaming updates - - streamingUpdateTimeouts.current.set(log.id, timeout); - } else { - // For non-streaming updates, update immediately - updateExistingLog(log); - } - - // Update stats for completed requests - if (log.status == "success" || log.status == "error") { - setStats((prevStats) => { - if (!prevStats) return prevStats; - - const newStats = { ...prevStats }; - newStats.total_requests += 1; - - // Update success rate - const successCount = (prevStats.success_rate / 100) * prevStats.total_requests; - const newSuccessCount = log.status === "success" ? successCount + 1 : successCount; - newStats.success_rate = (newSuccessCount / newStats.total_requests) * 100; - - // Update user-facing success rate (same approximation as success_rate) - const userSuccessCount = ((prevStats.user_facing_success_rate ?? 0) / 100) * prevStats.total_requests; - const newUserSuccessCount = log.status === "success" ? userSuccessCount + 1 : userSuccessCount; - newStats.user_facing_success_rate = (newUserSuccessCount / newStats.total_requests) * 100; - - // Update average latency - if (log.latency) { - const totalLatency = prevStats.average_latency * prevStats.total_requests; - newStats.average_latency = (totalLatency + log.latency) / newStats.total_requests; - } - - // Update total tokens - if (log.token_usage) { - newStats.total_tokens += log.token_usage.total_tokens; - } - - // Update total cost - if (log.cost) { - newStats.total_cost += log.cost; - } - - return newStats; - }); - - // Update histogram for completed requests - setHistogram((prevHistogram) => { - if (!prevHistogram || typeof prevHistogram.bucket_size_seconds !== "number" || prevHistogram.bucket_size_seconds <= 0) { - return prevHistogram; - } - - const logTime = new Date(log.timestamp).getTime(); - const bucketSizeMs = prevHistogram.bucket_size_seconds * 1000; - const bucketTime = Math.floor(logTime / bucketSizeMs) * bucketSizeMs; - - const updatedBuckets = [...prevHistogram.buckets]; - const bucketIndex = updatedBuckets.findIndex((b) => { - const bTime = new Date(b.timestamp).getTime(); - return Math.floor(bTime / bucketSizeMs) * bucketSizeMs === bucketTime; - }); - - if (bucketIndex >= 0) { - // Update existing bucket - updatedBuckets[bucketIndex] = { - ...updatedBuckets[bucketIndex], - count: updatedBuckets[bucketIndex].count + 1, - success: updatedBuckets[bucketIndex].success + (log.status === "success" ? 1 : 0), - error: updatedBuckets[bucketIndex].error + (log.status === "error" ? 1 : 0), - }; - } else { - // Create new bucket for this timestamp - const newBucket = { - timestamp: new Date(bucketTime).toISOString(), - count: 1, - success: log.status === "success" ? 1 : 0, - error: log.status === "error" ? 1 : 0, - }; - // Insert in sorted order - const insertIndex = updatedBuckets.findIndex((b) => new Date(b.timestamp).getTime() > bucketTime); - if (insertIndex === -1) { - updatedBuckets.push(newBucket); - } else { - updatedBuckets.splice(insertIndex, 0, newBucket); - } - } - - return { ...prevHistogram, buckets: updatedBuckets }; - }); - } - } - } - }, []); - - const updateExistingLog = useCallback((updatedLog: LogEntry) => { - setLogs((prevLogs: LogEntry[]) => { - return prevLogs.map((existingLog) => (existingLog.id === updatedLog.id ? updatedLog : existingLog)); - }); - - // Update fetchedLog if it matches the updated log (for real-time detail sheet updates when log is not on current page) - setFetchedLog((prev) => { - if (prev && prev.id === updatedLog.id) { - return updatedLog; - } - return prev; - }); - }, []); - - const { isConnected: isSocketConnected, subscribe } = useWebSocket(); - - // Subscribe to log messages - only when live updates are enabled - useEffect(() => { - if (!liveEnabled) { - return; - } - - const unsubscribe = subscribe("log", (data) => { - const { payload, operation } = data; - handleLogMessage(payload, operation); - }); - - return unsubscribe; - }, [handleLogMessage, subscribe, liveEnabled]); - - // Cleanup timeouts on unmount - useEffect(() => { - return () => { - streamingUpdateTimeouts.current.forEach((timeout) => clearTimeout(timeout)); - streamingUpdateTimeouts.current.clear(); - }; - }, []); - - const fetchLogs = useCallback(async () => { - setFetchingLogs(true); - setError(null); - - try { - const result = await triggerGetLogs({ filters, pagination }); - - if (result.error) { - const errorMessage = getErrorMessage(result.error); - setError(errorMessage); - setLogs([]); - setTotalItems(0); - } else if (result.data) { - setLogs(result.data.logs || []); - setTotalItems(result.data.stats.total_requests); - } - - // Only set showEmptyState on initial load and only based on total logs - if (initialLoading) { - // Check if there are any logs globally, not just in the current filter - setShowEmptyState(result.data ? !result.data.has_logs : true); - } - } catch { - setError("Cannot fetch logs. Please check if logs are enabled in your Bifrost config."); - setLogs([]); - setTotalItems(0); - setShowEmptyState(true); - } finally { - setFetchingLogs(false); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, pagination]); - - const fetchStats = useCallback(async () => { - setFetchingStats(true); - try { - const result = await triggerGetStats({ filters }); - - if (result.error) { - // Don't show error for stats failure, just log it - console.error("Failed to fetch stats:", result.error); - } else if (result.data) { - setStats(result.data); - } - } catch (error) { - console.error("Failed to fetch stats:", error); - } finally { - setFetchingStats(false); - } - }, [filters, triggerGetStats]); - - const fetchHistogram = useCallback(async () => { - setFetchingHistogram(true); - - try { - const result = await triggerGetHistogram({ filters }); - - if (result.error) { - // Don't show error for histogram failure, just log it - console.error("Failed to fetch histogram:", result.error); - } else if (result.data) { - setHistogram(result.data); - } - } catch (error) { - console.error("Failed to fetch histogram:", error); - } finally { - setFetchingHistogram(false); - } - }, [filters, triggerGetHistogram]); - - // Helper to toggle live updates - const handleLiveToggle = useCallback( - (enabled: boolean) => { - setUrlState({ live_enabled: enabled }); - // When re-enabling, refetch logs to get latest data - if (enabled) { - fetchLogs(); - } - }, - [setUrlState, fetchLogs], - ); - - // Fetch logs when filters or pagination change - useEffect(() => { - if (!initialLoading) { - fetchLogs(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, pagination, initialLoading]); - - // Fetch stats and histogram when filters change (but not pagination) - useEffect(() => { - if (!initialLoading) { - fetchStats(); - fetchHistogram(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, initialLoading]); - - // Initial load - useEffect(() => { - const initialLoad = async () => { - // Load logs and stats in parallel, don't wait for stats to show the page - await fetchLogs(); - fetchStats(); // Don't await - let it load in background - fetchHistogram(); // Don't await - let it load in background - setInitialLoading(false); - }; - initialLoad(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const getMessageText = (content: ChatMessageContent): string => { - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - return content.reduce((acc: string, block: ContentBlock) => { - if (block.type === "text" && block.text) { - return acc + block.text; - } - return acc; - }, ""); - } - return ""; - }; - - // Helper function to check if a log matches the current filters - const matchesFilters = (log: LogEntry, filters: LogFilters, applyTimeFilters = true): boolean => { - if (filters.user_ids?.length) { - if (!log.user_id || !filters.user_ids.includes(log.user_id)) return false; - } - if (filters.team_ids?.length) { - if (!log.team_id || !filters.team_ids.includes(log.team_id)) return false; - } - if (filters.customer_ids?.length) { - if (!log.customer_id || !filters.customer_ids.includes(log.customer_id)) return false; - } - if (filters.business_unit_ids?.length) { - if (!log.business_unit_id || !filters.business_unit_ids.includes(log.business_unit_id)) return false; - } - if (filters.missing_cost_only && typeof log.cost === "number" && log.cost > 0) { - return false; - } - if (filters.parent_request_id && log.parent_request_id !== filters.parent_request_id) { - return false; - } - if (filters.providers?.length && !filters.providers.includes(log.provider)) { - return false; - } - if (filters.aliases?.length && !filters.aliases.includes(log.alias ?? "")) { - return false; - } - if (filters.models?.length && !filters.models.includes(log.model)) { - return false; - } - if (filters.status?.length && !filters.status.includes(log.status)) { - return false; - } - if (filters.objects?.length && !filters.objects.includes(log.object)) { - return false; - } - if (filters.selected_key_ids?.length && log.selected_key_id && !filters.selected_key_ids.includes(log.selected_key_id)) { - return false; - } - if (filters.virtual_key_ids?.length) { - if (!log.virtual_key_id || !filters.virtual_key_ids.includes(log.virtual_key_id)) { - return false; - } - } - if (filters.routing_rule_ids?.length) { - if (!log.routing_rule_id || !filters.routing_rule_ids.includes(log.routing_rule_id)) { - return false; - } - } - if (filters.routing_engine_used?.length) { - if (!log.routing_engines_used || !log.routing_engines_used.some((engine) => filters.routing_engine_used!.includes(engine))) { - return false; - } - } - if (filters.start_time && new Date(log.timestamp) < new Date(filters.start_time)) { - return false; - } - if (applyTimeFilters && filters.end_time && new Date(log.timestamp) > new Date(filters.end_time)) { - return false; - } - if (filters.min_latency && (!log.latency || log.latency < filters.min_latency)) { - return false; - } - if (filters.max_latency && (!log.latency || log.latency > filters.max_latency)) { - return false; - } - if (filters.min_tokens && (!log.token_usage || log.token_usage.total_tokens < filters.min_tokens)) { - return false; - } - if (filters.max_tokens && (!log.token_usage || log.token_usage.total_tokens > filters.max_tokens)) { - return false; - } - if (filters.metadata_filters) { - for (const [key, value] of Object.entries(filters.metadata_filters)) { - const metadataValue = log.metadata?.[key]; - if (metadataValue === undefined || String(metadataValue) !== value) { - return false; - } - } - } - if (filters.content_search) { - const search = filters.content_search.toLowerCase(); - const content = [ - ...(log.input_history || []).map((msg: ChatMessage) => getMessageText(msg.content)), - log.output_message ? getMessageText(log.output_message.content) : "", - ] - .join(" ") - .toLowerCase(); - - if (!content.includes(search)) { - return false; - } - } - return true; - }; - - const statCards = useMemo( - () => [ - { - title: "Total Requests", - value: , - icon: , - }, - { - title: "Success Rate", - value: , - 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.", - }, - { - title: "User Success Rate", - value: fetchingStats ? : stats ? `${(stats.user_facing_success_rate ?? 0).toFixed(2)}%` : "-", - icon: , - description: "Success rate as perceived by the end user. It includes fallback chains as one request.", - }, - { - title: "Avg Latency", - value: ( - - ), - icon: , - }, - { - title: "Total Tokens", - value: , - icon: , - }, - { - title: "Total Cost", - value: , - icon: , - }, - ], - [stats], - ); - - const { data: filterData } = useGetAvailableFilterDataQuery(); - - // Get metadata keys from filterdata API so columns always show even with no data on current page - const metadataKeys = useMemo(() => { - if (!filterData?.metadata_keys) return []; - return Object.keys(filterData.metadata_keys).sort(); - }, [filterData?.metadata_keys]); - - const columns = useMemo( - () => createColumns(handleDelete, hasDeleteAccess, metadataKeys), - [handleDelete, hasDeleteAccess, metadataKeys], - ); - - 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]); - - const handleLogNavigate = useCallback( - (direction: "prev" | "next") => { - const currentLogId = selectedLogId || ""; - if (direction === "prev") { - if (selectedLogIndex > 0) { - // Navigate to previous log on current page - setUrlState({ selected_log: logs[selectedLogIndex - 1].id }); - } else if (pagination.offset > 0) { - // Go to previous page and select the last item - const newOffset = Math.max(0, pagination.offset - pagination.limit); - setUrlState({ offset: newOffset, selected_log: "" }); - // Fetch previous page, then select last log - triggerGetLogs({ - filters, - pagination: { ...pagination, offset: newOffset }, - }).then((result) => { - if (result.data?.logs?.length) { - const lastLog = result.data.logs[result.data.logs.length - 1]; - setUrlState({ selected_log: lastLog.id }); - } else if (result.error) { - setUrlState({ offset: pagination.offset, selected_log: currentLogId }); - setError(getErrorMessage(result.error)); - } - }); - } - } else { - if (selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1) { - // Navigate to next log on current page - setUrlState({ selected_log: logs[selectedLogIndex + 1].id }); - } else if (pagination.offset + pagination.limit < totalItems) { - // Go to next page and select the first item - const newOffset = pagination.offset + pagination.limit; - setUrlState({ offset: newOffset, selected_log: "" }); - // Fetch next page, then select first log - triggerGetLogs({ - filters, - pagination: { ...pagination, offset: newOffset }, - }).then((result) => { - if (result.data?.logs?.length) { - const firstLog = result.data.logs[0]; - setUrlState({ selected_log: firstLog.id }); - } else if (result.error) { - setUrlState({ offset: pagination.offset, selected_log: currentLogId }); - setError(getErrorMessage(result.error)); - } - }); - } - } - }, - [selectedLogId, selectedLogIndex, logs, pagination, totalItems, filters, setUrlState, triggerGetLogs], - ); - - return ( -
- {initialLoading ? ( - - ) : showEmptyState ? ( - - ) : ( -
- {/* Sidebar Filters */} - - - {/* Main Content */} -
-
- -
-
- {statCards.map((card) => ( - - -
-
- {card.title} - {"description" in card && card.description && ( - - - - - {card.description} - - )} -
-
{card.value}
-
-
-
- ))} -
- -
- -
- - {error && ( - - - {error} - - )} - -
- { - if (columnId === "actions") return; - setUrlState({ selected_log: row.id }, { history: "replace" }); - setSelectedSessionId(null); - setSessionHighlightedLogId(null); - }} - liveEnabled={liveEnabled} - isSocketConnected={isSocketConnected} - columnEntries={columnEntries} - columnOrder={columnOrder} - columnVisibility={columnVisibility} - columnPinning={columnPinning} - onToggleColumnVisibility={toggleColumnVisibility} - onTogglePin={toggleColumnPin} - onReorderColumns={reorderColumns} - /> -
-
- - {/* Log Detail Sheet */} - !open && setUrlState({ selected_log: "" })} - handleDelete={handleDelete} - onNavigate={handleLogNavigate} - hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)} - hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)} - onFilterByParentRequestId={handleFilterByParentRequestId} - onViewSession={(sessionId, logId) => { - setUrlState({ selected_log: "" }, { history: "replace" }); - setSessionHighlightedLogId(logId); - setSelectedSessionId(sessionId); - }} - /> - { - setSelectedSessionId(null); - setUrlState({ selected_log: log.id }, { history: "replace" }); - }} - onFilterByParentRequestId={handleFilterByParentRequestId} - /> -
- )} -
- ); -} \ No newline at end of file + const [logs, setLogs] = useState([]); + const [totalItems, setTotalItems] = useState(0); // changes with filters + const [stats, setStats] = useState(null); + const [histogram, setHistogram] = useState( + null, + ); + const [initialLoading, setInitialLoading] = useState(true); // on initial load + const [fetchingLogs, setFetchingLogs] = useState(false); // on pagination/filters change + const [fetchingStats, setFetchingStats] = useState(false); // on stats fetch + const [fetchingHistogram, setFetchingHistogram] = useState(false); // on histogram fetch + const [error, setError] = useState(null); + const [showEmptyState, setShowEmptyState] = useState(false); + + const hasDeleteAccess = useRbac(RbacResource.Logs, RbacOperation.Delete); + + // RTK Query lazy hooks for manual triggering + const [triggerGetLogs] = useLazyGetLogsQuery(); + const [triggerGetStats] = useLazyGetLogsStatsQuery(); + const [triggerGetHistogram] = useLazyGetLogsHistogramQuery(); + const [deleteLogs] = useDeleteLogsMutation(); + + const [selectedSessionId, setSelectedSessionId] = useState( + null, + ); + const [sessionHighlightedLogId, setSessionHighlightedLogId] = useState< + string | null + >(null); + // Stable handler so SessionDetailsSheet's loadSessionPage useCallback doesn't + // recreate on every parent re-render. Without this, every live WebSocket log + // tick would re-render LogsPage, hand the sheet a fresh inline arrow, recreate + // loadSessionPage, and trip the reset effect — wiping sessionLogs and + // refetching from offset 0 while the sheet is open. + const handleSessionSheetOpenChange = useCallback((open: boolean) => { + if (!open) { + setSelectedSessionId(null); + setSessionHighlightedLogId(null); + } + }, []); + const [isChartOpen, setIsChartOpen] = useState(true); + const [triggerGetLogById] = useLazyGetLogByIdQuery(); + const [fetchedLog, setFetchedLog] = useState(null); + + // Debouncing for streaming updates (client-side) + const streamingUpdateTimeouts = useRef< + Map> + >(new Map()); + + // Track if user has manually modified the time range + const userModifiedTimeRange = useRef(false); + + // Capture initial defaults on mount to detect shared URLs with custom time ranges + const initialDefaults = useRef(dateUtils.getDefaultTimeRange()); + + // Memoize default time range to prevent recalculation on every render + // This is crucial to avoid triggering refetches when the sheet opens/closes + const defaultTimeRange = useMemo(() => dateUtils.getDefaultTimeRange(), []); + + // Get fresh default time range for refresh logic + const getDefaultTimeRange = () => dateUtils.getDefaultTimeRange(); + + // URL state management with nuqs - all filters and pagination in URL + const [urlState, setUrlState] = useQueryStates( + { + parent_request_id: parseAsString.withDefault(""), + providers: parseAsArrayOf(parseAsString).withDefault([]), + models: parseAsArrayOf(parseAsString).withDefault([]), + aliases: parseAsArrayOf(parseAsString).withDefault([]), + status: parseAsArrayOf(parseAsString).withDefault([]), + objects: parseAsArrayOf(parseAsString).withDefault([]), + selected_key_ids: parseAsArrayOf(parseAsString).withDefault([]), + virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]), + routing_rule_ids: parseAsArrayOf(parseAsString).withDefault([]), + routing_engine_used: parseAsArrayOf(parseAsString).withDefault([]), + user_ids: parseAsArrayOf(parseAsString).withDefault([]), + team_ids: parseAsArrayOf(parseAsString).withDefault([]), + customer_ids: parseAsArrayOf(parseAsString).withDefault([]), + business_unit_ids: parseAsArrayOf(parseAsString).withDefault([]), + content_search: parseAsString.withDefault(""), + start_time: parseAsInteger.withDefault(defaultTimeRange.startTime), + end_time: parseAsInteger.withDefault(defaultTimeRange.endTime), + limit: parseAsInteger.withDefault(25), // Default fallback, actual value calculated based on table height + offset: parseAsInteger.withDefault(0), + sort_by: parseAsString.withDefault("timestamp"), + order: parseAsString.withDefault("desc"), + live_enabled: parseAsBoolean.withDefault(true), + missing_cost_only: parseAsBoolean.withDefault(false), + metadata_filters: parseAsString.withDefault(""), + selected_log: parseAsString.withDefault(""), + }, + { + history: "push", + shallow: false, + }, + ); + + // Derive selectedLog: find in current logs array, or fetch by ID from API + const selectedLogId = urlState.selected_log || null; + const selectedLogFromData = useMemo( + () => + selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null, + [selectedLogId, logs], + ); + + const activeLogFetchId = useRef(null); + useEffect(() => { + if (!selectedLogId || selectedLogFromData) { + setFetchedLog(null); + activeLogFetchId.current = null; + return; + } + // Track which log ID this fetch is for to prevent stale responses + const fetchId = selectedLogId; + activeLogFetchId.current = fetchId; + triggerGetLogById(selectedLogId).then((result) => { + if (activeLogFetchId.current === fetchId) { + if (result.data) { + setFetchedLog(result.data); + } else if (result.error) { + setError(getErrorMessage(result.error)); + } + } + }); + }, [selectedLogId, selectedLogFromData, triggerGetLogById]); + + const selectedLog = selectedLogFromData ?? fetchedLog; + + // Refresh time range defaults on page focus/visibility + useEffect(() => { + const refreshDefaultsIfStale = () => { + // Skip refresh if user has manually modified the time range + if (userModifiedTimeRange.current) { + return; + } + + // Check if current time range matches the initial defaults (within tolerance) + const startTimeDiff = Math.abs( + urlState.start_time - initialDefaults.current.startTime, + ); + const endTimeDiff = Math.abs( + urlState.end_time - initialDefaults.current.endTime, + ); + const tolerance = 5; // 5 seconds tolerance for slight timing differences + + // Only refresh if current values match the initial defaults + // This preserves shared URLs with custom time ranges + if (startTimeDiff <= tolerance && endTimeDiff <= tolerance) { + const defaults = getDefaultTimeRange(); + const currentEndDiff = Math.abs(urlState.end_time - defaults.endTime); + // If end time is more than 5 minutes old, refresh both + if (currentEndDiff > 300) { + setUrlState({ + start_time: defaults.startTime, + end_time: defaults.endTime, + }); + // Update baseline so subsequent focus events compare against refreshed defaults + initialDefaults.current.startTime = defaults.startTime; + initialDefaults.current.endTime = defaults.endTime; + } + } + }; + + const handleVisibilityChange = () => { + if (!document.hidden) { + refreshDefaultsIfStale(); + } + }; + + const handleFocus = () => { + refreshDefaultsIfStale(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleFocus); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + }; + }, [urlState.start_time, urlState.end_time, setUrlState]); + + // Convert URL state to filters and pagination for API calls + const filters: LogFilters = useMemo( + () => ({ + parent_request_id: urlState.parent_request_id, + providers: urlState.providers, + models: urlState.models, + aliases: urlState.aliases, + status: urlState.status, + objects: urlState.objects, + selected_key_ids: urlState.selected_key_ids, + virtual_key_ids: urlState.virtual_key_ids, + routing_rule_ids: urlState.routing_rule_ids, + routing_engine_used: urlState.routing_engine_used, + user_ids: urlState.user_ids, + team_ids: urlState.team_ids, + customer_ids: urlState.customer_ids, + business_unit_ids: urlState.business_unit_ids, + content_search: urlState.content_search, + start_time: dateUtils.toISOString(urlState.start_time), + end_time: dateUtils.toISOString(urlState.end_time), + missing_cost_only: urlState.missing_cost_only, + metadata_filters: urlState.metadata_filters + ? (() => { + try { + return JSON.parse(urlState.metadata_filters); + } catch { + return undefined; + } + })() + : undefined, + }), + // Only re-derive filters when filter-related URL params change (not pagination) + [ + urlState.providers, + urlState.models, + urlState.aliases, + urlState.status, + urlState.objects, + urlState.selected_key_ids, + urlState.virtual_key_ids, + urlState.routing_rule_ids, + urlState.routing_engine_used, + urlState.user_ids, + urlState.team_ids, + urlState.customer_ids, + urlState.business_unit_ids, + urlState.content_search, + urlState.parent_request_id, + urlState.start_time, + urlState.end_time, + urlState.missing_cost_only, + urlState.metadata_filters, + ], + ); + + const pagination: Pagination = useMemo( + () => ({ + limit: urlState.limit, + offset: urlState.offset, + sort_by: urlState.sort_by as "timestamp" | "latency" | "tokens" | "cost", + order: urlState.order as "asc" | "desc", + }), + [urlState.limit, urlState.offset, urlState.sort_by, urlState.order], + ); + + const liveEnabled = urlState.live_enabled; + + // Helper to update filters in URL + const setFilters = useCallback( + (newFilters: LogFilters) => { + // Mark time range as user-modified only if start_time or end_time actually changed + if ( + newFilters.start_time !== filters.start_time || + newFilters.end_time !== filters.end_time + ) { + userModifiedTimeRange.current = true; + } + + setUrlState({ + parent_request_id: newFilters.parent_request_id || "", + providers: newFilters.providers || [], + models: newFilters.models || [], + aliases: newFilters.aliases || [], + status: newFilters.status || [], + objects: newFilters.objects || [], + selected_key_ids: newFilters.selected_key_ids || [], + virtual_key_ids: newFilters.virtual_key_ids || [], + routing_rule_ids: newFilters.routing_rule_ids || [], + routing_engine_used: newFilters.routing_engine_used || [], + user_ids: newFilters.user_ids || [], + team_ids: newFilters.team_ids || [], + customer_ids: newFilters.customer_ids || [], + business_unit_ids: newFilters.business_unit_ids || [], + 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 ?? false, + metadata_filters: newFilters.metadata_filters + ? JSON.stringify(newFilters.metadata_filters) + : "", + offset: 0, + }); + }, + [setUrlState, filters], + ); + + // Helper to update pagination in URL + const setPagination = useCallback( + (newPagination: Pagination) => { + setUrlState({ + limit: newPagination.limit, + offset: newPagination.offset, + sort_by: newPagination.sort_by, + order: newPagination.order, + }); + }, + [setUrlState], + ); + + // Handler for time range changes from the volume chart + const handleTimeRangeChange = useCallback( + (startTime: number, endTime: number) => { + setUrlState({ + start_time: startTime, + end_time: endTime, + offset: 0, + }); + }, + [setUrlState], + ); + + // Handler for resetting zoom to default 24h view + const handleResetZoom = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + const twentyFourHoursAgo = now - 24 * 60 * 60; + setUrlState({ + start_time: twentyFourHoursAgo, + end_time: now, + offset: 0, + }); + }, [setUrlState]); + + // Check if user has zoomed (time range is different from default 24h) + const isZoomed = useMemo(() => { + const currentRange = urlState.end_time - urlState.start_time; + const defaultRange = 24 * 60 * 60; // 24 hours in seconds + // Consider zoomed if range is less than 90% of default (to account for minor differences) + return currentRange < defaultRange * 0.9; + }, [urlState.start_time, urlState.end_time]); + + const latest = useRef({ + logs, + filters, + pagination, + showEmptyState, + liveEnabled, + }); + useEffect(() => { + latest.current = { logs, filters, pagination, showEmptyState, liveEnabled }; + }, [logs, filters, pagination, showEmptyState, liveEnabled]); + + const handleFilterByParentRequestId = useCallback( + (parentRequestId: string) => { + setSelectedSessionId(null); + setSessionHighlightedLogId(null); + setUrlState({ selected_log: "" }, { history: "replace" }); + setFilters({ + ...filters, + parent_request_id: parentRequestId, + }); + }, + [filters, setFilters], + ); + + const handleDelete = useCallback( + async (log: LogEntry) => { + try { + await deleteLogs({ ids: [log.id] }).unwrap(); + setLogs((prevLogs) => prevLogs.filter((l) => l.id !== log.id)); + setTotalItems((prev) => prev - 1); + // Clear selected log if it was the deleted one + if (urlState.selected_log === log.id) { + setUrlState({ selected_log: "" }); + } + } catch (error) { + setError(getErrorMessage(error)); + } + }, + [deleteLogs, urlState.selected_log, setUrlState], + ); + + const handleLogMessage = useCallback( + (log: LogEntry, operation: "create" | "update") => { + const { logs, filters, pagination, showEmptyState, liveEnabled } = + latest.current; + // If we were in empty state, exit it since we now have logs + if (showEmptyState) { + setShowEmptyState(false); + } + + if (operation === "create") { + // Handle new log creation + // Only prepend the new log if we're on the first page and sorted by timestamp desc + if ( + pagination.offset === 0 && + pagination.sort_by === "timestamp" && + pagination.order === "desc" + ) { + // Check if the log matches current filters + if (!matchesFilters(log, filters, !liveEnabled)) { + return; + } + + setLogs((prevLogs: LogEntry[]) => { + // Check if log already exists (prevent duplicates) + if (prevLogs.some((existingLog) => existingLog.id === log.id)) { + return prevLogs; + } + + // Remove the last log if we're at the page limit + const updatedLogs = [log, ...prevLogs]; + if (updatedLogs.length > pagination.limit) { + updatedLogs.pop(); + } + return updatedLogs; + }); + + // Update fetchedLog if it matches (for real-time detail sheet updates when log is not on current page) + setFetchedLog((prev) => { + if (prev && prev.id === log.id) { + return log; + } + return prev; + }); + + setTotalItems((prev: number) => prev + 1); + } + } else if (operation === "update") { + // Handle log updates with debouncing for streaming + + // Check if the log exists in our current list + const logExists = logs.some((existingLog) => existingLog.id === log.id); + + if (!logExists) { + // Fallback: if log doesn't exist, treat as create (e.g., user was on different page when created) + if ( + pagination.offset === 0 && + pagination.sort_by === "timestamp" && + pagination.order === "desc" + ) { + // Check if the log matches current filters + if (matchesFilters(log, filters, !liveEnabled)) { + setLogs((prevLogs: LogEntry[]) => { + // Double-check it doesn't exist (race condition protection) + if (prevLogs.some((existingLog) => existingLog.id === log.id)) { + return prevLogs.map((existingLog) => + existingLog.id === log.id ? log : existingLog, + ); + } + + // Add as new log + const updatedLogs = [log, ...prevLogs]; + if (updatedLogs.length > pagination.limit) { + updatedLogs.pop(); + } + return updatedLogs; + }); + } + } + } else { + // Normal update flow for existing logs + if (log.stream) { + // For streaming logs, debounce updates to avoid UI thrashing + const existingTimeout = streamingUpdateTimeouts.current.get(log.id); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + const timeout = setTimeout(() => { + updateExistingLog(log); + streamingUpdateTimeouts.current.delete(log.id); + }, 100); // 100ms debounce for streaming updates + + streamingUpdateTimeouts.current.set(log.id, timeout); + } else { + // For non-streaming updates, update immediately + updateExistingLog(log); + } + + // Update stats for completed requests + if (log.status == "success" || log.status == "error") { + setStats((prevStats) => { + if (!prevStats) return prevStats; + + const newStats = { ...prevStats }; + newStats.total_requests += 1; + + // Update success rate + const successCount = + (prevStats.success_rate / 100) * prevStats.total_requests; + const newSuccessCount = + log.status === "success" ? successCount + 1 : successCount; + newStats.success_rate = + (newSuccessCount / newStats.total_requests) * 100; + + // Update user-facing success rate (same approximation as success_rate) + const userSuccessCount = + ((prevStats.user_facing_success_rate ?? 0) / 100) * + prevStats.total_requests; + const newUserSuccessCount = + log.status === "success" + ? userSuccessCount + 1 + : userSuccessCount; + newStats.user_facing_success_rate = + (newUserSuccessCount / newStats.total_requests) * 100; + + // Update average latency + if (log.latency) { + const totalLatency = + prevStats.average_latency * prevStats.total_requests; + newStats.average_latency = + (totalLatency + log.latency) / newStats.total_requests; + } + + // Update total tokens + if (log.token_usage) { + newStats.total_tokens += log.token_usage.total_tokens; + } + + // Update total cost + if (log.cost) { + newStats.total_cost += log.cost; + } + + return newStats; + }); + + // Update histogram for completed requests + setHistogram((prevHistogram) => { + if ( + !prevHistogram || + typeof prevHistogram.bucket_size_seconds !== "number" || + prevHistogram.bucket_size_seconds <= 0 + ) { + return prevHistogram; + } + + const logTime = new Date(log.timestamp).getTime(); + const bucketSizeMs = prevHistogram.bucket_size_seconds * 1000; + const bucketTime = + Math.floor(logTime / bucketSizeMs) * bucketSizeMs; + + const updatedBuckets = [...prevHistogram.buckets]; + const bucketIndex = updatedBuckets.findIndex((b) => { + const bTime = new Date(b.timestamp).getTime(); + return ( + Math.floor(bTime / bucketSizeMs) * bucketSizeMs === bucketTime + ); + }); + + if (bucketIndex >= 0) { + // Update existing bucket + updatedBuckets[bucketIndex] = { + ...updatedBuckets[bucketIndex], + count: updatedBuckets[bucketIndex].count + 1, + success: + updatedBuckets[bucketIndex].success + + (log.status === "success" ? 1 : 0), + error: + updatedBuckets[bucketIndex].error + + (log.status === "error" ? 1 : 0), + }; + } else { + // Create new bucket for this timestamp + const newBucket = { + timestamp: new Date(bucketTime).toISOString(), + count: 1, + success: log.status === "success" ? 1 : 0, + error: log.status === "error" ? 1 : 0, + }; + // Insert in sorted order + const insertIndex = updatedBuckets.findIndex( + (b) => new Date(b.timestamp).getTime() > bucketTime, + ); + if (insertIndex === -1) { + updatedBuckets.push(newBucket); + } else { + updatedBuckets.splice(insertIndex, 0, newBucket); + } + } + + return { ...prevHistogram, buckets: updatedBuckets }; + }); + } + } + } + }, + [], + ); + + const updateExistingLog = useCallback((updatedLog: LogEntry) => { + setLogs((prevLogs: LogEntry[]) => { + return prevLogs.map((existingLog) => + existingLog.id === updatedLog.id ? updatedLog : existingLog, + ); + }); + + // Update fetchedLog if it matches the updated log (for real-time detail sheet updates when log is not on current page) + setFetchedLog((prev) => { + if (prev && prev.id === updatedLog.id) { + return updatedLog; + } + return prev; + }); + }, []); + + const { isConnected: isSocketConnected, subscribe } = useWebSocket(); + + // Subscribe to log messages - only when live updates are enabled + useEffect(() => { + if (!liveEnabled) { + return; + } + + const unsubscribe = subscribe("log", (data) => { + const { payload, operation } = data; + handleLogMessage(payload, operation); + }); + + return unsubscribe; + }, [handleLogMessage, subscribe, liveEnabled]); + + // Cleanup timeouts on unmount + useEffect(() => { + return () => { + streamingUpdateTimeouts.current.forEach((timeout) => + clearTimeout(timeout), + ); + streamingUpdateTimeouts.current.clear(); + }; + }, []); + + const fetchLogs = useCallback(async () => { + setFetchingLogs(true); + setError(null); + + try { + const result = await triggerGetLogs({ filters, pagination }); + + if (result.error) { + const errorMessage = getErrorMessage(result.error); + setError(errorMessage); + setLogs([]); + setTotalItems(0); + } else if (result.data) { + setLogs(result.data.logs || []); + setTotalItems(result.data.stats.total_requests); + } + + // Only set showEmptyState on initial load and only based on total logs + if (initialLoading) { + // Check if there are any logs globally, not just in the current filter + setShowEmptyState(result.data ? !result.data.has_logs : true); + } + } catch { + setError( + "Cannot fetch logs. Please check if logs are enabled in your Bifrost config.", + ); + setLogs([]); + setTotalItems(0); + setShowEmptyState(true); + } finally { + setFetchingLogs(false); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, pagination]); + + const fetchStats = useCallback(async () => { + setFetchingStats(true); + try { + const result = await triggerGetStats({ filters }); + + if (result.error) { + // Don't show error for stats failure, just log it + console.error("Failed to fetch stats:", result.error); + } else if (result.data) { + setStats(result.data); + } + } catch (error) { + console.error("Failed to fetch stats:", error); + } finally { + setFetchingStats(false); + } + }, [filters, triggerGetStats]); + + const fetchHistogram = useCallback(async () => { + setFetchingHistogram(true); + + try { + const result = await triggerGetHistogram({ filters }); + + if (result.error) { + // Don't show error for histogram failure, just log it + console.error("Failed to fetch histogram:", result.error); + } else if (result.data) { + setHistogram(result.data); + } + } catch (error) { + console.error("Failed to fetch histogram:", error); + } finally { + setFetchingHistogram(false); + } + }, [filters, triggerGetHistogram]); + + // Helper to toggle live updates + const handleLiveToggle = useCallback( + (enabled: boolean) => { + setUrlState({ live_enabled: enabled }); + // When re-enabling, refetch logs to get latest data + if (enabled) { + fetchLogs(); + } + }, + [setUrlState, fetchLogs], + ); + + // Fetch logs when filters or pagination change + useEffect(() => { + if (!initialLoading) { + fetchLogs(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, pagination, initialLoading]); + + // Fetch stats and histogram when filters change (but not pagination) + useEffect(() => { + if (!initialLoading) { + fetchStats(); + fetchHistogram(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, initialLoading]); + + // Initial load + useEffect(() => { + const initialLoad = async () => { + // Load logs and stats in parallel, don't wait for stats to show the page + await fetchLogs(); + fetchStats(); // Don't await - let it load in background + fetchHistogram(); // Don't await - let it load in background + setInitialLoading(false); + }; + initialLoad(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getMessageText = (content: ChatMessageContent): string => { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content.reduce((acc: string, block: ContentBlock) => { + if (block.type === "text" && block.text) { + return acc + block.text; + } + return acc; + }, ""); + } + return ""; + }; + + // Helper function to check if a log matches the current filters + const matchesFilters = ( + log: LogEntry, + filters: LogFilters, + applyTimeFilters = true, + ): boolean => { + if (filters.user_ids?.length) { + if (!log.user_id || !filters.user_ids.includes(log.user_id)) return false; + } + if (filters.team_ids?.length) { + if (!log.team_id || !filters.team_ids.includes(log.team_id)) return false; + } + if (filters.customer_ids?.length) { + if (!log.customer_id || !filters.customer_ids.includes(log.customer_id)) + return false; + } + if (filters.business_unit_ids?.length) { + if ( + !log.business_unit_id || + !filters.business_unit_ids.includes(log.business_unit_id) + ) + return false; + } + if ( + filters.missing_cost_only && + typeof log.cost === "number" && + log.cost > 0 + ) { + return false; + } + if ( + filters.parent_request_id && + log.parent_request_id !== filters.parent_request_id + ) { + return false; + } + if ( + filters.providers?.length && + !filters.providers.includes(log.provider) + ) { + return false; + } + if (filters.aliases?.length && !filters.aliases.includes(log.alias ?? "")) { + return false; + } + if (filters.models?.length && !filters.models.includes(log.model)) { + return false; + } + if (filters.status?.length && !filters.status.includes(log.status)) { + return false; + } + if (filters.objects?.length && !filters.objects.includes(log.object)) { + return false; + } + if ( + filters.selected_key_ids?.length && + log.selected_key_id && + !filters.selected_key_ids.includes(log.selected_key_id) + ) { + return false; + } + if (filters.virtual_key_ids?.length) { + if ( + !log.virtual_key_id || + !filters.virtual_key_ids.includes(log.virtual_key_id) + ) { + return false; + } + } + if (filters.routing_rule_ids?.length) { + if ( + !log.routing_rule_id || + !filters.routing_rule_ids.includes(log.routing_rule_id) + ) { + return false; + } + } + if (filters.routing_engine_used?.length) { + if ( + !log.routing_engines_used || + !log.routing_engines_used.some((engine) => + filters.routing_engine_used!.includes(engine), + ) + ) { + return false; + } + } + if ( + filters.start_time && + new Date(log.timestamp) < new Date(filters.start_time) + ) { + return false; + } + if ( + applyTimeFilters && + filters.end_time && + new Date(log.timestamp) > new Date(filters.end_time) + ) { + return false; + } + if ( + filters.min_latency && + (!log.latency || log.latency < filters.min_latency) + ) { + return false; + } + if ( + filters.max_latency && + (!log.latency || log.latency > filters.max_latency) + ) { + return false; + } + if ( + filters.min_tokens && + (!log.token_usage || log.token_usage.total_tokens < filters.min_tokens) + ) { + return false; + } + if ( + filters.max_tokens && + (!log.token_usage || log.token_usage.total_tokens > filters.max_tokens) + ) { + return false; + } + if (filters.metadata_filters) { + for (const [key, value] of Object.entries(filters.metadata_filters)) { + const metadataValue = log.metadata?.[key]; + if (metadataValue === undefined || String(metadataValue) !== value) { + return false; + } + } + } + if (filters.content_search) { + const search = filters.content_search.toLowerCase(); + const content = [ + ...(log.input_history || []).map((msg: ChatMessage) => + getMessageText(msg.content), + ), + log.output_message ? getMessageText(log.output_message.content) : "", + ] + .join(" ") + .toLowerCase(); + + if (!content.includes(search)) { + return false; + } + } + return true; + }; + + const statCards = useMemo( + () => [ + { + title: "Total Requests", + value: ( + + ), + icon: , + }, + { + title: "Success Rate", + value: ( + + ), + 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.", + }, + { + title: "User Success Rate", + value: fetchingStats ? ( + + ) : stats ? ( + `${(stats.user_facing_success_rate ?? 0).toFixed(2)}%` + ) : ( + "-" + ), + icon: , + description: + "Success rate as perceived by the end user. It includes fallback chains as one request.", + }, + { + title: "Avg Latency", + value: ( + + ), + icon: , + }, + { + title: "Total Tokens", + value: ( + + ), + icon: , + }, + { + title: "Total Cost", + value: ( + + ), + icon: , + }, + ], + [stats], + ); + + const { data: filterData } = useGetAvailableFilterDataQuery(); + + // Get metadata keys from filterdata API so columns always show even with no data on current page + const metadataKeys = useMemo(() => { + if (!filterData?.metadata_keys) return []; + return Object.keys(filterData.metadata_keys).sort(); + }, [filterData?.metadata_keys]); + + const columns = useMemo( + () => createColumns(handleDelete, hasDeleteAccess, metadataKeys), + [handleDelete, hasDeleteAccess, metadataKeys], + ); + + 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], + ); + + const handleLogNavigate = useCallback( + (direction: "prev" | "next") => { + const currentLogId = selectedLogId || ""; + if (direction === "prev") { + if (selectedLogIndex > 0) { + // Navigate to previous log on current page + setUrlState({ selected_log: logs[selectedLogIndex - 1].id }); + } else if (pagination.offset > 0) { + // Go to previous page and select the last item + const newOffset = Math.max(0, pagination.offset - pagination.limit); + setUrlState({ offset: newOffset, selected_log: "" }); + // Fetch previous page, then select last log + triggerGetLogs({ + filters, + pagination: { ...pagination, offset: newOffset }, + }).then((result) => { + if (result.data?.logs?.length) { + const lastLog = result.data.logs[result.data.logs.length - 1]; + setUrlState({ selected_log: lastLog.id }); + } else if (result.error) { + setUrlState({ + offset: pagination.offset, + selected_log: currentLogId, + }); + setError(getErrorMessage(result.error)); + } + }); + } + } else { + if (selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1) { + // Navigate to next log on current page + setUrlState({ selected_log: logs[selectedLogIndex + 1].id }); + } else if (pagination.offset + pagination.limit < totalItems) { + // Go to next page and select the first item + const newOffset = pagination.offset + pagination.limit; + setUrlState({ offset: newOffset, selected_log: "" }); + // Fetch next page, then select first log + triggerGetLogs({ + filters, + pagination: { ...pagination, offset: newOffset }, + }).then((result) => { + if (result.data?.logs?.length) { + const firstLog = result.data.logs[0]; + setUrlState({ selected_log: firstLog.id }); + } else if (result.error) { + setUrlState({ + offset: pagination.offset, + selected_log: currentLogId, + }); + setError(getErrorMessage(result.error)); + } + }); + } + } + }, + [ + selectedLogId, + selectedLogIndex, + logs, + pagination, + totalItems, + filters, + setUrlState, + triggerGetLogs, + ], + ); + + return ( +
+ {initialLoading ? ( + + ) : showEmptyState ? ( + + ) : ( +
+ {/* Sidebar Filters */} + + + {/* Main Content */} +
+
+ +
+
+ {statCards.map((card) => ( + + +
+
+ {card.title} + {"description" in card && card.description && ( + + + + + + {card.description} + + + )} +
+
+ {card.value} +
+
+
+
+ ))} +
+ +
+ +
+ + {error && ( + + + {error} + + )} + +
+ { + if (columnId === "actions") return; + setUrlState({ selected_log: row.id }, { history: "replace" }); + setSelectedSessionId(null); + setSessionHighlightedLogId(null); + }} + liveEnabled={liveEnabled} + isSocketConnected={isSocketConnected} + columnEntries={columnEntries} + columnOrder={columnOrder} + columnVisibility={columnVisibility} + columnPinning={columnPinning} + onToggleColumnVisibility={toggleColumnVisibility} + onTogglePin={toggleColumnPin} + onReorderColumns={reorderColumns} + /> +
+
+ + {/* Log Detail Sheet */} + !open && setUrlState({ selected_log: "" })} + handleDelete={handleDelete} + onNavigate={handleLogNavigate} + hasPrev={ + selectedLogIndex > 0 || + (selectedLogIndex !== -1 && pagination.offset > 0) + } + hasNext={ + selectedLogIndex !== -1 && + (selectedLogIndex < logs.length - 1 || + pagination.offset + pagination.limit < totalItems) + } + onFilterByParentRequestId={handleFilterByParentRequestId} + onViewSession={(sessionId, logId) => { + setUrlState({ selected_log: "" }, { history: "replace" }); + setSessionHighlightedLogId(logId); + setSelectedSessionId(sessionId); + }} + /> + { + setSelectedSessionId(null); + setUrlState({ selected_log: log.id }, { history: "replace" }); + }} + onFilterByParentRequestId={handleFilterByParentRequestId} + /> +
+ )} +
+ ); +} diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 21d9310055..5441249e0e 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -1,1079 +1,2252 @@ import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + formatCost, + formatLatency, + formatTokens, +} from "@/app/workspace/dashboard/utils/chartUtils"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alertDialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CodeEditor } from "@/components/ui/codeEditor"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdownMenu"; import { DottedSeparator } from "@/components/ui/separator"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; -import { ProviderIconType, RenderProviderIcon, RoutingEngineUsedIcons } from "@/lib/constants/icons"; import { - RequestTypeColors, - RequestTypeLabels, - RoutingEngineUsedColors, - RoutingEngineUsedLabels, - Status, - StatusColors, + ProviderIconType, + RenderProviderIcon, + RoutingEngineUsedIcons, +} from "@/lib/constants/icons"; +import { + RequestTypeColors, + RequestTypeLabels, + RoutingEngineUsedColors, + RoutingEngineUsedLabels, + Status, + StatusColors, } from "@/lib/constants/logs"; -import { LogEntry } from "@/lib/types/logs"; +import { LogEntry, ResponsesMessage } from "@/lib/types/logs"; +import { cn } from "@/lib/utils"; import { Link } from "@tanstack/react-router"; import { addMilliseconds, format } from "date-fns"; -import { Clipboard, Loader2, MoreVertical, Trash2 } from "lucide-react"; -import type { ReactNode } from "react"; +import { + AlertCircle, + ChevronDown, + Clipboard, + Loader2, + MoreVertical, + Trash2, + Wrench, +} from "lucide-react"; +import { useState, type ReactNode } from "react"; import { toast } from "sonner"; import BlockHeader from "../views/blockHeader"; import CollapsibleBox from "../views/collapsibleBox"; import ImageView from "../views/imageView"; import LogChatMessageView from "../views/logChatMessageView"; import LogEntryDetailsView from "../views/logEntryDetailsView"; -import LogResponsesMessageView from "../views/logResponsesMessageView"; import PluginLogsView from "../views/pluginLogsView"; import SpeechView from "../views/speechView"; import TranscriptionView from "../views/transcriptionView"; import VideoView from "../views/videoView"; +const extractResponsesText = (msg: ResponsesMessage): string => { + if (msg.type === "reasoning") { + const summaryText = (msg.summary ?? []) + .map((s) => s.text) + .filter(Boolean) + .join("\n") + .trim(); + if (summaryText) return summaryText; + if (msg.encrypted_content) return msg.encrypted_content; + } + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + return msg.content + .filter( + (b: any) => + b && + b.text && + (b.type === "input_text" || + b.type === "output_text" || + b.type === "reasoning_text" || + b.type === "refusal"), + ) + .map((b: any) => b.text as string) + .join("\n"); + } + if (typeof (msg as any).arguments === "string") + return (msg as any).arguments as string; + return ""; +}; + +const getResponsesRole = (msg: ResponsesMessage): MessageRole => { + if (msg.type === "reasoning") return "reasoning"; + if ( + msg.type && + (msg.type.endsWith("_call") || + msg.type.endsWith("_call_output") || + msg.type === "mcp_list_tools" || + msg.type === "mcp_approval_request" || + msg.type === "mcp_approval_responses") + ) { + return "tool"; + } + const r = msg.role; + if (r === "user") return "user"; + if (r === "assistant") return "assistant"; + if (r === "system" || r === "developer") return "system"; + return "assistant"; +}; + +const extractMessageText = (message: any): string => { + if (!message || message.content == null) return ""; + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) { + return message.content + .filter( + (block: any) => + block && + (block.type === "text" || + block.type === "input_text" || + block.type === "output_text") && + block.text, + ) + .map((block: any) => block.text) + .join("\n"); + } + return ""; +}; + const formatJsonSafe = (str: string | undefined): string => { - try { - return JSON.stringify(JSON.parse(str || ""), null, 2); - } catch { - return str || ""; - } + try { + return JSON.stringify(JSON.parse(str || ""), null, 2); + } catch { + return str || ""; + } +}; + +const formatToolChoice = (value: unknown): string => { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } }; // Helper to detect passthrough operations -const isPassthroughOperation = (object: string) => object === "passthrough" || object === "passthrough_stream"; +const isPassthroughOperation = (object: string) => + object === "passthrough" || object === "passthrough_stream"; // Helper to detect container operations (for hiding irrelevant fields like Model/Tokens) const isContainerOperation = (object: string) => { - const containerTypes = [ - "container_create", - "container_list", - "container_retrieve", - "container_delete", - "container_file_create", - "container_file_list", - "container_file_retrieve", - "container_file_content", - "container_file_delete", - ]; - return containerTypes.includes(object?.toLowerCase()); + const containerTypes = [ + "container_create", + "container_list", + "container_retrieve", + "container_delete", + "container_file_create", + "container_file_list", + "container_file_retrieve", + "container_file_content", + "container_file_delete", + ]; + return containerTypes.includes(object?.toLowerCase()); +}; + +const statusPillStyles: Record = { + success: + "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900", + error: + "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900", + processing: + "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900", + cancelled: + "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-800", }; +const statusDotStyles: Record = { + success: "bg-green-500", + error: "bg-red-500", + processing: "bg-blue-500", + cancelled: "bg-gray-400", +}; + +function StatusPill({ status }: { status: Status }) { + return ( + + + {status} + + ); +} + +function HeroStat({ + label, + value, + sub, + mono = false, + valueClass, + hasRightBorder = false, +}: { + label: string; + value: ReactNode; + sub?: ReactNode; + mono?: boolean; + valueClass?: string; + hasRightBorder?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+ {sub ? ( +
+ {sub} +
+ ) : null} +
+ ); +} + +function CopyInlineButton({ text }: { text: string }) { + const { copy } = useCopyToClipboard({ successMessage: "Copied" }); + return ( + + ); +} + +type MessageRole = "system" | "user" | "assistant" | "reasoning" | "tool"; +const messageToneClass: Record = { + system: "bg-zinc-50 border-zinc-200 dark:bg-zinc-900/40 dark:border-zinc-800", + user: "bg-blue-50/60 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900", + assistant: "bg-white border-zinc-200 dark:bg-zinc-900 dark:border-zinc-800", + reasoning: + "bg-violet-50/70 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900", + tool: "bg-amber-50/70 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900", +}; +const messageDotClass: Record = { + system: "bg-zinc-400", + user: "bg-blue-500", + assistant: "bg-zinc-900 dark:bg-zinc-100", + reasoning: "bg-violet-500", + tool: "bg-amber-500", +}; +const messageRoleLabel: Record = { + system: "System", + user: "User", + assistant: "Assistant", + reasoning: "Reasoning", + tool: "Tool", +}; + +function CollapsibleCode({ + text, + preview = 3, + lang, + mono = true, +}: { + text: string; + preview?: number; + lang?: string; + mono?: boolean; +}) { + const [open, setOpen] = useState(false); + const lines = text.split("\n"); + const shown = open ? lines : lines.slice(0, preview); + const hasMore = lines.length > preview; + const moreCount = lines.length - preview; + return ( + <> + {mono ? ( +
+          {shown.join("\n")}
+        
+ ) : ( +
+ {shown.join("\n")} +
+ )} + {hasMore && ( +
+ + + {lines.length} lines{lang ? ` · ${lang}` : ""} + +
+ )} + + ); +} + +function MessageRow({ + role, + meta, + children, + last = false, +}: { + role: MessageRole; + meta?: string; + children: ReactNode; + last?: boolean; +}) { + return ( +
+
+ + {!last &&
} +
+
+
+ + {messageRoleLabel[role]} + + {meta ? ( + {meta} + ) : null} +
+
+ {children} +
+
+
+ ); +} interface LogDetailViewProps { - log: LogEntry | null; - resolvedSelectedPromptName?: string; // Current prompt name from prompt-repo when `selected_prompt_id` is set; falls back to stored log name - loading?: boolean; - handleDelete?: (log: LogEntry) => void; - onClose?: () => void; - headerAction?: ReactNode; - onFilterByParentRequestId?: (parentRequestId: string) => void; + log: LogEntry | null; + resolvedSelectedPromptName?: string; // Current prompt name from prompt-repo when `selected_prompt_id` is set; falls back to stored log name + loading?: boolean; + handleDelete?: (log: LogEntry) => void; + onClose?: () => void; + headerAction?: ReactNode; + onFilterByParentRequestId?: (parentRequestId: string) => void; } export function LogDetailView({ - log, - resolvedSelectedPromptName, - loading = false, - handleDelete, - onClose, - headerAction, - onFilterByParentRequestId, + log, + resolvedSelectedPromptName, + loading = false, + handleDelete, + onClose, + headerAction, + onFilterByParentRequestId, }: LogDetailViewProps) { - const { copy: copyRequestId } = useCopyToClipboard({ successMessage: "Request ID copied" }); - const { copy: copyBody } = useCopyToClipboard({ - successMessage: "Request body copied to clipboard", - errorMessage: "Failed to copy request body", - }); + const { copy: copyRequestId } = useCopyToClipboard({ + successMessage: "Request ID copied", + }); + const { copy: copyBody } = useCopyToClipboard({ + successMessage: "Request body copied to clipboard", + errorMessage: "Failed to copy request body", + }); + + if (!log) return null; + + const selectedPromptDisplayName = + resolvedSelectedPromptName ?? log.selected_prompt_name ?? ""; - if (!log) return null; + const isContainer = isContainerOperation(log.object); + const isPassthrough = isPassthroughOperation(log.object); + const passthroughParams = isPassthrough + ? (log.params as { + method?: string; + path?: string; + raw_query?: string; + status_code?: number; + }) + : null; - const selectedPromptDisplayName = resolvedSelectedPromptName ?? log.selected_prompt_name ?? ""; + let toolsParameter = null; + if (log.params?.tools) { + try { + toolsParameter = JSON.stringify(log.params.tools, null, 2); + } catch {} + } - const isContainer = isContainerOperation(log.object); - const isPassthrough = isPassthroughOperation(log.object); - const passthroughParams = isPassthrough - ? (log.params as { - method?: string; - path?: string; - raw_query?: string; - status_code?: number; - }) - : null; + const audioFormat = + (log.params as any)?.audio?.format || + (log.params as any)?.extra_params?.audio?.format || + undefined; + const rawRequest = log.raw_request; + const rawResponse = log.raw_response; + const passthroughRequestBody = log.passthrough_request_body; + const passthroughResponseBody = log.passthrough_response_body; + const videoOutput = + log.video_generation_output || + log.video_retrieve_output || + log.video_download_output; + const videoListOutput = log.video_list_output; + const pluginLogCount = (() => { + if (!log.plugin_logs) return 0; + try { + const parsed = JSON.parse(log.plugin_logs); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return Object.values(parsed).reduce((sum, v) => sum + (Array.isArray(v) ? v.length : 0), 0); + } + } catch {} + return 0; + })(); - let toolsParameter = null; - if (log.params?.tools) { - try { - toolsParameter = JSON.stringify(log.params.tools, null, 2); - } catch {} - } + return loading ? ( +
+ +
+ ) : ( + <> + {/* Breadcrumb header with actions */} +
+
+ {headerAction} + Request details +
+ {handleDelete && onClose ? ( + + + + + + + copyRequestBody(log, copyBody)} + data-testid="logdetails-copy-request-body-button" + > + + Copy request body + + + + + + Delete log + + + + + + + + Are you sure you want to delete this log? + + + This action cannot be undone. This will permanently delete the + log entry. + + + + + Cancel + + { + handleDelete(log); + onClose(); + }} + > + Delete + + + + + ) : null} +
+
+
+
+
+ + + {RequestTypeLabels[ + log.object as keyof typeof RequestTypeLabels + ] ?? log.object} + + {log.routing_rule && ( + + rule: {log.routing_rule.name} + + )} + {log.metadata?.isAsyncRequest ? ( + + Async + + ) : null} + {(log.is_large_payload_request || + log.is_large_payload_response) && ( + + Large Payload + + )} +
+
+
+ Request +
+ + {log.id || "—"} + + {log.id ? : null} +
+ {(log.routing_rule || log.selected_key) && ( +
+ {log.routing_rule ? ( + <> + matched rule{" "} + + “{log.routing_rule.name}” + + + ) : null} + {log.routing_rule && log.selected_key ? " · " : ""} + {log.selected_key ? ( + <> + key{" "} + + {log.selected_key.name} + + + ) : null} +
+ )} +
+
+ + {log.provider} +
+
+
+ { + if (!log.timestamp) return ""; + const start = new Date(log.timestamp); + if (isNaN(start.getTime())) return ""; + const startStr = format(start, "HH:mm:ss"); + if (log.latency == null || isNaN(log.latency)) return startStr; + return `${startStr} → ${format(addMilliseconds(start, log.latency), "HH:mm:ss")}`; + })()} + hasRightBorder + /> + + + + +
- const audioFormat = (log.params as any)?.audio?.format || (log.params as any)?.extra_params?.audio?.format || undefined; - const rawRequest = log.raw_request; - const rawResponse = log.raw_response; - const passthroughRequestBody = log.passthrough_request_body; - const passthroughResponseBody = log.passthrough_response_body; - const videoOutput = log.video_generation_output || log.video_retrieve_output || log.video_download_output; - const videoListOutput = log.video_list_output; + {/* Timeline */} + {log.latency != null && + !isNaN(log.latency) && + log.latency > 0 && + (() => { + const total = log.latency; + const tone = + total >= 5000 + ? "bg-red-400" + : total >= 2000 + ? "bg-amber-400" + : "bg-blue-400"; + return ( +
+
+ timeline + + {formatLatency(total)} + +
+
+
+
+
+ + total{" "} + {formatLatency(total)} + +
+
+ ); + })()} +
+
+ + More details + + + timings, request meta, tokens, caching, metadata + + + + +
+
+ +
+ { + const d = log.timestamp ? new Date(log.timestamp) : null; + return d && !isNaN(d.getTime()) + ? format(d, "yyyy-MM-dd hh:mm:ss aa") + : "N/A"; + })()} + /> + { + const d = log.timestamp ? new Date(log.timestamp) : null; + return d && !isNaN(d.getTime()) + ? format( + addMilliseconds(d, log.latency || 0), + "yyyy-MM-dd hh:mm:ss aa", + ) + : "N/A"; + })()} + /> + {log.latency.toFixed(2)}ms
+ ) + } + /> +
+
+ +
+ +
+ + + {log.provider} + + } + /> + {!isContainer && ( + + )} + {!isContainer && log.alias && ( + + )} + + {RequestTypeLabels[ + log.object as keyof typeof RequestTypeLabels + ] ?? + log.object ?? + "unknown"} +
+ } + /> + {log.parent_request_id && ( + + + + onFilterByParentRequestId( + log.parent_request_id as string, + ) + } + > + {log.parent_request_id} + + + + Filter this session + + + ) : ( + + {log.parent_request_id} + + ) + } + /> + )} + {log.selected_key && ( + + )} + {(log.selected_prompt_id || + log.selected_prompt_name || + log.selected_prompt_version) && ( + + {selectedPromptDisplayName} + {selectedPromptDisplayName && log.selected_prompt_version + ? " · " + : ""} + {log.selected_prompt_version ? ( + <>v{log.selected_prompt_version} + ) : null} + + } + /> + )} + {log.number_of_retries > 0 && ( + + )} + {log.team_id && ( + + {log.team_name || log.team_id} + + } + /> + )} + {log.customer_id && ( + + {log.customer_name || log.customer_id} + + } + /> + )} + {log.business_unit_id && ( + + {log.business_unit_name || log.business_unit_id} + + } + /> + )} + {log.user_id && ( + + + + {log.user_name || log.user_id} + + + + {log.user_name ? log.user_id : "Filter by user"} + + + } + /> + )} + {log.fallback_index > 0 && ( + + )} + {log.virtual_key && ( + + )} + {log.routing_engines_used && + log.routing_engines_used.length > 0 && ( + + {log.routing_engines_used.map((engine) => ( + +
+ {RoutingEngineUsedIcons[ + engine as keyof typeof RoutingEngineUsedIcons + ]?.()} + + {RoutingEngineUsedLabels[ + engine as keyof typeof RoutingEngineUsedLabels + ] ?? engine} + +
+
+ ))} +
+ } + /> + )} + {log.routing_rule && ( + + )} - return loading ? ( -
- -
- ) : ( - <> -
-
-
- {headerAction} -
- {log.id && ( -

- Request ID:{" "} - copyRequestId(log.id)}> - {log.id} - -

- )} - - {log.status} - - {log.metadata?.isAsyncRequest ? ( - - Async - - ) : null} - {(log.is_large_payload_request || log.is_large_payload_response) && ( - - Large Payload - - )} -
-
- {handleDelete && onClose ? ( - - - - - - - copyRequestBody(log, copyBody)} data-testid="logdetails-copy-request-body-button"> - - Copy request body - - - - - - Delete log - - - - - - - Are you sure you want to delete this log? - This action cannot be undone. This will permanently delete the log entry. - - - Cancel - { - handleDelete(log); - onClose(); - }} - > - Delete - - - - - ) : null} -
-
-
-
- -
- - - {(log.latency || 0)?.toFixed(2)}ms
} - /> -
-
- -
- -
- - - {log.provider} - - } - /> - {!isContainer && } - {!isContainer && log.alias && } - - {RequestTypeLabels[log.object as keyof typeof RequestTypeLabels] ?? log.object ?? "unknown"} -
- } - /> - {log.parent_request_id && ( - - - onFilterByParentRequestId(log.parent_request_id as string)} - > - {log.parent_request_id} - - - Filter this session - - ) : ( - {log.parent_request_id} - ) - } - /> - )} - {log.selected_key && } - {(log.selected_prompt_id || log.selected_prompt_name || log.selected_prompt_version) && ( - - {selectedPromptDisplayName} - {selectedPromptDisplayName && log.selected_prompt_version ? " · " : ""} - {log.selected_prompt_version ? <>v{log.selected_prompt_version} : null} - - } - /> - )} - {log.number_of_retries > 0 && ( - - )} - {log.team_id && ( - - {log.team_name || log.team_id} - - } - /> - )} - {log.customer_id && ( - - {log.customer_name || log.customer_id} - - } - /> - )} - {log.business_unit_id && ( - - {log.business_unit_name || log.business_unit_id} - - } - /> - )} - {log.user_id && ( - - - - {log.user_name || log.user_id} - - - {log.user_name ? log.user_id : "Filter by user"} - - } - /> - )} - {log.fallback_index > 0 && } - {log.virtual_key && } - {log.routing_engines_used && log.routing_engines_used.length > 0 && ( - - {log.routing_engines_used.map((engine) => ( - -
- {RoutingEngineUsedIcons[engine as keyof typeof RoutingEngineUsedIcons]?.()} - {RoutingEngineUsedLabels[engine as keyof typeof RoutingEngineUsedLabels] ?? engine} -
-
- ))} -
- } - /> - )} - {log.routing_rule && } + {(log.params as any)?.audio && ( + <> + {(log.params as any).audio.format && ( + + )} + {(log.params as any).audio.voice && ( + + )} + + )} - {(log.params as any)?.audio && ( - <> - {(log.params as any).audio.format && ( - - )} - {(log.params as any).audio.voice && ( - - )} - - )} + {passthroughParams && ( + <> + {passthroughParams.method && ( + + )} + {passthroughParams.path && ( + + )} + {passthroughParams.raw_query && ( + + )} + {(passthroughParams.status_code ?? 0) !== 0 && ( + + )} + + )} - {passthroughParams && ( - <> - {passthroughParams.method && } - {passthroughParams.path && } - {passthroughParams.raw_query && ( - - )} - {(passthroughParams.status_code ?? 0) !== 0 && ( - - )} - - )} + {log.params && + Object.keys(log.params).length > 0 && + Object.entries(log.params) + .filter(([key]) => { + const passthroughKeys = [ + "method", + "path", + "raw_query", + "status_code", + ]; + return ( + key !== "tools" && + key !== "instructions" && + key !== "audio" && + !(isPassthrough && passthroughKeys.includes(key)) + ); + }) + .filter( + ([_, value]) => + typeof value === "boolean" || + typeof value === "number" || + typeof value === "string", + ) + .map(([key, value]) => ( + + ))} +
+
+ {log.status === "success" && !isContainer && !isPassthrough && ( + <> + +
+ +
+ + + + + {log.token_usage?.prompt_tokens_details && ( + <> + {log.token_usage.prompt_tokens_details + .cached_read_tokens && ( + + )} + {log.token_usage.prompt_tokens_details + .cached_write_tokens && ( + + )} + {log.token_usage.prompt_tokens_details.audio_tokens && ( + + )} + + )} + {log.token_usage?.completion_tokens_details && ( + <> + {log.token_usage.completion_tokens_details + .reasoning_tokens && ( + + )} + {log.token_usage.completion_tokens_details + .audio_tokens && ( + + )} + {log.token_usage.completion_tokens_details + .accepted_prediction_tokens && ( + + )} + {log.token_usage.completion_tokens_details + .rejected_prediction_tokens && ( + + )} + + )} +
+
+ {(() => { + const params = log.params as any; + const reasoning = params?.reasoning; + if ( + !reasoning || + typeof reasoning !== "object" || + Object.keys(reasoning).length === 0 + ) { + return null; + } + return ( + <> + +
+ +
+ {reasoning.effort && ( + + {reasoning.effort} + + } + /> + )} + {reasoning.summary && ( + + {reasoning.summary} + + } + /> + )} + {reasoning.generate_summary && ( + + {reasoning.generate_summary} + + } + /> + )} + {reasoning.max_tokens && ( + + )} +
+
+ + ); + })()} + {log.cache_debug && ( + <> + +
+ +
+ {log.cache_debug.cache_hit ? ( + <> + + {log.cache_debug.hit_type} + + } + /> + {log.cache_debug.hit_type === "semantic" && ( + <> + {log.cache_debug.provider_used && ( + + {log.cache_debug.provider_used} + + } + /> + )} + {log.cache_debug.model_used && ( + + )} + {log.cache_debug.threshold && ( + + )} + {log.cache_debug.similarity && ( + + )} + {log.cache_debug.input_tokens && ( + + )} + + )} + + ) : ( + <> + {log.cache_debug.provider_used && ( + + {log.cache_debug.provider_used} + + } + /> + )} + {log.cache_debug.model_used && ( + + )} + {log.cache_debug.input_tokens && ( + + )} + + )} +
+
+ + )} + {log.metadata && + Object.keys(log.metadata).filter((k) => k !== "isAsyncRequest") + .length > 0 && ( + <> + +
+ +
+ {Object.entries(log.metadata) + .filter(([key]) => key !== "isAsyncRequest") + .map(([key, value]) => ( + + ))} +
+
+ + )} + + )} + + + + + + Messages + {log.input_history?.length ? ( + + {log.input_history.length + (log.output_message ? 1 : 0)} + + ) : null} + + + Tools + {log.params?.tools?.length ? ( + + {log.params.tools.length} + + ) : null} + + + Routing + {log.routing_engine_logs ? ( + + {log.routing_engine_logs.split("\n").filter(Boolean).length} + + ) : null} + + + Plugin Logs + {pluginLogCount > 0 ? ( + + {pluginLogCount} + + ) : null} + + + Raw JSON + + - {log.params && - Object.keys(log.params).length > 0 && - Object.entries(log.params) - .filter(([key]) => { - const passthroughKeys = ["method", "path", "raw_query", "status_code"]; - return key !== "tools" && key !== "instructions" && key !== "audio" && !(isPassthrough && passthroughKeys.includes(key)); - }) - .filter(([_, value]) => typeof value === "boolean" || typeof value === "number" || typeof value === "string") - .map(([key, value]) => )} - - - {log.status === "success" && !isContainer && !isPassthrough && ( - <> - -
- -
- - - - - {log.token_usage?.prompt_tokens_details && ( - <> - {log.token_usage.prompt_tokens_details.cached_read_tokens && ( - - )} - {log.token_usage.prompt_tokens_details.cached_write_tokens && ( - - )} - {log.token_usage.prompt_tokens_details.audio_tokens && ( - - )} - - )} - {log.token_usage?.completion_tokens_details && ( - <> - {log.token_usage.completion_tokens_details.reasoning_tokens && ( - - )} - {log.token_usage.completion_tokens_details.audio_tokens && ( - - )} - {log.token_usage.completion_tokens_details.accepted_prediction_tokens && ( - - )} - {log.token_usage.completion_tokens_details.rejected_prediction_tokens && ( - - )} - - )} -
-
- {(() => { - const params = log.params as any; - const reasoning = params?.reasoning; - if (!reasoning || typeof reasoning !== "object" || Object.keys(reasoning).length === 0) { - return null; - } - return ( - <> - -
- -
- {reasoning.effort && ( - - {reasoning.effort} - - } - /> - )} - {reasoning.summary && ( - - {reasoning.summary} - - } - /> - )} - {reasoning.generate_summary && ( - - {reasoning.generate_summary} - - } - /> - )} - {reasoning.max_tokens && } -
-
- - ); - })()} - {log.cache_debug && ( - <> - -
- -
- {log.cache_debug.cache_hit ? ( - <> - - {log.cache_debug.hit_type} - - } - /> - {log.cache_debug.hit_type === "semantic" && ( - <> - {log.cache_debug.provider_used && ( - - {log.cache_debug.provider_used} - - } - /> - )} - {log.cache_debug.model_used && ( - - )} - {log.cache_debug.threshold && ( - - )} - {log.cache_debug.similarity && ( - - )} - {log.cache_debug.input_tokens && ( - - )} - - )} - - ) : ( - <> - {log.cache_debug.provider_used && ( - - {log.cache_debug.provider_used} - - } - /> - )} - {log.cache_debug.model_used && ( - - )} - {log.cache_debug.input_tokens && ( - - )} - - )} -
-
- - )} - {log.metadata && Object.keys(log.metadata).filter((k) => k !== "isAsyncRequest").length > 0 && ( - <> - -
- -
- {Object.entries(log.metadata) - .filter(([key]) => key !== "isAsyncRequest") - .map(([key, value]) => ( - - ))} -
-
- - )} - - )} - - {log.attempt_trail && log.attempt_trail.length > 1 && ( - JSON.stringify(log.attempt_trail, null, 2)} - > -
- - - - - - - - - - {log.attempt_trail.map((record) => ( - - - - - - ))} - -
#KeyResult
{record.attempt + 1}{record.key_name || record.key_id} - {record.fail_reason ? ( - {record.fail_reason} - ) : ( - success - )} -
-
-
- )} - {log.routing_engine_logs && ( - log.routing_engine_logs || ""}> -
- {log.routing_engine_logs} -
-
- )} - {log.plugin_logs && } - {toolsParameter && ( - toolsParameter}> - - - )} - {log.params?.instructions && ( - log.params?.instructions || ""}> -
- {log.params.instructions} -
-
- )} - {(log.speech_input || log.speech_output) && ( - - )} - {(log.transcription_input || log.transcription_output) && ( - - )} - {(log.image_generation_input || log.image_edit_input || log.image_variation_input || log.image_generation_output) && ( - - )} - {(log.video_generation_input || videoOutput || videoListOutput) && ( - - )} - {log.list_models_output && ( - JSON.stringify(log.list_models_output, null, 2)} - > - - - )} - {isPassthrough && passthroughRequestBody && ( - { - try { - return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2); - } catch { - return passthroughRequestBody || ""; - } - }} - > - { - try { - return JSON.stringify(JSON.parse(passthroughRequestBody || ""), null, 2); - } catch { - return passthroughRequestBody || ""; - } - })()} - lang="json" - readonly={true} - options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} - /> - - )} - {log.input_history && log.input_history.length > 1 && ( - <> -
Conversation History
- {log.input_history.slice(0, -1).map((message, index) => ( - - ))} - - )} - {log.input_history && log.input_history.length > 0 && ( - <> -
Input
- - - )} - {log.responses_input_history && log.responses_input_history.length > 0 && ( - <> -
Input
- - - )} - {log.is_large_payload_request && !log.input_history?.length && !log.responses_input_history?.length && ( -
- Large payload request — input content was streamed directly to the provider and is not available for display. - {log.raw_request && " A truncated preview is available in the Raw Request section below."} -
- )} - {log.is_large_payload_response && !log.output_message && !log.responses_output?.length && log.status !== "processing" && ( -
- Large payload response — response content was streamed directly to the client and is not available for display. - {log.raw_response && " A truncated preview is available in the Raw Response section below."} -
- )} - {log.status !== "processing" && ( - <> - {log.output_message && !log.error_details?.error.message && ( - <> -
-
Response
-
- - - )} - {log.responses_output && log.responses_output.length > 0 && !log.error_details?.error.message && ( - <> -
Response
- - - )} - {isPassthrough && passthroughResponseBody && ( - { - try { - return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2); - } catch { - return passthroughResponseBody || ""; - } - }} - > - { - try { - return JSON.stringify(JSON.parse(passthroughResponseBody || ""), null, 2); - } catch { - return passthroughResponseBody || ""; - } - })()} - lang="json" - readonly={true} - options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }} - /> - - )} - {rawRequest && ( - <> -
- Raw Request sent to {log.provider} - {log.is_large_payload_request && ( - (truncated preview) - )} -
- formatJsonSafe(rawRequest)} - > - - - - )} - {rawResponse && ( - <> -
- Raw Response from {log.provider} - {log.is_large_payload_response && ( - (truncated preview) - )} -
- formatJsonSafe(rawResponse)} - > - - - - )} - {log.embedding_output && log.embedding_output.length > 0 && !log.error_details?.error.message && ( - <> -
Embedding
- embedding.embedding), - null, - 2, - ), - }} - /> - - )} - {log.rerank_output && !log.error_details?.error.message && ( - <> - JSON.stringify(log.rerank_output, null, 2)} - > - - - - )} - {log.error_details?.error.message && ( - <> -
Error
- log.error_details?.error.message || ""}> -
- {log.error_details.error.message} -
-
- - )} - {log.error_details?.error.error && ( - <> -
Error Details
- - typeof log.error_details?.error.error === "string" - ? log.error_details.error.error - : JSON.stringify(log.error_details?.error.error, null, 2) - } - > -
- {typeof log.error_details?.error.error === "string" - ? log.error_details.error.error - : JSON.stringify(log.error_details?.error.error, null, 2)} -
-
- - )} - - )} - - ); + + {(log.speech_input || log.speech_output) && ( + + )} + {(log.transcription_input || log.transcription_output) && ( + + )} + {(log.image_generation_input || + log.image_edit_input || + log.image_variation_input || + log.image_generation_output) && ( + + )} + {(log.video_generation_input || videoOutput || videoListOutput) && ( + + )} + + {((log.input_history && log.input_history.length > 0) || + (log.output_message && !log.error_details?.error.message)) && ( +
+ {log.input_history?.map((message, index) => { + const role = ((message.role as string) || + "user") as MessageRole; + const text = extractMessageText(message); + const hasToolCalls = + Array.isArray(message.tool_calls) && + message.tool_calls.length > 0; + const isLast = + index === (log.input_history?.length ?? 0) - 1 && + !log.output_message && + !log.error_details?.error.message; + const lineCount = text ? text.split("\n").length : 0; + const approxTokens = text + ? Math.max(1, Math.round(text.length / 4)) + : 0; + const meta = text + ? role === "system" || role === "tool" + ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens` + : `${lineCount} line${lineCount === 1 ? "" : "s"}` + : hasToolCalls + ? `${message.tool_calls!.length} tool call${message.tool_calls!.length === 1 ? "" : "s"}` + : undefined; + const usePlainText = role === "user" || role === "assistant"; + return ( + + {text ? ( + usePlainText ? ( + + ) : ( + + ) + ) : ( + + )} + {hasToolCalls && text ? ( +
+ {message.tool_calls!.length} tool call + {message.tool_calls!.length === 1 ? "" : "s"} +
+ ) : null} +
+ ); + })} + {log.output_message && + !log.error_details?.error.message && + (() => { + const text = extractMessageText(log.output_message); + const lineCount = text ? text.split("\n").length : 0; + const tokenMeta = log.token_usage?.completion_tokens + ? `${log.token_usage.completion_tokens} tokens` + : undefined; + const meta = text + ? tokenMeta + ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ${tokenMeta}` + : `${lineCount} line${lineCount === 1 ? "" : "s"}` + : tokenMeta; + return ( + + {text ? ( + + ) : ( + + )} + + ); + })()} +
+ )} + + {(() => { + const inputMsgs = log.responses_input_history ?? []; + const outputMsgs = + log.status !== "processing" && !log.error_details?.error.message + ? (log.responses_output ?? []) + : []; + const all: ResponsesMessage[] = [...inputMsgs, ...outputMsgs]; + if (all.length === 0) return null; + return ( +
+ {all.map((msg, index) => { + const role = getResponsesRole(msg); + const text = extractResponsesText(msg); + const isLast = index === all.length - 1; + const lineCount = text ? text.split("\n").length : 0; + const approxTokens = text + ? Math.max(1, Math.round(text.length / 4)) + : 0; + const isEncrypted = + msg.type === "reasoning" && !!msg.encrypted_content; + const meta = text + ? role === "system" || role === "tool" + ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ~${approxTokens} tokens` + : role === "reasoning" + ? `~${approxTokens} tokens${isEncrypted ? " · encrypted" : ""}` + : `${lineCount} line${lineCount === 1 ? "" : "s"}` + : msg.type || undefined; + const usePlainText = role === "user" || role === "assistant"; + return ( + + {text ? ( + usePlainText ? ( + + ) : ( + + ) + ) : ( +
+ {msg.type || "—"} +
+ )} +
+ ); + })} +
+ ); + })()} + + {log.is_large_payload_request && + !log.input_history?.length && + !log.responses_input_history?.length && ( +
+ Large payload request — input content was streamed directly to + the provider and is not available for display. + {log.raw_request && + " A truncated preview is available in the Raw JSON tab."} +
+ )} + {log.is_large_payload_response && + !log.output_message && + !log.responses_output?.length && + log.status !== "processing" && ( +
+ Large payload response — response content was streamed directly + to the client and is not available for display. + {log.raw_response && + " A truncated preview is available in the Raw JSON tab."} +
+ )} + + {log.status !== "processing" && + log.embedding_output && + log.embedding_output.length > 0 && + !log.error_details?.error.message && ( +
+
Embedding
+ embedding.embedding, + ), + null, + 2, + ), + }} + /> +
+ )} + {log.status !== "processing" && + log.rerank_output && + !log.error_details?.error.message && ( + JSON.stringify(log.rerank_output, null, 2)} + > + + + )} + + {(log.error_details?.error.message || + log.error_details?.error.error != null) && ( +
+
+ + Error + {log.error_details?.error.message ? ( + + ) : null} +
+ {log.error_details?.error.message ? ( +
+ {log.error_details.error.message} +
+ ) : null} + {log.error_details?.error.error != null ? ( +
+ + Details + + +
+ {typeof log.error_details.error.error === "string" + ? log.error_details.error.error + : JSON.stringify(log.error_details.error.error, null, 2)} +
+
+ ) : null} +
+ )} +
+ + + {toolsParameter ? ( +
+
+ {log.params?.tools?.length ?? 0} tools exposed to the model + {(log.params as any)?.tool_choice != null ? ( + <> + {" "} + · tool_choice ={" "} + + {formatToolChoice((log.params as any).tool_choice)} + + + ) : null} +
+
+ {(log.params?.tools as any[]).map((tool, i) => { + const name = + tool?.function?.name ?? tool?.name ?? `tool_${i}`; + const description = + tool?.function?.description ?? tool?.description ?? ""; + const schema = + tool?.function?.parameters ?? + tool?.input_schema ?? + tool?.parameters ?? + null; + const schemaJson = + schema != null ? JSON.stringify(schema, null, 2) : ""; + return ( +
+ +
+ +
+
+
+ {name} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ +
+ {schemaJson ? ( +
+
+ Parameters + +
+
+                            {schemaJson}
+                          
+
+ ) : ( +
+ No parameter schema. +
+ )} +
+ ); + })} +
+
+ ) : null} + {log.params?.instructions && ( + log.params?.instructions || ""} + > +
+ {log.params.instructions} +
+
+ )} + {!toolsParameter && !log.params?.instructions && ( +
+ No tools or instructions on this request. +
+ )} +
+ + + {log.attempt_trail && log.attempt_trail.length > 1 && ( + JSON.stringify(log.attempt_trail, null, 2)} + > +
+ + + + + + + + + + {log.attempt_trail.map((record) => ( + + + + + + ))} + +
#KeyResult
+ {record.attempt + 1} + + {record.key_name || record.key_id} + + {record.fail_reason ? ( + + {record.fail_reason} + + ) : ( + + success + + )} +
+
+
+ )} + {log.routing_engine_logs && ( + log.routing_engine_logs || ""} + > +
+ {log.routing_engine_logs + .split("\n") + .filter((l) => l.trim()) + .map((line, i) => { + const m = line.match( + /^\[(\d+)\]\s+\[([^\]]+)\]\s+-\s+(.*)$/, + ); + const ts = m ? Number(m[1]) : null; + const scope = m ? m[2] : null; + const message = m ? m[3] : line; + return ( +
+ {ts != null ? ( + + {format(new Date(ts), "HH:mm:ss.SSS")} + + ) : null} + {scope ? ( + + {scope} + + ) : null} + + {message} + +
+ ); + })} +
+
+ )} + {!log.attempt_trail?.length && !log.routing_engine_logs && ( +
+ No routing logs for this request. +
+ )} +
+ + + {log.plugin_logs ? ( + + ) : ( +
+ No plugin logs for this request. +
+ )} +
+ + + {isPassthrough && passthroughRequestBody && ( + { + try { + return JSON.stringify( + JSON.parse(passthroughRequestBody || ""), + null, + 2, + ); + } catch { + return passthroughRequestBody || ""; + } + }} + > + { + try { + return JSON.stringify( + JSON.parse(passthroughRequestBody || ""), + null, + 2, + ); + } catch { + return passthroughRequestBody || ""; + } + })()} + lang="json" + readonly={true} + options={{ + scrollBeyondLastLine: false, + lineNumbers: "off", + alwaysConsumeMouseWheel: false, + }} + /> + + )} + {isPassthrough && + passthroughResponseBody && + log.status !== "processing" && ( + { + try { + return JSON.stringify( + JSON.parse(passthroughResponseBody || ""), + null, + 2, + ); + } catch { + return passthroughResponseBody || ""; + } + }} + > + { + try { + return JSON.stringify( + JSON.parse(passthroughResponseBody || ""), + null, + 2, + ); + } catch { + return passthroughResponseBody || ""; + } + })()} + lang="json" + readonly={true} + options={{ + scrollBeyondLastLine: false, + lineNumbers: "off", + alwaysConsumeMouseWheel: false, + }} + /> + + )} + {rawRequest && ( + <> +
+ Raw Request sent to{" "} + + {log.provider} + + {log.is_large_payload_request && ( + + (truncated preview) + + )} +
+ formatJsonSafe(rawRequest)} + > + + + + )} + {rawResponse && log.status !== "processing" && ( + <> +
+ Raw Response from{" "} + + {log.provider} + + {log.is_large_payload_response && ( + + (truncated preview) + + )} +
+ formatJsonSafe(rawResponse)} + > + + + + )} + {log.list_models_output && ( + JSON.stringify(log.list_models_output, null, 2)} + > + + + )} + {!rawRequest && + !rawResponse && + !passthroughRequestBody && + !passthroughResponseBody && + !log.list_models_output && ( +
+ No raw JSON available. +
+ )} +
+
+ + ); } -const copyRequestBody = async (log: LogEntry, copy: (text: string) => Promise) => { - try { - const isChat = log.object === "chat.completion" || log.object === "chat.completion.chunk"; - const isResponses = log.object === "response" || log.object === "response.completion.chunk"; - const isRealtimeTurn = log.object === "realtime.turn"; - const isSpeech = log.object === "audio.speech" || log.object === "audio.speech.chunk"; - const isTextCompletion = log.object === "text.completion" || log.object === "text.completion.chunk"; - const isEmbedding = log.object === "list"; +const copyRequestBody = async ( + log: LogEntry, + copy: (text: string) => Promise, +) => { + try { + const isChat = + log.object === "chat.completion" || + log.object === "chat.completion.chunk"; + const isResponses = + log.object === "response" || log.object === "response.completion.chunk"; + const isRealtimeTurn = log.object === "realtime.turn"; + const isSpeech = + log.object === "audio.speech" || log.object === "audio.speech.chunk"; + const isTextCompletion = + log.object === "text.completion" || + log.object === "text.completion.chunk"; + const isEmbedding = log.object === "list"; - const extractTextFromMessage = (message: any): string => { - if (!message || !message.content) { - return ""; - } - if (typeof message.content === "string") { - return message.content; - } - if (Array.isArray(message.content)) { - return message.content - .filter((block: any) => block && block.type === "text" && block.text) - .map((block: any) => block.text) - .join("\n"); - } - return ""; - }; + const extractTextFromMessage = (message: any): string => { + if (!message || !message.content) { + return ""; + } + if (typeof message.content === "string") { + return message.content; + } + if (Array.isArray(message.content)) { + return message.content + .filter((block: any) => block && block.type === "text" && block.text) + .map((block: any) => block.text) + .join("\n"); + } + return ""; + }; - const extractTextsFromMessage = (message: any): string[] => { - if (!message || !message.content) { - return []; - } - if (typeof message.content === "string") { - return message.content ? [message.content] : []; - } - if (Array.isArray(message.content)) { - return message.content.filter((block: any) => block && block.type === "text" && block.text).map((block: any) => block.text); - } - return []; - }; + const extractTextsFromMessage = (message: any): string[] => { + if (!message || !message.content) { + return []; + } + if (typeof message.content === "string") { + return message.content ? [message.content] : []; + } + if (Array.isArray(message.content)) { + return message.content + .filter((block: any) => block && block.type === "text" && block.text) + .map((block: any) => block.text); + } + return []; + }; - const isSupportedType = isChat || isResponses || isRealtimeTurn || isSpeech || isTextCompletion || isEmbedding; - if (!isSupportedType) { - if (log.object === "audio.transcription" || log.object === "audio.transcription.chunk") { - toast.error("Copy request body is not available for transcription requests"); - } else { - toast.error("Copy request body is only available for chat, responses, speech, text completion, and embedding requests"); - } - return; - } + const isSupportedType = + isChat || + isResponses || + isRealtimeTurn || + isSpeech || + isTextCompletion || + isEmbedding; + if (!isSupportedType) { + if ( + log.object === "audio.transcription" || + log.object === "audio.transcription.chunk" + ) { + toast.error( + "Copy request body is not available for transcription requests", + ); + } else { + toast.error( + "Copy request body is only available for chat, responses, speech, text completion, and embedding requests", + ); + } + return; + } - const requestBody: any = { - model: log.provider && log.model ? `${log.provider}/${log.model}` : log.model || "", - }; + const requestBody: any = { + model: + log.provider && log.model + ? `${log.provider}/${log.model}` + : log.model || "", + }; - if (isRealtimeTurn) { - if (log.input_history && log.input_history.length > 0) { - requestBody.messages = log.input_history; - } - if (log.output_message) { - requestBody.output = log.output_message; - } - } else if (isChat && log.input_history && log.input_history.length > 0) { - requestBody.messages = log.input_history; - } else if (isResponses && log.responses_input_history && log.responses_input_history.length > 0) { - requestBody.input = log.responses_input_history; - } else if (isSpeech && log.speech_input) { - requestBody.input = log.speech_input.input; - } else if (isTextCompletion && log.input_history && log.input_history.length > 0) { - const firstMessage = log.input_history[0]; - const prompt = extractTextFromMessage(firstMessage); - if (prompt) { - requestBody.prompt = prompt; - } - } else if (isEmbedding && log.input_history && log.input_history.length > 0) { - const texts: string[] = []; - for (const message of log.input_history) { - const messageTexts = extractTextsFromMessage(message); - texts.push(...messageTexts); - } - if (texts.length > 0) { - requestBody.input = texts.length === 1 ? texts[0] : texts; - } - } + if (isRealtimeTurn) { + if (log.input_history && log.input_history.length > 0) { + requestBody.messages = log.input_history; + } + if (log.output_message) { + requestBody.output = log.output_message; + } + } else if (isChat && log.input_history && log.input_history.length > 0) { + requestBody.messages = log.input_history; + } else if ( + isResponses && + log.responses_input_history && + log.responses_input_history.length > 0 + ) { + requestBody.input = log.responses_input_history; + } else if (isSpeech && log.speech_input) { + requestBody.input = log.speech_input.input; + } else if ( + isTextCompletion && + log.input_history && + log.input_history.length > 0 + ) { + const firstMessage = log.input_history[0]; + const prompt = extractTextFromMessage(firstMessage); + if (prompt) { + requestBody.prompt = prompt; + } + } else if ( + isEmbedding && + log.input_history && + log.input_history.length > 0 + ) { + const texts: string[] = []; + for (const message of log.input_history) { + const messageTexts = extractTextsFromMessage(message); + texts.push(...messageTexts); + } + if (texts.length > 0) { + requestBody.input = texts.length === 1 ? texts[0] : texts; + } + } - if (log.params) { - const paramsCopy = { ...log.params }; - delete paramsCopy.tools; - delete paramsCopy.instructions; - Object.assign(requestBody, paramsCopy); - } + if (log.params) { + const paramsCopy = { ...log.params }; + delete paramsCopy.tools; + delete paramsCopy.instructions; + Object.assign(requestBody, paramsCopy); + } - if ((isChat || isResponses || isRealtimeTurn) && log.params?.tools && Array.isArray(log.params.tools) && log.params.tools.length > 0) { - requestBody.tools = log.params.tools; - } - if ((isResponses || isRealtimeTurn) && log.params?.instructions) { - requestBody.instructions = log.params.instructions; - } + if ( + (isChat || isResponses || isRealtimeTurn) && + log.params?.tools && + Array.isArray(log.params.tools) && + log.params.tools.length > 0 + ) { + requestBody.tools = log.params.tools; + } + if ((isResponses || isRealtimeTurn) && log.params?.instructions) { + requestBody.instructions = log.params.instructions; + } - const requestBodyJson = JSON.stringify(requestBody, null, 2); - await copy(requestBodyJson); - } catch { - toast.error("Failed to copy request body"); - } -}; \ No newline at end of file + const requestBodyJson = JSON.stringify(requestBody, null, 2); + await copy(requestBodyJson); + } catch { + toast.error("Failed to copy request body"); + } +}; diff --git a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx index c1c87244fd..2e9805ad89 100644 --- a/ui/app/workspace/logs/sheets/logDetailsSheet.tsx +++ b/ui/app/workspace/logs/sheets/logDetailsSheet.tsx @@ -9,118 +9,135 @@ import { useHotkeys } from "react-hotkeys-hook"; import { LogDetailView } from "./logDetailView"; interface LogDetailSheetProps { - log: LogEntry | null; - open: boolean; - onOpenChange: (open: boolean) => void; - handleDelete: (log: LogEntry) => void; - onNavigate?: (direction: "prev" | "next") => void; - hasPrev?: boolean; - hasNext?: boolean; - onViewSession?: (sessionId: string, logId: string) => void; - onFilterByParentRequestId?: (parentRequestId: string) => void; + log: LogEntry | null; + open: boolean; + onOpenChange: (open: boolean) => void; + handleDelete: (log: LogEntry) => void; + onNavigate?: (direction: "prev" | "next") => void; + hasPrev?: boolean; + hasNext?: boolean; + onViewSession?: (sessionId: string, logId: string) => void; + onFilterByParentRequestId?: (parentRequestId: string) => void; } export function LogDetailSheet({ - log, - open, - onOpenChange, - handleDelete, - onNavigate, - hasPrev = false, - hasNext = false, - onViewSession, - onFilterByParentRequestId, + log, + open, + onOpenChange, + handleDelete, + onNavigate, + hasPrev = false, + hasNext = false, + onViewSession, + onFilterByParentRequestId, }: LogDetailSheetProps) { - const [pollingInterval, setPollingInterval] = useState(0); - const { - data: fullLog, - isLoading, - isError, - } = useGetLogByIdQuery(log?.id ?? "", { - skip: !open || !log?.id, - pollingInterval, - }); + const [pollingInterval, setPollingInterval] = useState(0); + const { + data: fullLog, + isLoading, + isError, + } = useGetLogByIdQuery(log?.id ?? "", { + skip: !open || !log?.id, + pollingInterval, + }); - const shouldPoll = isError || fullLog?.status === "processing"; + const shouldPoll = isError || fullLog?.status === "processing"; - const isFullDataReady = log != null && (isError || (fullLog?.id === log.id && !isLoading)); - // Prefer full log when loaded; otherwise list row — enables prompt fetch in parallel with getLogById - const selectedPromptId = log ? (fullLog?.id === log.id ? fullLog : log).selected_prompt_id : undefined; - const { data: selectedPromptData } = useGetPromptQuery(selectedPromptId ?? "", { - skip: !open || !selectedPromptId, - }); + const isFullDataReady = + log != null && (isError || (fullLog?.id === log.id && !isLoading)); + // Prefer full log when loaded; otherwise list row — enables prompt fetch in parallel with getLogById + const selectedPromptId = log + ? (fullLog?.id === log.id ? fullLog : log).selected_prompt_id + : undefined; + const { data: selectedPromptData } = useGetPromptQuery( + selectedPromptId ?? "", + { + skip: !open || !selectedPromptId, + }, + ); - useEffect(() => { - setPollingInterval(shouldPoll ? 2000 : 0); - }, [shouldPoll]); + useEffect(() => { + setPollingInterval(shouldPoll ? 2000 : 0); + }, [shouldPoll]); - // Keyboard navigation: arrow up/down to navigate between logs - useHotkeys("up", () => onNavigate?.("prev"), { enabled: open && hasPrev, preventDefault: true }); - useHotkeys("down", () => onNavigate?.("next"), { enabled: open && hasNext, preventDefault: true }); + // Keyboard navigation: arrow up/down to navigate between logs + useHotkeys("up", () => onNavigate?.("prev"), { + enabled: open && hasPrev, + preventDefault: true, + }); + useHotkeys("down", () => onNavigate?.("next"), { + enabled: open && hasNext, + preventDefault: true, + }); - if (!log) return null; + if (!log) return null; + // Show a loader only on the initial fetch, not during background polling refetches. + const displayLog: LogEntry = isFullDataReady && fullLog ? fullLog : log; + const resolvedSelectedPromptName = + selectedPromptData?.prompt?.name ?? displayLog.selected_prompt_name ?? ""; - // Show a loader only on the initial fetch, not during background polling refetches. - const displayLog: LogEntry = isFullDataReady && fullLog ? fullLog : log; - const resolvedSelectedPromptName = selectedPromptData?.prompt?.name ?? displayLog.selected_prompt_name ?? ""; - - return ( - - - {!isFullDataReady ? ( -
- Loading log details - -
- ) : ( - onOpenChange(false)} - onFilterByParentRequestId={onFilterByParentRequestId} - headerAction={ - <> - {displayLog.parent_request_id && onViewSession ? ( - - ) : null} -
- - -
- - } - /> - )} -
-
- ); -} \ No newline at end of file + return ( + + + {!isFullDataReady ? ( +
+ Loading log details + +
+ ) : ( + onOpenChange(false)} + onFilterByParentRequestId={onFilterByParentRequestId} + headerAction={ + <> + {displayLog.parent_request_id && onViewSession ? ( + + ) : null} +
+ + +
+ + } + /> + )} +
+
+ ); +} diff --git a/ui/app/workspace/logs/views/columns.tsx b/ui/app/workspace/logs/views/columns.tsx index 1b8a13ee57..b45832760a 100644 --- a/ui/app/workspace/logs/views/columns.tsx +++ b/ui/app/workspace/logs/views/columns.tsx @@ -1,329 +1,499 @@ +import { + formatCost, + formatLatency, + formatTokens, +} from "@/app/workspace/dashboard/utils/chartUtils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; -import { ProviderName, RequestTypeColors, RequestTypeLabels, Status, StatusBarColors } from "@/lib/constants/logs"; -import { ChatMessageContent, LogEntry, ResponsesMessageContentBlock } from "@/lib/types/logs"; +import { + getProviderLabel, + ProviderName, + RequestTypeColors, + RequestTypeLabels, + Status, + StatusBarColors, +} from "@/lib/constants/logs"; +import { + ChatMessageContent, + LogEntry, + ResponsesMessageContentBlock, +} from "@/lib/types/logs"; import { cn } from "@/lib/utils"; import { ColumnDef } from "@tanstack/react-table"; -import { format } from "date-fns"; +import { format, formatDistanceToNow } from "date-fns"; import { ArrowUpDown, Trash2 } from "lucide-react"; function getAssistantToolCallSummary(log?: LogEntry): string { - const toolCalls = log?.output_message?.tool_calls || []; - return toolCalls - .map((toolCall) => { - const name = toolCall?.function?.name; - if (!name) { - return ""; - } - const argumentsText = toolCall?.function?.arguments?.trim(); - return argumentsText ? `${name}(${argumentsText})` : name; - }) - .filter(Boolean) - .join("\n"); + const toolCalls = log?.output_message?.tool_calls || []; + return toolCalls + .map((toolCall) => { + const name = toolCall?.function?.name; + if (!name) { + return ""; + } + const argumentsText = toolCall?.function?.arguments?.trim(); + return argumentsText ? `${name}(${argumentsText})` : name; + }) + .filter(Boolean) + .join("\n"); } function getMessageFromContent(content?: ChatMessageContent): string { - if (content == undefined) { - return ""; - } - if (typeof content === "string") { - return content; - } - let lastTextContentBlock = ""; - for (const block of content) { - if ((block.type === "text" || block.type === "input_text" || block.type === "output_text") && block.text) { - lastTextContentBlock = block.text; - } - } - return lastTextContentBlock; + if (content == undefined) { + return ""; + } + if (typeof content === "string") { + return content; + } + let lastTextContentBlock = ""; + for (const block of content) { + if ( + (block.type === "text" || + block.type === "input_text" || + block.type === "output_text") && + block.text + ) { + lastTextContentBlock = block.text; + } + } + return lastTextContentBlock; } -export function getRealtimeTurnMessages(log?: LogEntry): { tool?: string; user?: string; assistant?: string; assistantToolCall?: string } { - const toolMessages = log?.input_history?.filter((message) => message.role === "tool") || []; - const userMessages = log?.input_history?.filter((message) => message.role === "user") || []; - return { - tool: - toolMessages - .map((m) => getMessageFromContent(m.content)) - .filter(Boolean) - .join("\n") || "", - user: - userMessages - .map((m) => getMessageFromContent(m.content)) - .filter(Boolean) - .join("\n") || "", - assistant: log?.output_message ? getMessageFromContent(log.output_message.content) : "", - assistantToolCall: getAssistantToolCallSummary(log), - }; +export function getRealtimeTurnMessages(log?: LogEntry): { + tool?: string; + user?: string; + assistant?: string; + assistantToolCall?: string; +} { + const toolMessages = + log?.input_history?.filter((message) => message.role === "tool") || []; + const userMessages = + log?.input_history?.filter((message) => message.role === "user") || []; + return { + tool: + toolMessages + .map((m) => getMessageFromContent(m.content)) + .filter(Boolean) + .join("\n") || "", + user: + userMessages + .map((m) => getMessageFromContent(m.content)) + .filter(Boolean) + .join("\n") || "", + assistant: log?.output_message + ? getMessageFromContent(log.output_message.content) + : "", + assistantToolCall: getAssistantToolCallSummary(log), + }; } export function getMessage(log?: LogEntry) { - if (log?.object === "list_models") { - return "N/A"; - } - if (log?.object === "realtime.turn") { - const messages = getRealtimeTurnMessages(log); - const parts = [ - messages.tool ? `Tool Result: ${messages.tool}` : "", - messages.user ? `User: ${messages.user}` : "", - messages.assistantToolCall ? `Assistant Tool Call: ${messages.assistantToolCall}` : "", - messages.assistant ? `Assistant: ${messages.assistant}` : "", - ].filter(Boolean); - if (parts.length > 0) { - return parts.join("\n"); - } - return ""; - } - if (log?.input_history && log.input_history.length > 0) { - return getMessageFromContent(log.input_history[log.input_history.length - 1].content); - } else if (log?.responses_input_history && log.responses_input_history.length > 0) { - let lastMessage = log.responses_input_history[log.responses_input_history.length - 1]; - let lastMessageContent = lastMessage.content; - if (typeof lastMessageContent === "string") { - return lastMessageContent; - } - let lastTextContentBlock = ""; - for (const block of (lastMessageContent ?? []) as ResponsesMessageContentBlock[]) { - if (block.text && block.text !== "") { - lastTextContentBlock = block.text; - } - } - // If no content found in content field, check output field for Responses API - if (!lastTextContentBlock && lastMessage.output) { - // Handle output field - it could be a string, an array of content blocks, or a computer tool call output data - if (typeof lastMessage.output === "string") { - return lastMessage.output; - } else if (Array.isArray(lastMessage.output)) { - return lastMessage.output.map((block) => block.text).join("\n"); - } else if (lastMessage.output.type && lastMessage.output.type === "computer_screenshot") { - return lastMessage.output.image_url; - } - } - return lastTextContentBlock ?? ""; - } else if (log?.output_message) { - return getMessageFromContent(log.output_message.content); - } else if (log?.speech_input) { - return log.speech_input.input; - } else if (log?.transcription_input) { - return "Audio file"; - } else if (log?.image_generation_input?.prompt) { - return log.image_generation_input.prompt; - } - const obj = log?.object as string | undefined; - if (obj === "image_edit" || obj === "image_edit_stream" || obj === "image_variation") { - return "Image file"; - } - if (log?.content_summary) { - return log.content_summary; - } - return ""; + if (log?.object === "list_models") { + return "N/A"; + } + if (log?.object === "realtime.turn") { + const messages = getRealtimeTurnMessages(log); + const parts = [ + messages.tool ? `Tool Result: ${messages.tool}` : "", + messages.user ? `User: ${messages.user}` : "", + messages.assistantToolCall + ? `Assistant Tool Call: ${messages.assistantToolCall}` + : "", + messages.assistant ? `Assistant: ${messages.assistant}` : "", + ].filter(Boolean); + if (parts.length > 0) { + return parts.join("\n"); + } + return ""; + } + if (log?.input_history && log.input_history.length > 0) { + return getMessageFromContent( + log.input_history[log.input_history.length - 1].content, + ); + } else if ( + log?.responses_input_history && + log.responses_input_history.length > 0 + ) { + let lastMessage = + log.responses_input_history[log.responses_input_history.length - 1]; + let lastMessageContent = lastMessage.content; + if (typeof lastMessageContent === "string") { + return lastMessageContent; + } + let lastTextContentBlock = ""; + for (const block of (lastMessageContent ?? + []) as ResponsesMessageContentBlock[]) { + if (block.text && block.text !== "") { + lastTextContentBlock = block.text; + } + } + // If no content found in content field, check output field for Responses API + if (!lastTextContentBlock && lastMessage.output) { + // Handle output field - it could be a string, an array of content blocks, or a computer tool call output data + if (typeof lastMessage.output === "string") { + return lastMessage.output; + } else if (Array.isArray(lastMessage.output)) { + return lastMessage.output.map((block) => block.text).join("\n"); + } else if ( + lastMessage.output.type && + lastMessage.output.type === "computer_screenshot" + ) { + return lastMessage.output.image_url; + } + } + return lastTextContentBlock ?? ""; + } else if (log?.output_message) { + return getMessageFromContent(log.output_message.content); + } else if (log?.speech_input) { + return log.speech_input.input; + } else if (log?.transcription_input) { + return "Audio file"; + } else if (log?.image_generation_input?.prompt) { + return log.image_generation_input.prompt; + } + const obj = log?.object as string | undefined; + if ( + obj === "image_edit" || + obj === "image_edit_stream" || + obj === "image_variation" + ) { + return "Image file"; + } + if (log?.content_summary) { + return log.content_summary; + } + return ""; } -export function LogMessageCell({ log, maxWidth = "max-w-[400px]" }: { log: LogEntry; maxWidth?: string }) { - const input = getMessage(log); - const isLargePayload = log.is_large_payload_request || log.is_large_payload_response; - const realtimeMessages = log.object === "realtime.turn" ? getRealtimeTurnMessages(log) : null; +export function LogMessageCell({ + log, + maxWidth = "max-w-[400px]", +}: { + log: LogEntry; + maxWidth?: string; +}) { + const input = getMessage(log); + const isLargePayload = + log.is_large_payload_request || log.is_large_payload_response; + const realtimeMessages = + log.object === "realtime.turn" ? getRealtimeTurnMessages(log) : null; - return ( -
- {isLargePayload && ( - - LP - - )} - {realtimeMessages && - (realtimeMessages.tool || realtimeMessages.user || realtimeMessages.assistantToolCall || realtimeMessages.assistant) ? ( -
- {realtimeMessages.tool ?
Tool Result: {realtimeMessages.tool}
: null} - {realtimeMessages.user ?
User: {realtimeMessages.user}
: null} - {realtimeMessages.assistantToolCall ? ( -
Assistant Tool Call: {realtimeMessages.assistantToolCall}
- ) : null} - {realtimeMessages.assistant ?
Assistant: {realtimeMessages.assistant}
: null} -
- ) : ( -
- {input || - (isLargePayload - ? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}` - : "-")} -
- )} -
- ); + return ( +
+ {isLargePayload && ( + + LP + + )} + {realtimeMessages && + (realtimeMessages.tool || + realtimeMessages.user || + realtimeMessages.assistantToolCall || + realtimeMessages.assistant) ? ( +
+ {realtimeMessages.tool ? ( +
Tool Result: {realtimeMessages.tool}
+ ) : null} + {realtimeMessages.user ? ( +
User: {realtimeMessages.user}
+ ) : null} + {realtimeMessages.assistantToolCall ? ( +
+ Assistant Tool Call: {realtimeMessages.assistantToolCall} +
+ ) : null} + {realtimeMessages.assistant ? ( +
+ Assistant: {realtimeMessages.assistant} +
+ ) : null} +
+ ) : ( +
+ {input || + (isLargePayload + ? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}` + : "-")} +
+ )} +
+ ); } export const createColumns = ( - onDelete: (log: LogEntry) => void, - hasDeleteAccess = true, - metadataKeys: string[] = [], + onDelete: (log: LogEntry) => void, + hasDeleteAccess = true, + metadataKeys: string[] = [], ): ColumnDef[] => { - const baseColumns: ColumnDef[] = [ - { - accessorKey: "status", - header: "", - size: 8, - maxSize: 8, - cell: ({ row }) => { - const status = row.original.status as Status; - return
; - }, - }, - { - accessorKey: "timestamp", - header: ({ column }) => ( - - ), - 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"}
; - }, - }, - { - id: "request_type", - header: "Type", - size: 120, - cell: ({ row }) => { - return ( - - {RequestTypeLabels[row.original.object as keyof typeof RequestTypeLabels]} - - ); - }, - }, - { - accessorKey: "input", - header: "Message", - size: 440, - cell: ({ row }) => , - }, - { - accessorKey: "provider", - header: "Provider", - size: 160, - cell: ({ row }) => { - const provider = row.original.provider as ProviderName; - return ( - - - {provider} - - ); - }, - }, - { - accessorKey: "model", - header: "Model", - size: 160, - cell: ({ row }) =>
{row.original.model || "N/A"}
, - }, - { - accessorKey: "latency", - header: ({ column }) => ( - - ), - size: 140, - cell: ({ row }) => { - const latency = row.original.latency; - return ( -
- {latency === undefined || latency === null ? "N/A" : `${latency.toLocaleString()}ms`} -
- ); - }, - }, - { - accessorKey: "tokens", - header: ({ column }) => ( - - ), - size: 220, - cell: ({ row }) => { - const tokenUsage = row.original.token_usage; - if (!tokenUsage) { - return
N/A
; - } + const baseColumns: ColumnDef[] = [ + { + accessorKey: "status", + header: "", + size: 8, + maxSize: 8, + cell: ({ row }) => { + const status = row.original.status as Status; + return ( +
+ ); + }, + }, + { + accessorKey: "timestamp", + header: ({ column }) => ( + + ), + size: 180, + cell: ({ row }) => { + const timestamp = row.original.timestamp; + const date = timestamp ? new Date(timestamp) : null; + const isValid = date && date.toString() !== "Invalid Date"; + if (!isValid) { + return
N/A
; + } + return ( +
+ + {format(date, "MMM dd HH:mm:ss")} + + + {formatDistanceToNow(date, { addSuffix: true })} + +
+ ); + }, + }, + { + id: "request_type", + header: "Type", + size: 180, + cell: ({ row }) => { + return ( + + { + RequestTypeLabels[ + row.original.object as keyof typeof RequestTypeLabels + ] + } + + ); + }, + }, + { + accessorKey: "input", + header: "Message", + size: 250, + cell: ({ row }) => , + }, + { + accessorKey: "model", + header: "Model", + size: 220, + cell: ({ row }) => { + const provider = row.original.provider as ProviderName | undefined; + const model = row.original.model; + return ( +
+ {provider ? ( + + ) : null} +
+ + {model || "N/A"} + + + {provider ? getProviderLabel(provider) : "N/A"} + +
+
+ ); + }, + }, + { + accessorKey: "latency", + header: ({ column }) => ( + + ), + size: 170, + cell: ({ row }) => { + const latency = row.original.latency; + if (latency === undefined || latency === null) { + return
N/A
; + } + const tone = + latency >= 5000 + ? "bg-red-500" + : latency >= 2000 + ? "bg-amber-500" + : "bg-emerald-500"; + const pct = Math.min(100, (latency / 5000) * 100); + return ( +
+ + {formatLatency(latency)} + +
+
+
+
+ ); + }, + }, + { + accessorKey: "tokens", + header: ({ column }) => ( + + ), + size: 190, + cell: ({ row }) => { + const tokenUsage = row.original.token_usage; + if (!tokenUsage) { + return
N/A
; + } + const prompt = tokenUsage.prompt_tokens ?? 0; + const completion = tokenUsage.completion_tokens ?? 0; + const total = tokenUsage.total_tokens ?? 0; + const hasSplit = + tokenUsage.completion_tokens != null && + tokenUsage.prompt_tokens != null; + const splitBase = prompt + completion || 1; + const inPct = (prompt / splitBase) * 100; + return ( +
+
+ + {formatTokens(total)} + + {hasSplit && ( +
+
+
+
+ )} +
+ {hasSplit && ( +
+ {formatTokens(prompt)} + / + + {formatTokens(completion)} + +
+ )} +
+ ); + }, + }, + { + accessorKey: "cost", + header: ({ column }) => ( + + ), + size: 120, + cell: ({ row }) => { + if (row.original.cost == null) { + return
N/A
; + } + return ( +
+ {formatCost(row.original.cost)} +
+ ); + }, + }, + ]; - return ( -
-
- {tokenUsage.total_tokens.toLocaleString()}{" "} - {tokenUsage.completion_tokens != null && tokenUsage.prompt_tokens != null - ? `(${tokenUsage.prompt_tokens.toLocaleString()}+${tokenUsage.completion_tokens.toLocaleString()})` - : ""} -
-
- ); - }, - }, - { - accessorKey: "cost", - header: ({ column }) => ( - - ), - size: 120, - cell: ({ row }) => { - if (!row.original.cost) { - return
N/A
; - } + const metadataColumns: ColumnDef[] = metadataKeys.map((key) => ({ + id: `metadata_${key}`, + header: key.charAt(0).toUpperCase() + key.slice(1), + size: 126, + cell: ({ row }) => { + const value = row.original.metadata?.[key]; + return ( +
+ {value ?? "-"} +
+ ); + }, + })); - return ( -
-
{row.original.cost?.toFixed(4)}
-
- ); - }, - }, - ]; + const actionsColumn: ColumnDef = { + id: "actions", + size: 72, + cell: ({ row }) => { + const log = row.original; + return ( + + ); + }, + }; - const metadataColumns: ColumnDef[] = metadataKeys.map((key) => ({ - id: `metadata_${key}`, - header: key.charAt(0).toUpperCase() + key.slice(1), - size: 126, - cell: ({ row }) => { - const value = row.original.metadata?.[key]; - return
{value ?? "-"}
; - }, - })); - - const actionsColumn: ColumnDef = { - id: "actions", - size: 72, - cell: ({ row }) => { - const log = row.original; - return ( - - ); - }, - }; - - return [...baseColumns, ...metadataColumns, actionsColumn]; -}; \ No newline at end of file + return [...baseColumns, ...metadataColumns, actionsColumn]; +}; diff --git a/ui/app/workspace/logs/views/logsTable.tsx b/ui/app/workspace/logs/views/logsTable.tsx index 638cf41ae9..81ef8dae4a 100644 --- a/ui/app/workspace/logs/views/logsTable.tsx +++ b/ui/app/workspace/logs/views/logsTable.tsx @@ -1,285 +1,342 @@ import { - buildPinStyle, - type ColumnConfigEntry, - DraggableColumnHeader, - PIN_SHADOW_LEFT, - PIN_SHADOW_RIGHT, - useHeaderCellRefs, - usePinOffsets, + buildPinStyle, + type ColumnConfigEntry, + DraggableColumnHeader, + PIN_SHADOW_LEFT, + PIN_SHADOW_RIGHT, + 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, 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 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"; interface DataTableProps { - columns: ColumnDef[]; - data: LogEntry[]; - totalItems: number; - loading?: boolean; - pagination: Pagination; - onPaginationChange: (pagination: Pagination) => void; - onRowClick?: (log: LogEntry, columnId: string) => void; - isSocketConnected: boolean; - liveEnabled: boolean; - /** 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; + columns: ColumnDef[]; + data: LogEntry[]; + totalItems: number; + loading?: boolean; + pagination: Pagination; + onPaginationChange: (pagination: Pagination) => void; + onRowClick?: (log: LogEntry, columnId: string) => void; + isSocketConnected: boolean; + liveEnabled: boolean; + /** 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({ - columns, - data, - totalItems, - loading = false, - pagination, - onPaginationChange, - onRowClick, - isSocketConnected, - liveEnabled, - columnEntries, - columnOrder, - columnVisibility, - columnPinning, - onToggleColumnVisibility, - onTogglePin, - onReorderColumns, + columns, + data, + totalItems, + loading = false, + pagination, + onPaginationChange, + onRowClick, + isSocketConnected, + liveEnabled, + 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 [sorting, setSorting] = useState([ + { id: pagination.sort_by, desc: pagination.order === "desc" }, + ]); + const tableContainerRef = useRef(null); + const calculatedPageSize = useTablePageSize(tableContainerRef); - const fixedColumnIds = useMemo(() => new Set([]), []); + const fixedColumnIds = useMemo(() => new Set([]), []); - // Measure actual header cell widths for pixel-perfect pin offsets - const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs(); - const pinOffsets = usePinOffsets(headerCellRefs, columnPinning); + // Measure actual header cell widths for pixel-perfect pin offsets + const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs(); + const pinOffsets = usePinOffsets(headerCellRefs, columnPinning); - // Shadow on the edge of pinned groups - const lastLeftPinId = columnPinning.left?.at(-1); - const firstRightPinId = columnPinning.right?.at(0); + // Shadow on the edge of pinned groups + const lastLeftPinId = columnPinning.left?.at(-1); + const firstRightPinId = columnPinning.right?.at(0); - // Handle native drag-and-drop reorder - const handleColumnDrop = useCallback( - (draggedId: string, targetId: string) => { - 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); - onReorderColumns(newEntries); - }, - [columnEntries, onReorderColumns], - ); + // Handle native drag-and-drop reorder + const handleColumnDrop = useCallback( + (draggedId: string, targetId: string) => { + 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); + onReorderColumns(newEntries); + }, + [columnEntries, onReorderColumns], + ); - // Refs to avoid stale closures in the page size effect - const paginationRef = useRef(pagination); - const onPaginationChangeRef = useRef(onPaginationChange); - paginationRef.current = pagination; - onPaginationChangeRef.current = onPaginationChange; + // Refs to avoid stale closures in the page size effect + const paginationRef = useRef(pagination); + const onPaginationChangeRef = useRef(onPaginationChange); + paginationRef.current = pagination; + onPaginationChangeRef.current = onPaginationChange; - useEffect(() => { - if (calculatedPageSize && calculatedPageSize > paginationRef.current.limit) { - onPaginationChangeRef.current({ - ...paginationRef.current, - limit: calculatedPageSize, - offset: 0, - }); - } - }, [calculatedPageSize]); + useEffect(() => { + if ( + calculatedPageSize && + calculatedPageSize > paginationRef.current.limit + ) { + onPaginationChangeRef.current({ + ...paginationRef.current, + limit: calculatedPageSize, + offset: 0, + }); + } + }, [calculatedPageSize]); - const handleSortingChange = (updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - setSorting(newSorting); - if (newSorting.length > 0) { - const { id, desc } = newSorting[0]; - onPaginationChange({ - ...pagination, - sort_by: id as "timestamp" | "latency" | "tokens" | "cost", - order: desc ? "desc" : "asc", - }); - } - }; + const handleSortingChange = ( + updaterOrValue: SortingState | ((old: SortingState) => SortingState), + ) => { + const newSorting = + typeof updaterOrValue === "function" + ? updaterOrValue(sorting) + : updaterOrValue; + setSorting(newSorting); + if (newSorting.length > 0) { + const { id, desc } = newSorting[0]; + onPaginationChange({ + ...pagination, + sort_by: id as "timestamp" | "latency" | "tokens" | "cost", + order: desc ? "desc" : "asc", + }); + } + }; - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - manualSorting: true, - manualFiltering: true, - pageCount: Math.ceil(totalItems / pagination.limit), - state: { - sorting, - columnOrder, - columnVisibility, - columnPinning, - }, - onSortingChange: handleSortingChange, - }); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + pageCount: Math.ceil(totalItems / pagination.limit), + state: { + sorting, + columnOrder, + columnVisibility, + columnPinning, + }, + onSortingChange: handleSortingChange, + }); - const currentPage = Math.floor(pagination.offset / pagination.limit) + 1; - const totalPages = Math.ceil(totalItems / pagination.limit); - const startItem = pagination.offset + 1; - const endItem = Math.min(pagination.offset + pagination.limit, totalItems); + const hasItems = totalItems > 0; + const currentPage = hasItems ? Math.floor(pagination.offset / pagination.limit) + 1 : 0; + const totalPages = hasItems ? Math.ceil(totalItems / pagination.limit) : 0; + const startItem = hasItems ? pagination.offset + 1 : 0; + const endItem = hasItems ? Math.min(pagination.offset + pagination.limit, totalItems) : 0; - const goToPage = (page: number) => { - const newOffset = (page - 1) * pagination.limit; - onPaginationChange({ - ...pagination, - offset: newOffset, - }); - }; + const goToPage = (page: number) => { + const newOffset = (page - 1) * pagination.limit; + onPaginationChange({ + ...pagination, + offset: newOffset, + }); + }; - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {loading ? ( - - -
- - Loading logs... -
-
-
- ) : ( - <> - - -
- {!isSocketConnected ? ( - <> - - Not connected to socket, please refresh the page. - - ) : liveEnabled ? ( - <> - - Listening for logs... - - ) : ( - <> - - Live updates paused - - )} -
-
-
- {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. - - - )} - - )} -
-
-
+ return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {loading ? ( + + +
+ + Loading logs... +
+
+
+ ) : ( + <> + + +
+ {!isSocketConnected ? ( + <> + + Not connected to socket, please refresh the page. + + ) : liveEnabled ? ( + <> + + Listening for logs... + + ) : ( + <> + + Live updates paused + + )} +
+
+
+ {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( + "py-1.5 align-middle", + 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 */} -
-
- {startItem.toLocaleString()}-{endItem.toLocaleString()} of {totalItems.toLocaleString()} entries -
+ {/* Pagination Footer */} +
+
+ {startItem.toLocaleString()}-{endItem.toLocaleString()} of{" "} + {totalItems.toLocaleString()} entries +
-
- +
+ -
- Page - {currentPage} - of {totalPages} -
+
+ Page + {currentPage} + of {totalPages} +
- -
-
-
- ); -} \ No newline at end of file + +
+
+
+ ); +} diff --git a/ui/app/workspace/logs/views/logsVolumeChart.tsx b/ui/app/workspace/logs/views/logsVolumeChart.tsx index a71ed45010..ed41941d58 100644 --- a/ui/app/workspace/logs/views/logsVolumeChart.tsx +++ b/ui/app/workspace/logs/views/logsVolumeChart.tsx @@ -1,406 +1,540 @@ import { Card } from "@/components/ui/card"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Skeleton } from "@/components/ui/skeleton"; import type { HistogramBucket, LogsHistogramResponse } from "@/lib/types/logs"; import { ChevronDown, RotateCcw } from "lucide-react"; -import { Component, type ErrorInfo, type ReactNode, useCallback, useMemo, useState } from "react"; -import { Bar, BarChart, CartesianGrid, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { + Component, + type ErrorInfo, + type ReactNode, + useCallback, + useMemo, + useState, +} from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +const requestFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +function formatRequest(requests: number): string { + return requestFormatter.format(requests); +} // Empty chart placeholder when data fails to render function EmptyChart() { - return ( - - - - - - - - ); + return ( + + + + + + + + ); } // Error boundary to catch Recharts rendering errors -class ChartErrorBoundary extends Component<{ children: ReactNode; resetKey?: string }, { hasError: boolean }> { - constructor(props: { children: ReactNode; resetKey?: string }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(_: Error) { - return { hasError: true }; - } - - static getDerivedStateFromProps(props: { resetKey?: string }, state: { hasError: boolean; prevResetKey?: string }) { - // Reset error state when resetKey changes - if (props.resetKey !== state.prevResetKey) { - return { hasError: false, prevResetKey: props.resetKey }; - } - return null; - } - - componentDidCatch(error: Error, _errorInfo: ErrorInfo) { - console.warn("Chart rendering error:", error.message); - } - - render() { - if (this.state.hasError) { - return ; - } - return this.props.children; - } +class ChartErrorBoundary extends Component< + { children: ReactNode; resetKey?: string }, + { hasError: boolean } +> { + constructor(props: { children: ReactNode; resetKey?: string }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_: Error) { + return { hasError: true }; + } + + static getDerivedStateFromProps( + props: { resetKey?: string }, + state: { hasError: boolean; prevResetKey?: string }, + ) { + // Reset error state when resetKey changes + if (props.resetKey !== state.prevResetKey) { + return { hasError: false, prevResetKey: props.resetKey }; + } + return null; + } + + componentDidCatch(error: Error, _errorInfo: ErrorInfo) { + console.warn("Chart rendering error:", error.message); + } + + render() { + if (this.state.hasError) { + return ; + } + return this.props.children; + } } interface LogsVolumeChartProps { - data: LogsHistogramResponse | null; - loading?: boolean; - onTimeRangeChange: (startTime: number, endTime: number) => void; - onResetZoom?: () => void; - isZoomed?: boolean; - startTime: number; // Unix timestamp in seconds - endTime: number; // Unix timestamp in seconds - isOpen: boolean; - onOpenChange: (open: boolean) => void; + data: LogsHistogramResponse | null; + loading?: boolean; + onTimeRangeChange: (startTime: number, endTime: number) => void; + onResetZoom?: () => void; + isZoomed?: boolean; + startTime: number; // Unix timestamp in seconds + endTime: number; // Unix timestamp in seconds + isOpen: boolean; + onOpenChange: (open: boolean) => void; } // Format timestamp based on bucket size function formatTimestamp(timestamp: string, bucketSizeSeconds: number): string { - const date = new Date(timestamp); - - if (bucketSizeSeconds >= 86400) { - // Daily buckets: "Jan 20" - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - } else if (bucketSizeSeconds >= 3600) { - // Hourly buckets: "10:00" - return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - } else { - // Sub-hourly: "10:15" - return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - } + const date = new Date(timestamp); + + if (bucketSizeSeconds >= 86400) { + // Daily buckets: "Jan 20" + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } else if (bucketSizeSeconds >= 3600) { + // Hourly buckets: "10:00" + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } else { + // Sub-hourly: "10:15" + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } } // Format full timestamp for tooltip function formatFullTimestamp(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + const date = new Date(timestamp); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +type LogVolumeDataPoint = HistogramBucket & { + formattedTime: string; + index?: number; +}; + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ payload?: LogVolumeDataPoint }>; } +type ChartMouseEvent = { activeTooltipIndex?: number | string | null }; + // Custom tooltip component -function CustomTooltip({ active, payload }: any) { - if (!active || !payload || !payload.length) return null; - - const data = payload[0]?.payload as HistogramBucket & { formattedTime: string }; - if (!data) return null; - - return ( -
-
{formatFullTimestamp(data.timestamp)}
-
-
- - - Total - - {data.count.toLocaleString()} -
-
- - - Success - - {data.success.toLocaleString()} -
-
- - - Error - - {data.error.toLocaleString()} -
-
-
- ); +function CustomTooltip({ active, payload }: CustomTooltipProps) { + if (!active || !payload || !payload.length) return null; + + const data = payload[0]?.payload; + if (!data) return null; + + return ( +
+
+ {formatFullTimestamp(data.timestamp)} +
+
+
+ + + Total + + {data.count.toLocaleString()} +
+
+ + + Success + + + {data.success.toLocaleString()} + +
+
+ + + Error + + + {data.error.toLocaleString()} + +
+
+
+ ); } export function LogsVolumeChart({ - data, - loading, - onTimeRangeChange, - onResetZoom, - isZoomed, - startTime, - endTime, - isOpen, - onOpenChange, + data, + loading, + onTimeRangeChange, + onResetZoom, + isZoomed, + startTime, + endTime, + isOpen, + onOpenChange, }: LogsVolumeChartProps) { - // State for drag selection - const [refAreaLeft, setRefAreaLeft] = useState(null); - const [refAreaRight, setRefAreaRight] = useState(null); - const [isSelecting, setIsSelecting] = useState(false); - - // Transform data for chart, filling in empty buckets for the full time range - const chartData = useMemo(() => { - // Need bucket_size_seconds and valid time range - if (!data?.bucket_size_seconds || !startTime || !endTime || startTime >= endTime) { - return []; - } - - const bucketSizeMs = data.bucket_size_seconds * 1000; - - // Align start time to bucket boundary - const minTime = Math.floor((startTime * 1000) / bucketSizeMs) * bucketSizeMs; - const maxTime = endTime * 1000; - - // Safety: limit maximum number of buckets to prevent performance issues - const maxBuckets = 500; - const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs); - - if (estimatedBuckets > maxBuckets) { - // If too many buckets, just return the original data without filling - const result = (data.buckets || []).map((bucket, index) => ({ - ...bucket, - index, - formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds), - })); - // Ensure at least 2 data points for Recharts - if (result.length === 1) { - const nextTimestamp = new Date(new Date(result[0].timestamp).getTime() + bucketSizeMs).toISOString(); - result.push({ - timestamp: nextTimestamp, - count: 0, - success: 0, - error: 0, - index: 1, - formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds), - }); - } - return result; - } - - // First, create all empty buckets for the time range - const filledBuckets: Array = []; - for (let time = minTime, idx = 0; time <= maxTime; time += bucketSizeMs, idx++) { - const timestamp = new Date(time).toISOString(); - filledBuckets.push({ - timestamp, - count: 0, - success: 0, - error: 0, - index: idx, - formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds), - }); - } - - // Then, place API buckets at their correct positions using index calculation - // This is more robust than exact timestamp matching - for (const bucket of data.buckets || []) { - const bucketTime = new Date(bucket.timestamp).getTime(); - // Calculate the index for this bucket based on its offset from minTime - const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs); - - if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) { - filledBuckets[bucketIndex] = { - ...bucket, - index: bucketIndex, - formattedTime: formatTimestamp(bucket.timestamp, data.bucket_size_seconds), - }; - } - } - - // Ensure at least 2 data points for Recharts - if (filledBuckets.length === 1) { - const nextTimestamp = new Date(new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs).toISOString(); - filledBuckets.push({ - timestamp: nextTimestamp, - count: 0, - success: 0, - error: 0, - index: 1, - formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds), - }); - } - - return filledBuckets; - }, [data, startTime, endTime]); - - // Handle mouse down on chart (start selection) - const handleMouseDown = useCallback((e: any) => { - if (e?.activeTooltipIndex !== undefined) { - setRefAreaLeft(e.activeTooltipIndex); - setIsSelecting(true); - } - }, []); - - // Handle mouse move on chart (during selection) - const handleMouseMove = useCallback( - (e: any) => { - if (isSelecting && e?.activeTooltipIndex !== undefined) { - setRefAreaRight(e.activeTooltipIndex); - } - }, - [isSelecting], - ); - - // Handle mouse up on chart (end selection) - const handleMouseUp = useCallback(() => { - if (refAreaLeft === null || refAreaRight === null || !data?.bucket_size_seconds || chartData.length === 0) { - setRefAreaLeft(null); - setRefAreaRight(null); - setIsSelecting(false); - return; - } - - // Get the buckets by index - const leftBucket = chartData[refAreaLeft]; - const rightBucket = chartData[refAreaRight]; - - if (leftBucket && rightBucket) { - const leftTime = new Date(leftBucket.timestamp).getTime() / 1000; - const rightTime = new Date(rightBucket.timestamp).getTime() / 1000 + data.bucket_size_seconds; - - // Ensure left < right - const selectionStart = Math.min(leftTime, rightTime); - const selectionEnd = Math.max(leftTime, rightTime); - - // Only trigger if selection spans at least one bucket - if (selectionEnd - selectionStart >= data.bucket_size_seconds) { - onTimeRangeChange(selectionStart, selectionEnd); - } - } - - setRefAreaLeft(null); - setRefAreaRight(null); - setIsSelecting(false); - }, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]); - - // Handle click on a bar (zoom into that bucket) - const handleBarClick = useCallback( - (barData: any) => { - if (!data || !barData?.timestamp) return; - - const bucket = barData as HistogramBucket; - const startTime = new Date(bucket.timestamp).getTime() / 1000; - const endTime = startTime + data.bucket_size_seconds; - - onTimeRangeChange(startTime, endTime); - }, - [data, onTimeRangeChange], - ); - - // Check if we have valid data for the chart - const hasValidData = data && startTime && endTime && chartData.length >= 2; - - return ( - - -
- - - Request Volume - -
- {isOpen && ( -
- - - Success - - - - Error - -
- )} - {isZoomed && onResetZoom && ( - - )} -
-
- -
- {loading ? ( - - ) : hasValidData ? ( - - - - - chartData[Math.round(idx)]?.formattedTime || ""} - interval="preserveStartEnd" - /> - v.toLocaleString()} - domain={[0, (dataMax: number) => Math.max(dataMax, 5)]} - allowDataOverflow={false} - /> - } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> - handleBarClick(data)} - /> - handleBarClick(data)} - /> - {refAreaLeft !== null && refAreaRight !== null && chartData[refAreaLeft] && chartData[refAreaRight] && ( - - )} - - - - ) : ( - - )} -
-
-
-
- ); -} \ No newline at end of file + // State for drag selection + const [refAreaLeft, setRefAreaLeft] = useState(null); + const [refAreaRight, setRefAreaRight] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + + // Transform data for chart, filling in empty buckets for the full time range + const chartData = useMemo(() => { + // Need bucket_size_seconds and valid time range + if ( + !data?.bucket_size_seconds || + !startTime || + !endTime || + startTime >= endTime + ) { + return []; + } + + const bucketSizeMs = data.bucket_size_seconds * 1000; + + // Align start time to bucket boundary + const minTime = + Math.floor((startTime * 1000) / bucketSizeMs) * bucketSizeMs; + const maxTime = endTime * 1000; + + // Safety: limit maximum number of buckets to prevent performance issues + const maxBuckets = 500; + const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs); + + if (estimatedBuckets > maxBuckets) { + // If too many buckets, just return the original data without filling + const result = (data.buckets || []).map((bucket, index) => ({ + ...bucket, + index, + formattedTime: formatTimestamp( + bucket.timestamp, + data.bucket_size_seconds, + ), + })); + // Ensure at least 2 data points for Recharts + if (result.length === 1) { + const nextTimestamp = new Date( + new Date(result[0].timestamp).getTime() + bucketSizeMs, + ).toISOString(); + result.push({ + timestamp: nextTimestamp, + count: 0, + success: 0, + error: 0, + index: 1, + formattedTime: formatTimestamp( + nextTimestamp, + data.bucket_size_seconds, + ), + }); + } + return result; + } + + // First, create all empty buckets for the time range + const filledBuckets: Array< + HistogramBucket & { formattedTime: string; index: number } + > = []; + for ( + let time = minTime, idx = 0; + time < maxTime; + time += bucketSizeMs, idx++ + ) { + const timestamp = new Date(time).toISOString(); + filledBuckets.push({ + timestamp, + count: 0, + success: 0, + error: 0, + index: idx, + formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds), + }); + } + + // Then, place API buckets at their correct positions using index calculation + // This is more robust than exact timestamp matching + for (const bucket of data.buckets || []) { + const bucketTime = new Date(bucket.timestamp).getTime(); + // Calculate the index for this bucket based on its offset from minTime + const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs); + + if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) { + filledBuckets[bucketIndex] = { + ...bucket, + index: bucketIndex, + formattedTime: formatTimestamp( + bucket.timestamp, + data.bucket_size_seconds, + ), + }; + } + } + + // Ensure at least 2 data points for Recharts + if (filledBuckets.length === 1) { + const nextTimestamp = new Date( + new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs, + ).toISOString(); + filledBuckets.push({ + timestamp: nextTimestamp, + count: 0, + success: 0, + error: 0, + index: 1, + formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds), + }); + } + + return filledBuckets; + }, [data, startTime, endTime]); + + // Handle mouse down on chart (start selection) + const handleMouseDown = useCallback((e: ChartMouseEvent) => { + if (typeof e?.activeTooltipIndex === "number") { + setRefAreaLeft(e.activeTooltipIndex); + setIsSelecting(true); + } + }, []); + + // Handle mouse move on chart (during selection) + const handleMouseMove = useCallback( + (e: ChartMouseEvent) => { + if (isSelecting && typeof e?.activeTooltipIndex === "number") { + setRefAreaRight(e.activeTooltipIndex); + } + }, + [isSelecting], + ); + + // Handle mouse up on chart (end selection) + const handleMouseUp = useCallback(() => { + if ( + refAreaLeft === null || + refAreaRight === null || + !data?.bucket_size_seconds || + chartData.length === 0 + ) { + setRefAreaLeft(null); + setRefAreaRight(null); + setIsSelecting(false); + return; + } + + // Get the buckets by index + const leftBucket = chartData[refAreaLeft]; + const rightBucket = chartData[refAreaRight]; + + if (leftBucket && rightBucket) { + const leftTime = new Date(leftBucket.timestamp).getTime() / 1000; + const rightTime = new Date(rightBucket.timestamp).getTime() / 1000; + + // Ensure left < right; the end edge is one bucket past the later timestamp + const selectionStart = Math.min(leftTime, rightTime); + const selectionEnd = + Math.max(leftTime, rightTime) + data.bucket_size_seconds; + + // Only trigger if selection spans at least one bucket + if (selectionEnd - selectionStart >= data.bucket_size_seconds) { + onTimeRangeChange(selectionStart, selectionEnd); + } + } + + setRefAreaLeft(null); + setRefAreaRight(null); + setIsSelecting(false); + }, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]); + + // Handle click on a bar (zoom into that bucket) + const handleBarClick = useCallback( + (barData: LogVolumeDataPoint | undefined) => { + if (!data || !barData?.timestamp) return; + + const startTime = new Date(barData.timestamp).getTime() / 1000; + const endTime = startTime + data.bucket_size_seconds; + + onTimeRangeChange(startTime, endTime); + }, + [data, onTimeRangeChange], + ); + + // Check if we have valid data for the chart + const hasValidData = data && startTime && endTime && chartData.length >= 2; + + return ( + + +
+ + + + Request Volume + + +
+ {isOpen && ( +
+ + + Success + + + + Error + +
+ )} + {isZoomed && onResetZoom && ( + + )} +
+
+ +
+ {loading ? ( + + ) : hasValidData ? ( + + + + + + chartData[Math.round(idx)]?.formattedTime || "" + } + interval="preserveStartEnd" + /> + formatRequest(v)} + domain={[0, (dataMax: number) => Math.max(dataMax, 5)]} + allowDataOverflow={false} + /> + } + cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} + /> + handleBarClick(data?.payload as LogVolumeDataPoint | undefined)} + /> + handleBarClick(data?.payload as LogVolumeDataPoint | undefined)} + /> + {refAreaLeft !== null && + refAreaRight !== null && + chartData[refAreaLeft] && + chartData[refAreaRight] && ( + + )} + + + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/ui/lib/constants/icons.tsx b/ui/lib/constants/icons.tsx index dd32575859..c9d165abdd 100644 --- a/ui/lib/constants/icons.tsx +++ b/ui/lib/constants/icons.tsx @@ -3,205 +3,222 @@ import { useTheme } from "next-themes"; type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | number; type IconProps = { - size?: IconSize; - className?: string; - theme?: string; + size?: IconSize; + className?: string; + theme?: string; }; // Size mapping in pixels const sizeMap: Record = { - xs: 24, - sm: 32, - md: 40, - lg: 48, - xl: 64, + xs: 20, + sm: 32, + md: 40, + lg: 48, + xl: 64, }; // Function to resolve size value const resolveSize = (size: IconSize): number => { - if (typeof size === "number") return size; - return sizeMap[size] || sizeMap.md; + if (typeof size === "number") return size; + return sizeMap[size] || sizeMap.md; }; // Provider Icons with theme awareness where applicable export const ProviderIcons = { - anthropic: ({ size = "md", className = "", theme }: IconProps) => { - const resolvedSize = resolveSize(size); - return theme === "light" ? ( - - - - ) : ( - - - - ); - }, - - azure: ({ className = "" }: IconProps) => { - return azure; - }, - bedrock: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - return ( - - - - - - - - - - - ); - }, - - cerebras: ({ size = "md", className = "", theme }: IconProps) => { - const resolvedSize = resolveSize(size); - - return theme === "light" ? ( - - Cerebras - - - - ) : ( - - Cerebras - - - - ); - }, - - cohere: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - return ( - - - - - - ); - }, - - elevenlabs: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - - - - - ); - }, - - groq: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - { + const resolvedSize = resolveSize(size); + return theme === "light" ? ( + + + + ) : ( + + + + ); + }, + + azure: ({ className = "" }: IconProps) => { + return ( + azure + ); + }, + bedrock: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + + + + + + + + + + ); + }, + + cerebras: ({ size = "md", className = "", theme }: IconProps) => { + const resolvedSize = resolveSize(size); + + return theme === "light" ? ( + + Cerebras + + + + ) : ( + + Cerebras + + + + ); + }, + + cohere: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + + + + + ); + }, + + elevenlabs: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + + + + + ); + }, + + groq: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + - - - ); - }, - - mistral: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - - - - - - - ); - }, - - ollama: ({ size = "md", className = "", theme }: IconProps) => { - const resolvedSize = resolveSize(size); - return theme === "light" ? ( - - - - ) : ( - - - - ); - }, - - parasail: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - return ( - - - - - - ); - }, - - perplexity: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - return ( - - Perplexity - - - ); - }, - - sgl: ({ className = "" }: IconProps) => { - return sgl; - }, - openai: ({ size = "md", className = "", theme }: IconProps) => { - const resolvedSize = resolveSize(size); - - return theme === "light" ? ( - - - - ) : ( - - - - ); - }, - - vertex: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - - - - - - - - - - - - - - - - - ); - }, - - gemini: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - Gemini - - - - - - - - - - - - - - - - - - - - ); - }, - - openrouter: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - OpenRouter - - - ); - }, - - huggingface: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - HuggingFace - - - - - - - - ); - }, - nebius: ({ className = "" }: IconProps) => { - return nebius; - }, - xai: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - Grok - - - ); - }, - replicate: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - Replicate - - - ); - }, - vllm: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - return ( - - vLLM - - - - ); - }, - runway: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - Runway - - - ); - }, - fireworks: ({ size = "md", className = "" }: IconProps) => { - const resolvedSize = resolveSize(size); - - return ( - - Fireworks AI - - - ); - }, + /> + + ); + }, + + mistral: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + + + + + + + ); + }, + + ollama: ({ size = "md", className = "", theme }: IconProps) => { + const resolvedSize = resolveSize(size); + return theme === "light" ? ( + + + + ) : ( + + + + ); + }, + + parasail: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + + + + + ); + }, + + perplexity: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + Perplexity + + + ); + }, + + sgl: ({ className = "" }: IconProps) => { + return ( + sgl + ); + }, + openai: ({ size = "md", className = "", theme }: IconProps) => { + const resolvedSize = resolveSize(size); + + return theme === "light" ? ( + + + + ) : ( + + + + ); + }, + + vertex: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + + + + + + + + + + + + + + + + + ); + }, + + gemini: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + Gemini + + + + + + + + + + + + + + + + + + + + ); + }, + + openrouter: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + OpenRouter + + + ); + }, + + huggingface: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + HuggingFace + + + + + + + + ); + }, + nebius: ({ className = "" }: IconProps) => { + return ( + nebius + ); + }, + xai: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + Grok + + + ); + }, + replicate: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + Replicate + + + ); + }, + vllm: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + vLLM + + + + ); + }, + runway: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + Runway + + + ); + }, + fireworks: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + + return ( + + Fireworks AI + + + ); + }, } as const; // Routing Engine Icons export const RoutingEngineUsedIcons = { - "routing-rule": ({ className = "h-5 w-5 text-blue-800" }: { className?: string } = {}) => , - governance: ({ className = "h-5 w-5 text-green-800" }: { className?: string } = {}) => , - loadbalancing: ({ className = "h-5 w-5 text-red-800" }: { className?: string } = {}) => , + "routing-rule": ({ + className = "h-5 w-5 text-blue-800", + }: { className?: string } = {}) => , + governance: ({ + className = "h-5 w-5 text-green-800", + }: { className?: string } = {}) => , + loadbalancing: ({ + className = "h-5 w-5 text-red-800", + }: { className?: string } = {}) => , } as const; export type RoutingEngineType = keyof typeof RoutingEngineUsedIcons; // Helper component to render provider icons -export const RenderProviderIcon = ({ provider, ...props }: IconProps & { provider: keyof typeof ProviderIcons }) => { - const { resolvedTheme } = useTheme(); - const IconComponent = ProviderIcons[provider]; - return IconComponent ? IconComponent({ ...props, theme: resolvedTheme }) : null; +export const RenderProviderIcon = ({ + provider, + ...props +}: IconProps & { provider: keyof typeof ProviderIcons }) => { + const { resolvedTheme } = useTheme(); + const IconComponent = ProviderIcons[provider]; + return IconComponent + ? IconComponent({ ...props, theme: resolvedTheme }) + : null; }; export type ProviderIconType = keyof typeof ProviderIcons; -export default ProviderIcons; \ No newline at end of file +export default ProviderIcons; diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts index 08501bf695..a5ef193506 100644 --- a/ui/lib/utils.ts +++ b/ui/lib/utils.ts @@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} \ No newline at end of file + return twMerge(clsx(inputs)); +}