first commit
This commit is contained in:
6
ui/app/workspace/logs/connectors/layout.tsx
Normal file
6
ui/app/workspace/logs/connectors/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ConnectorsPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/logs/connectors")({
|
||||
component: ConnectorsPage,
|
||||
});
|
||||
5
ui/app/workspace/logs/connectors/page.tsx
Normal file
5
ui/app/workspace/logs/connectors/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ObservabilityConnectorsView from "@/app/workspace/observability/views/observabilityView";
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
return <ObservabilityConnectorsView />;
|
||||
}
|
||||
17
ui/app/workspace/logs/layout.tsx
Normal file
17
ui/app/workspace/logs/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
|
||||
import LogsPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasViewLogsAccess = useRbac(RbacResource.Logs, RbacOperation.View);
|
||||
const childMatches = useChildMatches();
|
||||
if (!hasViewLogsAccess) {
|
||||
return <NoPermissionView entity="logs" />;
|
||||
}
|
||||
return <div className="flex h-full flex-col">{childMatches.length === 0 ? <LogsPage /> : <Outlet />}</div>;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/logs")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
7
ui/app/workspace/logs/mcp-logs/layout.tsx
Normal file
7
ui/app/workspace/logs/mcp-logs/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspace/logs/mcp-logs")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/workspace/mcp-logs", replace: true });
|
||||
},
|
||||
});
|
||||
814
ui/app/workspace/logs/page.tsx
Normal file
814
ui/app/workspace/logs/page.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
import { LogDetailSheet } from "@/app/workspace/logs/sheets/logDetailsSheet";
|
||||
import { SessionDetailsSheet } from "@/app/workspace/logs/sheets/sessionDetailsSheet";
|
||||
import { createColumns } from "@/app/workspace/logs/views/columns";
|
||||
import { EmptyState } from "@/app/workspace/logs/views/emptyState";
|
||||
import { LogsHeaderView } from "@/app/workspace/logs/views/logsHeaderView";
|
||||
import { LogsDataTable } from "@/app/workspace/logs/views/logsTable";
|
||||
import { LogsVolumeChart } from "@/app/workspace/logs/views/logsVolumeChart";
|
||||
import { LogsFilterSidebar } from "@/components/filters/logsFilterSidebar";
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { useColumnConfig } from "@/components/table";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
getErrorMessage,
|
||||
useDeleteLogsMutation,
|
||||
useGetAvailableFilterDataQuery,
|
||||
useGetLogsHistogramQuery,
|
||||
useGetLogsQuery,
|
||||
useGetLogsStatsQuery,
|
||||
} from "@/lib/store";
|
||||
import { useLazyGetLogByIdQuery, useLazyGetLogsQuery } from "@/lib/store/apis/logsApi";
|
||||
import type { LogEntry, LogFilters, Pagination } from "@/lib/types/logs";
|
||||
import { dateUtils } from "@/lib/types/logs";
|
||||
import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
|
||||
import { getRangeForPeriod } from "@/lib/utils/timeRange";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const hasCheckedEmptyState = useRef(false);
|
||||
|
||||
const hasDeleteAccess = useRbac(RbacResource.Logs, RbacOperation.Delete);
|
||||
|
||||
const [deleteLogs] = useDeleteLogsMutation();
|
||||
// Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
|
||||
const [triggerGetLogs] = useLazyGetLogsQuery();
|
||||
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(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<LogEntry | null>(null);
|
||||
|
||||
// Track if user has manually modified the time range
|
||||
const userModifiedTimeRange = useRef<boolean>(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();
|
||||
|
||||
const { search } = useLocation();
|
||||
const hasExplicitTimeRange = (search as Record<string, unknown>)?.start_time && (search as Record<string, unknown>)?.end_time;
|
||||
|
||||
// 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"),
|
||||
polling: parseAsBoolean.withDefault(true).withOptions({ clearOnDefault: false }),
|
||||
period: parseAsString.withDefault(hasExplicitTimeRange ? "" : "1h").withOptions({ clearOnDefault: false }),
|
||||
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 activeLogFetchId = useRef<string | null>(null);
|
||||
const polling = urlState.polling;
|
||||
|
||||
// Refresh time range on page focus/visibility
|
||||
useEffect(() => {
|
||||
const refreshDefaultsIfStale = () => {
|
||||
if (!polling) return
|
||||
if (urlState.period) {
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Absolute custom range: skip refresh if user explicitly set it
|
||||
if (userModifiedTimeRange.current) return;
|
||||
|
||||
// Only slide back to default 1h if the timestamps still match the initial defaults
|
||||
const startTimeDiff = Math.abs(urlState.start_time - initialDefaults.current.startTime);
|
||||
const endTimeDiff = Math.abs(urlState.end_time - initialDefaults.current.endTime);
|
||||
const tolerance = 5;
|
||||
if (startTimeDiff <= tolerance && endTimeDiff <= tolerance) {
|
||||
const defaults = getDefaultTimeRange();
|
||||
const currentEndDiff = Math.abs(urlState.end_time - defaults.endTime);
|
||||
if (currentEndDiff > 300) {
|
||||
setUrlState(
|
||||
{
|
||||
start_time: defaults.startTime,
|
||||
end_time: defaults.endTime,
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
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.period, urlState.start_time, urlState.end_time, setUrlState, polling]);
|
||||
|
||||
// Refresh the time window every 5s while live polling is on and a relative period is active.
|
||||
// Updating start_time/end_time changes RTK args → triggers a refetch without needing pollingInterval.
|
||||
useEffect(() => {
|
||||
if (!polling || !urlState.period) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [polling, urlState.period, 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 period = urlState.period;
|
||||
|
||||
// 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
|
||||
const timeChanged = newFilters.start_time !== filters.start_time || newFilters.end_time !== filters.end_time;
|
||||
if (timeChanged) {
|
||||
userModifiedTimeRange.current = true;
|
||||
}
|
||||
|
||||
setUrlState({
|
||||
// Clear the period whenever an absolute range is applied via setFilters
|
||||
...(timeChanged && { period: "" }),
|
||||
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) => {
|
||||
userModifiedTimeRange.current = true;
|
||||
setUrlState({
|
||||
period: "",
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
[setUrlState],
|
||||
);
|
||||
|
||||
// Handler for resetting zoom to default 1h view
|
||||
const handleResetZoom = useCallback(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHour = now - 1 * 60 * 60;
|
||||
setUrlState({
|
||||
start_time: oneHour,
|
||||
end_time: now,
|
||||
offset: 0,
|
||||
});
|
||||
}, [setUrlState]);
|
||||
|
||||
// Check if user has zoomed (time range is different from default 1h)
|
||||
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]);
|
||||
|
||||
// Non-lazy RTK Query hooks — RTK handles caching, deduplication, and loading states.
|
||||
// pollingInterval is only set for the no-period case; period polling is handled by
|
||||
// a setInterval that updates URL timestamps, which changes args and triggers RTK to refetch.
|
||||
const {
|
||||
data: logsData,
|
||||
isLoading: logsIsLoading,
|
||||
isFetching: logsIsFetching,
|
||||
error: logsError,
|
||||
refetch: refetchLogs,
|
||||
} = useGetLogsQuery(
|
||||
{ filters, pagination },
|
||||
{
|
||||
// Poll every 5s on the empty state page so we transition as soon as the first log arrives.
|
||||
// When a relative period is active, the setInterval above updates URL timestamps → RTK
|
||||
// detects arg changes and refetches automatically; no separate pollingInterval needed.
|
||||
pollingInterval: showEmptyState ? 5000 : polling && !period ? 5000 : 0,
|
||||
refetchOnMountOrArgChange: true,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: stats,
|
||||
isFetching: statsIsFetching,
|
||||
refetch: refetchStats,
|
||||
} = useGetLogsStatsQuery(
|
||||
{ filters },
|
||||
{
|
||||
pollingInterval: polling && !period ? 5000 : 0,
|
||||
refetchOnMountOrArgChange: true,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: histogram,
|
||||
isLoading: histogramIsLoading,
|
||||
refetch: refetchHistogram,
|
||||
} = useGetLogsHistogramQuery(
|
||||
{ filters },
|
||||
{
|
||||
pollingInterval: polling && !period ? 5000 : 0,
|
||||
refetchOnMountOrArgChange: true,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Set showEmptyState on first response; clear it as soon as logs appear.
|
||||
useEffect(() => {
|
||||
if (!logsData) return;
|
||||
if (!hasCheckedEmptyState.current) {
|
||||
setShowEmptyState(!logsData.has_logs);
|
||||
hasCheckedEmptyState.current = true;
|
||||
} else if (showEmptyState && logsData.has_logs) {
|
||||
setShowEmptyState(false);
|
||||
}
|
||||
}, [logsData, showEmptyState]);
|
||||
|
||||
// On mount: if period is set and stored timestamps are stale, freshen them so the
|
||||
// initial query uses the correct window (RTK will refetch when args change).
|
||||
useEffect(() => {
|
||||
if (urlState.period) {
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
const freshEnd = Math.floor(to.getTime() / 1000);
|
||||
if (Math.abs(urlState.end_time - freshEnd) > 60) {
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: freshEnd,
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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();
|
||||
if (urlState.selected_log === log.id) {
|
||||
setUrlState({ selected_log: "" });
|
||||
}
|
||||
refetchLogs();
|
||||
refetchStats();
|
||||
refetchHistogram();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
},
|
||||
[deleteLogs, urlState.selected_log, setUrlState, refetchLogs, refetchStats, refetchHistogram],
|
||||
);
|
||||
|
||||
const handlePollToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setUrlState({ polling: enabled });
|
||||
if (enabled) {
|
||||
refetchLogs();
|
||||
refetchStats();
|
||||
refetchHistogram();
|
||||
}
|
||||
},
|
||||
[setUrlState, refetchLogs, refetchStats, refetchHistogram],
|
||||
);
|
||||
|
||||
// Period selection: store relative period + fresh timestamps in URL (bypasses setFilters
|
||||
// so userModifiedTimeRange stays false and tab-focus refresh keeps working)
|
||||
const handlePeriodChange = useCallback(
|
||||
(p: string, from: Date, to: Date) => {
|
||||
setUrlState({
|
||||
period: p,
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
[setUrlState],
|
||||
);
|
||||
|
||||
const statCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Total Requests",
|
||||
value: <NumberFlow value={stats?.total_requests ?? 0} format={COMPACT_NUMBER_FORMAT} />,
|
||||
icon: <BarChart className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: <NumberFlow value={stats?.success_rate ?? 0} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} suffix="%" />,
|
||||
icon: <CheckCircle className="size-4" />,
|
||||
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: (
|
||||
<NumberFlow
|
||||
value={stats?.user_facing_success_rate ?? 0}
|
||||
format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
|
||||
suffix="%"
|
||||
/>
|
||||
),
|
||||
icon: <CheckCircle className="size-4" />,
|
||||
description: "Success rate as perceived by the end user. It includes fallback chains as one request.",
|
||||
},
|
||||
{
|
||||
title: "Avg Latency",
|
||||
value: (
|
||||
<NumberFlow value={stats?.average_latency ?? 0} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} suffix="ms" />
|
||||
),
|
||||
icon: <Clock className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Total Tokens",
|
||||
value: <NumberFlow value={stats?.total_tokens ?? 0} format={COMPACT_NUMBER_FORMAT} />,
|
||||
icon: <Hash className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Total Cost",
|
||||
value: (
|
||||
<NumberFlow
|
||||
value={stats?.total_cost ?? 0}
|
||||
format={{
|
||||
...COMPACT_NUMBER_FORMAT,
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <DollarSign className="size-4" />,
|
||||
},
|
||||
],
|
||||
[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<string, string> = 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 logs = logsData?.logs ?? [];
|
||||
const totalItems = logsData?.stats?.total_requests ?? 0;
|
||||
const selectedLogFromData = useMemo(
|
||||
() => (selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null),
|
||||
[selectedLogId, logs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLogId || selectedLogFromData) {
|
||||
setFetchedLog(null);
|
||||
activeLogFetchId.current = null;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
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 (
|
||||
<div className="dark:bg-card no-padding-parent no-border-parent h-[calc(100vh_-_16px)]">
|
||||
{logsIsLoading ? (
|
||||
<FullPageLoader />
|
||||
) : showEmptyState ? (
|
||||
<EmptyState error={error ?? (logsError ? getErrorMessage(logsError as Parameters<typeof getErrorMessage>[0]) : null)} />
|
||||
) : (
|
||||
<div className="bg-background flex h-full w-full grow gap-3">
|
||||
{/* Sidebar Filters */}
|
||||
<LogsFilterSidebar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-card flex min-w-0 flex-1 flex-col gap-2 overflow-hidden rounded-l-md p-4 pb-2">
|
||||
<div className="shrink-0">
|
||||
<LogsHeaderView
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
fetchLogs={async () => {
|
||||
await refetchLogs();
|
||||
}}
|
||||
fetchStats={async () => {
|
||||
await refetchStats();
|
||||
}}
|
||||
fetchHistogram={async () => {
|
||||
await refetchHistogram();
|
||||
}}
|
||||
loading={logsIsFetching}
|
||||
polling={polling}
|
||||
onPollToggle={handlePollToggle}
|
||||
period={period}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
columnEntries={columnEntries}
|
||||
columnLabels={COLUMN_LABELS}
|
||||
onToggleColumnVisibility={toggleColumnVisibility}
|
||||
onResetColumns={resetColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{statCards.map((card) => (
|
||||
<Card key={card.title} className="py-4 shadow-none">
|
||||
<CardContent
|
||||
className={`flex items-center justify-between px-4 transition-opacity duration-200 ${statsIsFetching ? "opacity-50" : "opacity-100"}`}
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<span className="truncate">{card.title}</span>
|
||||
{"description" in card && card.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${card.title} info`}
|
||||
data-testid={`logs-metric-info-${card.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<Info className="size-3 cursor-help" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72 text-left text-xs text-wrap">{card.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xl font-medium sm:text-2xl">{card.value}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<LogsVolumeChart
|
||||
data={histogram ?? null}
|
||||
loading={histogramIsLoading}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onResetZoom={handleResetZoom}
|
||||
isZoomed={isZoomed}
|
||||
startTime={urlState.start_time}
|
||||
endTime={urlState.end_time}
|
||||
isOpen={isChartOpen}
|
||||
onOpenChange={setIsChartOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(error || !!logsError) && (
|
||||
<Alert variant="destructive" className="shrink-0">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error ?? (logsError ? getErrorMessage(logsError as Parameters<typeof getErrorMessage>[0]) : "")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<LogsDataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
loading={logsIsFetching}
|
||||
totalItems={totalItems}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onRowClick={(row, columnId) => {
|
||||
if (columnId === "actions") return;
|
||||
setUrlState({ selected_log: row.id }, { history: "replace" });
|
||||
setSelectedSessionId(null);
|
||||
setSessionHighlightedLogId(null);
|
||||
}}
|
||||
polling={polling}
|
||||
onRefresh={refetchLogs}
|
||||
columnEntries={columnEntries}
|
||||
columnOrder={columnOrder}
|
||||
columnVisibility={columnVisibility}
|
||||
columnPinning={columnPinning}
|
||||
onToggleColumnVisibility={toggleColumnVisibility}
|
||||
onTogglePin={toggleColumnPin}
|
||||
onReorderColumns={reorderColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Detail Sheet */}
|
||||
<LogDetailSheet
|
||||
log={selectedLog}
|
||||
open={selectedLog !== null}
|
||||
onOpenChange={(open) => !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);
|
||||
}}
|
||||
/>
|
||||
<SessionDetailsSheet
|
||||
sessionId={selectedSessionId}
|
||||
highlightedLogId={sessionHighlightedLogId}
|
||||
open={selectedSessionId !== null}
|
||||
onOpenChange={handleSessionSheetOpenChange}
|
||||
onLogClick={(log) => {
|
||||
setSelectedSessionId(null);
|
||||
setUrlState({ selected_log: log.id }, { history: "replace" });
|
||||
}}
|
||||
onFilterByParentRequestId={handleFilterByParentRequestId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2241
ui/app/workspace/logs/sheets/logDetailView.tsx
Normal file
2241
ui/app/workspace/logs/sheets/logDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
131
ui/app/workspace/logs/sheets/logDetailsSheet.tsx
Normal file
131
ui/app/workspace/logs/sheets/logDetailsSheet.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useGetLogByIdQuery } from "@/lib/store/apis/logsApi";
|
||||
import { useGetPromptQuery } from "@/lib/store/apis/promptsApi";
|
||||
import type { LogEntry } from "@/lib/types/logs";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
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;
|
||||
}
|
||||
|
||||
export function LogDetailSheet({
|
||||
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 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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 ?? "";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="border-secondary flex w-full flex-col gap-4 overflow-x-hidden border p-8 sm:max-w-[60%]">
|
||||
{!isFullDataReady ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<SheetTitle className="sr-only">Loading log details</SheetTitle>
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<LogDetailView
|
||||
log={displayLog}
|
||||
resolvedSelectedPromptName={resolvedSelectedPromptName}
|
||||
handleDelete={handleDelete}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onFilterByParentRequestId={onFilterByParentRequestId}
|
||||
headerAction={
|
||||
<>
|
||||
{displayLog.parent_request_id && onViewSession ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="session-button-view"
|
||||
onClick={() => onViewSession(displayLog.parent_request_id as string, displayLog.id)}
|
||||
>
|
||||
View Session
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onNavigate?.("prev")}
|
||||
aria-label="Previous log"
|
||||
data-testid="logdetails-prev-button"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onNavigate?.("next")}
|
||||
aria-label="Next log"
|
||||
data-testid="logdetails-next-button"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
22
ui/app/workspace/logs/sheets/observabilityConfigSheet.tsx
Normal file
22
ui/app/workspace/logs/sheets/observabilityConfigSheet.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import ObservabilityView from "@/app/workspace/config/views/observabilityView";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface ObservabilityConfigSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ObservabilityConfigSheet({ open, onOpenChange }: ObservabilityConfigSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
|
||||
<SheetHeader className="flex flex-row items-center px-0">
|
||||
<SheetTitle>Observability settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-2">
|
||||
<ObservabilityView />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
22
ui/app/workspace/logs/sheets/observabilitySettingsSheet.tsx
Normal file
22
ui/app/workspace/logs/sheets/observabilitySettingsSheet.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import LoggingView from "@/app/workspace/config/views/loggingView";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface ObservabilitySettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ObservabilitySettingsSheet({ open, onOpenChange }: ObservabilitySettingsSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col overflow-x-hidden px-8 pt-6 sm:max-w-3xl">
|
||||
<SheetHeader className="">
|
||||
<SheetTitle className="text-lg font-semibold">Logging settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<LoggingView />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
328
ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
Normal file
328
ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import type { ProviderName } from "@/lib/constants/logs";
|
||||
import { RequestTypeColors, RequestTypeLabels, Status, StatusBarColors } from "@/lib/constants/logs";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useGetLogSessionSummaryByIdQuery, useLazyGetLogSessionByIdQuery } from "@/lib/store/apis/logsApi";
|
||||
import { LogEntry } from "@/lib/types/logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDown, ArrowUp, Loader2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LogMessageCell } from "../views/columns";
|
||||
|
||||
const SESSION_LOG_PAGE_SIZE = 500;
|
||||
|
||||
const HIGHLIGHTED_ROW =
|
||||
"border-l-2 border-l-sky-500 bg-sky-500/[0.08] shadow-[inset_0_0_0_1px_rgba(56,189,248,0.18)] hover:bg-sky-500/[0.24] hover:shadow-[inset_0_0_0_1px_rgba(56,189,248,0.38)] dark:hover:bg-sky-400/[0.18]";
|
||||
|
||||
function formatDurationFromMs(durationMs?: number) {
|
||||
if (!durationMs || durationMs <= 0) return "0s";
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
interface SummaryCard {
|
||||
label: string;
|
||||
value: string;
|
||||
helper?: string;
|
||||
size?: "sm";
|
||||
}
|
||||
|
||||
interface SessionDetailsSheetProps {
|
||||
sessionId: string | null;
|
||||
highlightedLogId?: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLogClick?: (log: LogEntry) => void;
|
||||
onFilterByParentRequestId?: (parentRequestId: string) => void;
|
||||
}
|
||||
|
||||
export function SessionDetailsSheet({
|
||||
sessionId,
|
||||
highlightedLogId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onLogClick,
|
||||
onFilterByParentRequestId,
|
||||
}: SessionDetailsSheetProps) {
|
||||
const [triggerGetSession] = useLazyGetLogSessionByIdQuery();
|
||||
const [sessionLogs, setSessionLogs] = useState<LogEntry[]>([]);
|
||||
const [loadingSession, setLoadingSession] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [fetchedCount, setFetchedCount] = useState(0);
|
||||
const fetchedCountRef = useRef(fetchedCount);
|
||||
const totalCountRef = useRef(totalCount);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { data: sessionSummary } = useGetLogSessionSummaryByIdQuery(sessionId || "", {
|
||||
skip: !open || !sessionId,
|
||||
pollingInterval: 5000,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const summaryCards: SummaryCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Logs",
|
||||
value: (sessionSummary?.count || 0).toLocaleString(),
|
||||
helper: sessionSummary && sessionLogs.length < sessionSummary.count ? `(${sessionLogs.length.toLocaleString()} loaded)` : undefined,
|
||||
},
|
||||
{
|
||||
label: "Total Cost",
|
||||
value: `$${(sessionSummary?.total_cost || 0).toFixed(4)}`,
|
||||
},
|
||||
{
|
||||
label: "Total Tokens",
|
||||
value: (sessionSummary?.total_tokens || 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: "Started",
|
||||
value: sessionSummary?.started_at ? format(new Date(sessionSummary.started_at), "MMM d, yyyy hh:mm:ss aa") : "N/A",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
label: "Latest Update",
|
||||
value: sessionSummary?.latest_at ? format(new Date(sessionSummary.latest_at), "MMM d, yyyy hh:mm:ss aa") : "N/A",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
label: "Duration",
|
||||
value: formatDurationFromMs(sessionSummary?.duration_ms),
|
||||
},
|
||||
],
|
||||
[sessionSummary, sessionLogs.length],
|
||||
);
|
||||
|
||||
const sortSessionLogs = useCallback(
|
||||
(logs: LogEntry[]) =>
|
||||
[...logs].sort((a, b) =>
|
||||
sortOrder === "asc"
|
||||
? new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
: new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
const loadSessionPage = useCallback(
|
||||
async (offset: number, reset = false) => {
|
||||
if (!sessionId) return;
|
||||
setLoadingSession(true);
|
||||
try {
|
||||
const result = await triggerGetSession({
|
||||
sessionId,
|
||||
pagination: { limit: SESSION_LOG_PAGE_SIZE, offset, order: sortOrder },
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error("Failed to load session logs", {
|
||||
description: getErrorMessage(result.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
if (reset && result.data.count === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setTotalCount(result.data.count);
|
||||
setHasMore(result.data.has_more);
|
||||
setFetchedCount(offset + result.data.returned_count);
|
||||
setSessionLogs((prev) => {
|
||||
const next = reset ? result.data!.logs : [...prev, ...result.data!.logs];
|
||||
const seen = new Map<string, LogEntry>();
|
||||
for (const log of next) {
|
||||
seen.set(log.id, log);
|
||||
}
|
||||
return sortSessionLogs(Array.from(seen.values()));
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingSession(false);
|
||||
}
|
||||
},
|
||||
[onOpenChange, sessionId, sortOrder, sortSessionLogs, triggerGetSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchedCountRef.current = fetchedCount;
|
||||
}, [fetchedCount]);
|
||||
useEffect(() => {
|
||||
totalCountRef.current = totalCount;
|
||||
}, [totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) {
|
||||
return;
|
||||
}
|
||||
setSessionLogs([]);
|
||||
setFetchedCount(0);
|
||||
setTotalCount(0);
|
||||
fetchedCountRef.current = 0;
|
||||
totalCountRef.current = 0;
|
||||
setHasMore(false);
|
||||
loadSessionPage(0, true);
|
||||
}, [open, sessionId, sortOrder, loadSessionPage]);
|
||||
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-medium">Session</div>
|
||||
{sessionId && onFilterByParentRequestId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code
|
||||
className="text-primary hover:text-primary/80 cursor-pointer text-sm break-all underline-offset-2 hover:underline"
|
||||
onClick={() => onFilterByParentRequestId(sessionId)}
|
||||
>
|
||||
{sessionId}
|
||||
</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Filter this session</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<code className="text-sm break-all">{sessionId}</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="session-details-sort-btn"
|
||||
onClick={() => setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))}
|
||||
>
|
||||
{sortOrder === "asc" ? <ArrowUp className="mr-2 h-4 w-4" /> : <ArrowDown className="mr-2 h-4 w-4" />}
|
||||
{sortOrder === "asc" ? "Earliest first" : "Latest first"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{summaryCards.map((card) => (
|
||||
<Card key={card.label} className="py-4 shadow-none">
|
||||
<CardContent className="px-4">
|
||||
<div className="text-muted-foreground text-xs">{card.label}</div>
|
||||
<div
|
||||
className={
|
||||
card.size === "sm"
|
||||
? "font-mono text-sm leading-5 break-words sm:text-base"
|
||||
: "truncate font-mono text-xl font-medium sm:text-2xl"
|
||||
}
|
||||
>
|
||||
{card.helper ? (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{card.value}</span>
|
||||
<span className="text-muted-foreground text-sm">{card.helper}</span>
|
||||
</div>
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-sm border">
|
||||
<Table containerClassName="h-full overflow-auto">
|
||||
<TableHeader className="sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a]">
|
||||
<TableRow>
|
||||
<TableHead className="w-2"></TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingSession && sessionLogs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading session...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sessionLogs.length ? (
|
||||
sessionLogs.map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn("cursor-pointer transition-colors", log.id === highlightedLogId ? HIGHLIGHTED_ROW : "hover:bg-muted/40")}
|
||||
onClick={() => onLogClick?.(log)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className={`h-6 w-1 rounded-sm ${StatusBarColors[log.status as Status]}`} />
|
||||
</TableCell>
|
||||
<TableCell className="relative text-xs">
|
||||
{log.id === highlightedLogId ? (
|
||||
<div className="bg-background pointer-events-none absolute -top-1.5 left-1 z-10 rounded-full border border-sky-400/45 px-1.5 py-0 text-[9px] leading-tight font-semibold tracking-wide text-sky-600 uppercase dark:text-sky-300">
|
||||
Current
|
||||
</div>
|
||||
) : null}
|
||||
{format(new Date(log.timestamp), "yyyy-MM-dd hh:mm:ss aa (XXX)")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`${RequestTypeColors[log.object as keyof typeof RequestTypeColors]} text-xs`}>
|
||||
{RequestTypeLabels[log.object as keyof typeof RequestTypeLabels]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[360px]">
|
||||
<LogMessageCell log={log} maxWidth="max-w-[360px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="font-mono text-xs uppercase">
|
||||
<RenderProviderIcon provider={log.provider as ProviderIconType} size="sm" />
|
||||
{log.provider as ProviderName}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate font-mono text-xs">{log.model || "N/A"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground h-24 text-center">
|
||||
No logs found for this session.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{hasMore ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="session-details-load-more-btn"
|
||||
onClick={() => loadSessionPage(fetchedCount)}
|
||||
disabled={loadingSession}
|
||||
>
|
||||
{loadingSession ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
167
ui/app/workspace/logs/views/audioPlayer.tsx
Normal file
167
ui/app/workspace/logs/views/audioPlayer.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pause, Play, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
format?: string; // Optional format: "mp3", "wav", "pcm16", etc.
|
||||
}
|
||||
|
||||
const AudioPlayer = ({ src, format }: AudioPlayerProps) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audio] = useState<HTMLAudioElement | null>(typeof window !== "undefined" ? new Audio() : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Convert PCM16 to WAV format
|
||||
const convertPCM16ToWAV = (pcmData: Uint8Array, sampleRate: number = 24000, numChannels: number = 1): Uint8Array => {
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
||||
const blockAlign = (numChannels * bitsPerSample) / 8;
|
||||
const dataSize = pcmData.length;
|
||||
const fileSize = 36 + dataSize;
|
||||
|
||||
const wavBuffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(wavBuffer);
|
||||
|
||||
// RIFF header
|
||||
const writeString = (offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, fileSize, true);
|
||||
writeString(8, "WAVE");
|
||||
|
||||
// fmt subchunk
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // Subchunk1Size
|
||||
view.setUint16(20, 1, true); // AudioFormat (1 = PCM)
|
||||
view.setUint16(22, numChannels, true); // NumChannels
|
||||
view.setUint32(24, sampleRate, true); // SampleRate
|
||||
view.setUint32(28, byteRate, true); // ByteRate
|
||||
view.setUint16(32, blockAlign, true); // BlockAlign
|
||||
view.setUint16(34, bitsPerSample, true); // BitsPerSample
|
||||
|
||||
// data subchunk
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// Copy PCM data
|
||||
const wavArray = new Uint8Array(wavBuffer);
|
||||
wavArray.set(pcmData, 44);
|
||||
|
||||
return wavArray;
|
||||
};
|
||||
|
||||
const createAudioBlob = (base64Data: string, audioFormat?: string): Blob | null => {
|
||||
try {
|
||||
const binaryString = atob(base64Data);
|
||||
const pcmData = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||
|
||||
// Handle PCM16 format - convert to WAV
|
||||
if (audioFormat === "pcm16" || audioFormat === "pcm_s16le_16") {
|
||||
const wavData = convertPCM16ToWAV(pcmData);
|
||||
// Create a new ArrayBuffer to ensure proper type
|
||||
const buffer = new ArrayBuffer(wavData.length);
|
||||
new Uint8Array(buffer).set(wavData);
|
||||
return new Blob([buffer], {
|
||||
type: "audio/wav",
|
||||
});
|
||||
}
|
||||
|
||||
// Handle other formats
|
||||
let mimeType = "audio/mpeg"; // Default to MP3
|
||||
if (audioFormat === "wav") {
|
||||
mimeType = "audio/wav";
|
||||
} else if (audioFormat === "ogg") {
|
||||
mimeType = "audio/ogg";
|
||||
} else if (audioFormat === "webm") {
|
||||
mimeType = "audio/webm";
|
||||
}
|
||||
|
||||
return new Blob([pcmData], {
|
||||
type: mimeType,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to decode audio data:", err);
|
||||
setError("Failed to decode audio data. The audio file may be corrupted.");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audio || !src) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
const audioBlob = createAudioBlob(src, format);
|
||||
if (!audioBlob) return;
|
||||
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
audio.src = audioUrl;
|
||||
audio.play().catch((err) => {
|
||||
console.error("Failed to play audio:", err);
|
||||
setError("Failed to play audio. Please try again.");
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!src) return;
|
||||
|
||||
const audioBlob = createAudioBlob(src, format);
|
||||
if (!audioBlob) return;
|
||||
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Determine file extension based on format
|
||||
let extension = "mp3";
|
||||
if (format === "pcm16" || format === "pcm_s16le_16") {
|
||||
extension = "wav";
|
||||
} else if (format === "wav") {
|
||||
extension = "wav";
|
||||
} else if (format === "ogg") {
|
||||
extension = "ogg";
|
||||
} else if (format === "webm") {
|
||||
extension = "webm";
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = audioUrl;
|
||||
a.download = `speech-output.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handlePlayPause} variant="outline" size="sm" className="flex items-center gap-2" disabled={!!error}>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleDownload} variant="outline" size="sm" className="flex items-center gap-2" disabled={!!error}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
||||
8
ui/app/workspace/logs/views/blockHeader.tsx
Normal file
8
ui/app/workspace/logs/views/blockHeader.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function BlockHeader({ title, icon }: { title: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/workspace/logs/views/codeEditor.css
Normal file
16
ui/app/workspace/logs/views/codeEditor.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.monaco-editor-background {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.overflow-guard {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.monaco-editor {
|
||||
outline-style: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.margin {
|
||||
background: transparent !important;
|
||||
}
|
||||
87
ui/app/workspace/logs/views/collapsibleBox.tsx
Normal file
87
ui/app/workspace/logs/views/collapsibleBox.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface CollapsibleBoxProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsedHeight?: number;
|
||||
expandedMaxHeight?: number;
|
||||
onCopy?: () => string;
|
||||
}
|
||||
|
||||
export default function CollapsibleBox({ title, children, collapsedHeight = 60, expandedMaxHeight = 450, onCopy }: CollapsibleBoxProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [needsExpansion, setNeedsExpansion] = useState(false);
|
||||
const innerContentRef = useRef<HTMLDivElement>(null);
|
||||
const { copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (!innerContentRef.current) return;
|
||||
|
||||
const checkHeight = () => {
|
||||
const scrollHeight = innerContentRef.current?.scrollHeight || 0;
|
||||
setNeedsExpansion(scrollHeight > collapsedHeight);
|
||||
};
|
||||
|
||||
// Initial check after a small delay to allow content to render
|
||||
const timeoutId = setTimeout(checkHeight, 50);
|
||||
|
||||
// Observe for resize changes
|
||||
const observer = new ResizeObserver(checkHeight);
|
||||
observer.observe(innerContentRef.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [children, collapsedHeight]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!onCopy) return;
|
||||
copy(onCopy());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center justify-between border-b py-2 pl-6">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{onCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground mx-2 h-6 py-1 hover:bg-transparent hover:text-black dark:hover:text-white"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200"
|
||||
style={{ maxHeight: isExpanded ? `${expandedMaxHeight}px` : `${collapsedHeight}px` }}
|
||||
>
|
||||
<div ref={innerContentRef}>{children}</div>
|
||||
</div>
|
||||
{needsExpansion && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="bg-muted/50 text-muted-foreground hover:bg-muted flex w-full items-center justify-center gap-1 border-t py-1 text-xs"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
ui/app/workspace/logs/views/columns.test.ts
Normal file
100
ui/app/workspace/logs/views/columns.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { LogEntry } from "@/lib/types/logs";
|
||||
|
||||
import { getMessage } from "./columns";
|
||||
|
||||
describe("getMessage", () => {
|
||||
it("returns EI realtime text from input history", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello from the browser" }],
|
||||
},
|
||||
],
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("User: hello from the browser");
|
||||
});
|
||||
|
||||
it("returns LM realtime text from output message", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [],
|
||||
responses_input_history: [],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello from the model" }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("Assistant: hello from the model");
|
||||
});
|
||||
|
||||
it("returns split realtime text when both user and assistant are present", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "who are you?" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I am the assistant." }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe("User: who are you?\nAssistant: I am the assistant.");
|
||||
});
|
||||
|
||||
it("returns split realtime text including tool output", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: '{"nextResponse":"tool result"}' }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "who are you?" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I am the assistant." }],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe('Tool Result: {"nextResponse":"tool result"}\nUser: who are you?\nAssistant: I am the assistant.');
|
||||
});
|
||||
|
||||
it("returns realtime assistant tool calls from output message", () => {
|
||||
const log = {
|
||||
object: "realtime.turn",
|
||||
input_history: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me a pastel palette" }],
|
||||
},
|
||||
],
|
||||
output_message: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "display_color_palette",
|
||||
arguments: '{"theme":"pastel"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as LogEntry;
|
||||
|
||||
expect(getMessage(log)).toBe('User: show me a pastel palette\nAssistant Tool Call: display_color_palette({"theme":"pastel"})');
|
||||
});
|
||||
});
|
||||
499
ui/app/workspace/logs/views/columns.tsx
Normal file
499
ui/app/workspace/logs/views/columns.tsx
Normal file
@@ -0,0 +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 {
|
||||
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, 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");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isLargePayload && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
title="Large payload - streamed directly to provider"
|
||||
>
|
||||
LP
|
||||
</span>
|
||||
)}
|
||||
{realtimeMessages &&
|
||||
(realtimeMessages.tool ||
|
||||
realtimeMessages.user ||
|
||||
realtimeMessages.assistantToolCall ||
|
||||
realtimeMessages.assistant) ? (
|
||||
<div
|
||||
className={cn(maxWidth, "font-mono text-sm font-normal leading-5")}
|
||||
title={input || "-"}
|
||||
>
|
||||
{realtimeMessages.tool ? (
|
||||
<div className="truncate">Tool Result: {realtimeMessages.tool}</div>
|
||||
) : null}
|
||||
{realtimeMessages.user ? (
|
||||
<div className="truncate">User: {realtimeMessages.user}</div>
|
||||
) : null}
|
||||
{realtimeMessages.assistantToolCall ? (
|
||||
<div className="truncate">
|
||||
Assistant Tool Call: {realtimeMessages.assistantToolCall}
|
||||
</div>
|
||||
) : null}
|
||||
{realtimeMessages.assistant ? (
|
||||
<div className="truncate">
|
||||
Assistant: {realtimeMessages.assistant}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(maxWidth, "truncate font-mono text-[12px] font-normal")}
|
||||
title={input || "-"}
|
||||
>
|
||||
{input ||
|
||||
(isLargePayload
|
||||
? `Large payload ${log.is_large_payload_request && log.is_large_payload_response ? "request & response" : log.is_large_payload_request ? "request" : "response"}`
|
||||
: "-")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const createColumns = (
|
||||
onDelete: (log: LogEntry) => void,
|
||||
hasDeleteAccess = true,
|
||||
metadataKeys: string[] = [],
|
||||
): ColumnDef<LogEntry>[] => {
|
||||
const baseColumns: ColumnDef<LogEntry>[] = [
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "",
|
||||
size: 8,
|
||||
maxSize: 8,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status as Status;
|
||||
return (
|
||||
<div
|
||||
className={`h-full min-h-[24px] w-1 rounded-sm ${StatusBarColors[status]}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-time-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Time
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 130,
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.original.timestamp;
|
||||
const date = timestamp ? new Date(timestamp) : null;
|
||||
const isValid = date && date.toString() !== "Invalid Date";
|
||||
if (!isValid) {
|
||||
return <div className="truncate text-xs">N/A</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{format(date, "MMM dd HH:mm:ss")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10.5px] tabular-nums">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "request_type",
|
||||
header: "Type",
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"font-mono text-[11px] py-0.5 px-1.5 uppercase",
|
||||
RequestTypeColors[
|
||||
row.original.object as keyof typeof RequestTypeColors
|
||||
],
|
||||
)}
|
||||
>
|
||||
{
|
||||
RequestTypeLabels[
|
||||
row.original.object as keyof typeof RequestTypeLabels
|
||||
]
|
||||
}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "input",
|
||||
header: "Message",
|
||||
size: 350,
|
||||
cell: ({ row }) => <LogMessageCell log={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
header: "Model",
|
||||
size: 190,
|
||||
cell: ({ row }) => {
|
||||
const provider = row.original.provider as ProviderName | undefined;
|
||||
const model = row.original.model;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{provider ? (
|
||||
<RenderProviderIcon
|
||||
provider={provider as ProviderIconType}
|
||||
size="xs"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<span className="truncate font-mono text-[12px]">
|
||||
{model || "N/A"}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-[10.5px]">
|
||||
{provider ? getProviderLabel(provider) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-latency-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Latency
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
const latency = row.original.latency;
|
||||
if (latency === undefined || latency === null) {
|
||||
return <div className="pl-4 font-mono text-xs">N/A</div>;
|
||||
}
|
||||
const tone =
|
||||
latency >= 5000
|
||||
? "bg-red-500"
|
||||
: latency >= 2000
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
const pct = Math.min(100, (latency / 5000) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="font-mono text-[12px] tabular-nums">
|
||||
{formatLatency(latency)}
|
||||
</span>
|
||||
<div className="relative h-1.5 w-[56px] overflow-hidden rounded-sm bg-zinc-200 dark:bg-zinc-700">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-sm opacity-85",
|
||||
tone,
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "tokens",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-tokens-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Tokens
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 190,
|
||||
cell: ({ row }) => {
|
||||
const tokenUsage = row.original.token_usage;
|
||||
if (!tokenUsage) {
|
||||
return <div className="pl-4 font-mono text-xs">N/A</div>;
|
||||
}
|
||||
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 (
|
||||
<div className="flex flex-col items-start gap-0.5 pl-4 leading-tight">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] tabular-nums">
|
||||
{formatTokens(total)}
|
||||
</span>
|
||||
{hasSplit && (
|
||||
<div className="flex h-1.5 w-[64px] overflow-hidden rounded-sm">
|
||||
<div className="bg-blue-400" style={{ width: `${inPct}%` }} />
|
||||
<div className="flex-1 bg-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasSplit && (
|
||||
<div className="text-muted-foreground font-mono text-[10.5px] tabular-nums">
|
||||
<span className="text-blue-500">{formatTokens(prompt)}</span>
|
||||
<span> / </span>
|
||||
<span className="text-violet-500">
|
||||
{formatTokens(completion)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="logs-cost-sort-btn"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Cost
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.cost == null) {
|
||||
return <div className="pl-4 font-mono text-[12px]">N/A</div>;
|
||||
}
|
||||
return (
|
||||
<div className="pl-4 font-mono text-sm tabular-nums">
|
||||
{formatCost(row.original.cost)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const metadataColumns: ColumnDef<LogEntry>[] = 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 (
|
||||
<div className="max-w-[150px] truncate font-mono text-xs">
|
||||
{value ?? "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const actionsColumn: ColumnDef<LogEntry> = {
|
||||
id: "actions",
|
||||
size: 72,
|
||||
cell: ({ row }) => {
|
||||
const log = row.original;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
data-testid="log-delete-btn"
|
||||
aria-label="Delete log"
|
||||
className="text-secondary-foreground/30 hover:bg-destructive/10 hover:text-destructive border-destructive/10"
|
||||
onClick={() => onDelete(log)}
|
||||
disabled={!hasDeleteAccess}
|
||||
>
|
||||
<Trash2 strokeWidth={1.5} />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [...baseColumns, ...metadataColumns, actionsColumn];
|
||||
};
|
||||
323
ui/app/workspace/logs/views/emptyState.tsx
Normal file
323
ui/app/workspace/logs/views/emptyState.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
||||
import { getExampleBaseUrl } from "@/lib/utils/port";
|
||||
import { AlertTriangle, Copy } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "genai" | "litellm" | "langchain";
|
||||
type Language = "python" | "typescript";
|
||||
|
||||
type Examples = {
|
||||
curl: string;
|
||||
sdk: {
|
||||
[P in Provider]: {
|
||||
[L in Language]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Common editor options to reduce duplication
|
||||
const EditorOptions = {
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: "off",
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
glyphMargin: false,
|
||||
} as const;
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language: string;
|
||||
onLanguageChange?: (language: string) => void;
|
||||
showLanguageSelect?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = false, readonly = true }: CodeBlockProps) {
|
||||
const { copy: copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
{showLanguageSelect && onLanguageChange && (
|
||||
<Select value={language} onValueChange={onLanguageChange}>
|
||||
<SelectTrigger className="h-8 w-fit text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem className="text-xs" value="python">
|
||||
Python
|
||||
</SelectItem>
|
||||
<SelectItem className="text-xs" value="typescript">
|
||||
TypeScript
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(code)}>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CodeEditor className="w-full" code={code} lang={language} readonly={readonly} height={300} fontSize={14} options={EditorOptions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function EmptyState({ error }: EmptyStateProps) {
|
||||
const [language, setLanguage] = useState<Language>("python");
|
||||
|
||||
// Generate examples dynamically using the port utility
|
||||
const examples: Examples = useMemo(() => {
|
||||
const baseUrl = getExampleBaseUrl();
|
||||
|
||||
return {
|
||||
curl: `curl -X POST ${baseUrl}/v1/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
}'`,
|
||||
sdk: {
|
||||
openai: {
|
||||
python: `import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
base_url="${baseUrl}/openai",
|
||||
api_key="dummy-api-key" # Handled by Bifrost
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini", # or "provider/model" for other providers (anthropic/claude-3-sonnet)
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: "${baseUrl}/openai",
|
||||
apiKey: "dummy-api-key", // Handled by Bifrost
|
||||
});
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini", // or "provider/model" for other providers (anthropic/claude-3-sonnet)
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});`,
|
||||
},
|
||||
anthropic: {
|
||||
python: `import anthropic
|
||||
|
||||
client = anthropic.Anthropic(
|
||||
base_url="${baseUrl}/anthropic",
|
||||
api_key="dummy-api-key" # Handled by Bifrost
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-3-sonnet-20240229", # or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
max_tokens=1000,
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
const anthropic = new Anthropic({
|
||||
baseURL: "${baseUrl}/anthropic",
|
||||
apiKey: "dummy-api-key", // Handled by Bifrost
|
||||
});
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-3-sonnet-20240229", // or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
max_tokens: 1000,
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
});`,
|
||||
},
|
||||
genai: {
|
||||
python: `from google import genai
|
||||
from google.genai.types import HttpOptions
|
||||
|
||||
client = genai.Client(
|
||||
api_key="dummy-api-key", # Handled by Bifrost
|
||||
http_options=HttpOptions(base_url="${baseUrl}/genai")
|
||||
)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-pro", # or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
contents="Hello!"
|
||||
)`,
|
||||
typescript: `import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
const genAI = new GoogleGenerativeAI("dummy-api-key", { // Handled by Bifrost
|
||||
baseUrl: "${baseUrl}/genai",
|
||||
});
|
||||
|
||||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); // or "provider/model" for other providers (openai/gpt-4o-mini)
|
||||
const response = await model.generateContent("Hello!");`,
|
||||
},
|
||||
litellm: {
|
||||
python: `import litellm
|
||||
|
||||
litellm.api_base = "${baseUrl}/litellm"
|
||||
|
||||
response = litellm.completion(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)`,
|
||||
typescript: `import { completion } from "litellm";
|
||||
|
||||
const response = await completion({
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
api_base: "${baseUrl}/litellm",
|
||||
});`,
|
||||
},
|
||||
langchain: {
|
||||
python: `from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
# Initialize ChatOpenAI with Bifrost
|
||||
llm = ChatOpenAI(
|
||||
model="gpt-4o-mini",
|
||||
api_key="dummy-api-key", # Handled by Bifrost
|
||||
base_url="${baseUrl}/langchain",
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
# Simple message
|
||||
messages = [HumanMessage(content="Hello from LangChain!")]
|
||||
response = llm.invoke(messages)
|
||||
|
||||
# Chain with prompt template
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", "You are a helpful assistant."),
|
||||
("human", "{input}")
|
||||
])
|
||||
|
||||
chain = prompt | llm | StrOutputParser()
|
||||
result = chain.invoke({"input": "What is LangChain?"})`,
|
||||
typescript: `import { ChatOpenAI } from "@langchain/openai";
|
||||
import { HumanMessage } from "@langchain/core/messages";
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
import { StringOutputParser } from "@langchain/core/output_parsers";
|
||||
|
||||
// Initialize ChatOpenAI with Bifrost
|
||||
const llm = new ChatOpenAI({
|
||||
model: "gpt-4o-mini",
|
||||
openAIApiKey: "dummy-api-key", // Handled by Bifrost
|
||||
clientOptions: {
|
||||
baseURL: "${baseUrl}/langchain",
|
||||
},
|
||||
maxTokens: 100,
|
||||
});
|
||||
|
||||
// Simple message
|
||||
const messages = [new HumanMessage("Hello from LangChain!")];
|
||||
const response = await llm.invoke(messages);
|
||||
|
||||
// Chain with prompt template
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system", "You are a helpful assistant."],
|
||||
["human", "{input}"],
|
||||
]);
|
||||
|
||||
const chain = prompt.pipe(llm).pipe(new StringOutputParser());
|
||||
const result = await chain.invoke({ input: "What is LangChain?" });`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isUnexpectedError = error && error.includes("An unexpected error occurred");
|
||||
|
||||
return (
|
||||
<div className="dark:bg-card flex w-full flex-col items-center justify-center space-y-8 bg-white">
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{isUnexpectedError ? "Looks like you haven't configured the log store in your config file." : error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="w-full space-y-6 p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Integrate under 60 seconds</h3>
|
||||
<p className="text-muted-foreground text-sm">Send your first request to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="curl" className="w-full rounded-lg border">
|
||||
<TabsList className="grid h-10 w-full grid-cols-6 rounded-t-lg rounded-b-none">
|
||||
<TabsTrigger value="curl">cURL</TabsTrigger>
|
||||
<TabsTrigger value="openai">OpenAI SDK</TabsTrigger>
|
||||
<TabsTrigger value="anthropic">Anthropic SDK</TabsTrigger>
|
||||
<TabsTrigger value="genai">Google GenAI SDK</TabsTrigger>
|
||||
<TabsTrigger value="litellm">LiteLLM SDK</TabsTrigger>
|
||||
<TabsTrigger value="langchain">LangChain SDK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="curl" className="px-4">
|
||||
<CodeBlock code={examples.curl} language="bash" readonly={false} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="openai" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.openai[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anthropic" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.anthropic[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="genai" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.genai[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="litellm" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.litellm[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="langchain" className="px-4">
|
||||
<CodeBlock
|
||||
code={examples.sdk.langchain[language]}
|
||||
language={language}
|
||||
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
|
||||
showLanguageSelect
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
ui/app/workspace/logs/views/imageView.tsx
Normal file
177
ui/app/workspace/logs/views/imageView.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { BifrostImageGenerationOutput, ImageEditInput, ImageVariationInput } from "@/lib/types/logs";
|
||||
import { Image, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ImageMessage } from "@/components/chat/ImageMessage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RequestTypeLabels } from "@/lib/constants/logs";
|
||||
|
||||
interface ImageGenerationInput {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface ImageViewProps {
|
||||
imageInput?: ImageGenerationInput;
|
||||
imageEditInput?: ImageEditInput;
|
||||
imageVariationInput?: ImageVariationInput;
|
||||
imageOutput?: BifrostImageGenerationOutput;
|
||||
requestType?: string;
|
||||
}
|
||||
|
||||
// Detect MIME type from base64 magic bytes and return a data URL
|
||||
function getImageSrc(b64: string): string {
|
||||
if (b64.startsWith("/9j/")) return `data:image/jpeg;base64,${b64}`;
|
||||
if (b64.startsWith("iVBOR")) return `data:image/png;base64,${b64}`;
|
||||
if (b64.startsWith("UklGR")) return `data:image/webp;base64,${b64}`;
|
||||
if (b64.startsWith("R0lGO")) return `data:image/gif;base64,${b64}`;
|
||||
return `data:image/png;base64,${b64}`;
|
||||
}
|
||||
|
||||
// Helper function to get method type label from request type
|
||||
function getMethodTypeLabel(requestType?: string): string {
|
||||
if (!requestType) return "Image Generation";
|
||||
|
||||
const normalizedType = requestType.toLowerCase();
|
||||
if (normalizedType.includes("image_edit")) {
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Edit";
|
||||
}
|
||||
if (normalizedType.includes("image_variation")) {
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Variation";
|
||||
}
|
||||
return RequestTypeLabels[normalizedType as keyof typeof RequestTypeLabels] || "Image Generation";
|
||||
}
|
||||
|
||||
export default function ImageView({ imageInput, imageEditInput, imageVariationInput, imageOutput, requestType }: ImageViewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// Get all valid images
|
||||
const images = imageOutput?.data?.filter((img) => img.url || img.b64_json) ?? [];
|
||||
const totalImages = images.length;
|
||||
const currentImage = images[currentIndex] ?? null;
|
||||
|
||||
// Get method type label
|
||||
const methodTypeLabel = getMethodTypeLabel(requestType);
|
||||
|
||||
// Clamp currentIndex when images array changes to ensure it's always valid
|
||||
useEffect(() => {
|
||||
if (totalImages === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else {
|
||||
setCurrentIndex((prev) => Math.min(prev, totalImages - 1));
|
||||
}
|
||||
}, [totalImages]);
|
||||
|
||||
// Looping navigation
|
||||
const goToPrevious = () => setCurrentIndex((prev) => (prev === 0 ? totalImages - 1 : prev - 1));
|
||||
const goToNext = () => setCurrentIndex((prev) => (prev === totalImages - 1 ? 0 : prev + 1));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Image Input */}
|
||||
{imageInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{imageInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Edit Input */}
|
||||
{imageEditInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
{imageEditInput.images && imageEditInput.images.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">INPUT IMAGES</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{imageEditInput.images.map((img, i) =>
|
||||
img.image ? (
|
||||
<img
|
||||
key={i}
|
||||
src={getImageSrc(img.image)}
|
||||
alt={`Input image ${i + 1}`}
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{imageEditInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Variation Input */}
|
||||
{imageVariationInput && imageVariationInput.image?.image && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">INPUT IMAGE</div>
|
||||
<img
|
||||
src={getImageSrc(imageVariationInput.image.image)}
|
||||
alt="Input image"
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Output */}
|
||||
{currentImage && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Image className="h-4 w-4" />
|
||||
{methodTypeLabel} Output
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
{currentImage && (
|
||||
<>
|
||||
{currentImage.revised_prompt && (
|
||||
<div className="mb-4">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">REVISED PROMPT</div>
|
||||
<div className="font-mono text-xs">{currentImage.revised_prompt}</div>
|
||||
</div>
|
||||
)}
|
||||
<ImageMessage
|
||||
image={{
|
||||
...currentImage,
|
||||
output_format: imageOutput?.output_format,
|
||||
}}
|
||||
/>
|
||||
|
||||
{totalImages > 1 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevious} aria-label="Previous image" title="Previous image">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentIndex + 1} / {totalImages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={goToNext} aria-label="Next image" title="Next image">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
ui/app/workspace/logs/views/logChatMessageView.tsx
Normal file
250
ui/app/workspace/logs/views/logChatMessageView.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ChatMessage, ContentBlock } from "@/lib/types/logs";
|
||||
import { cleanJson, isJson } from "@/lib/utils/validation";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
|
||||
interface LogChatMessageViewProps {
|
||||
message: ChatMessage;
|
||||
audioFormat?: string; // Optional audio format from request params
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: ContentBlock; index: number }) {
|
||||
const blockType = block.type.replaceAll("_", " ");
|
||||
|
||||
// Handle text content
|
||||
if (block.text) {
|
||||
if (isJson(block.text)) {
|
||||
const jsonContent = JSON.stringify(cleanJson(block.text), null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => block.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{block.text}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle image content
|
||||
if (block.image_url) {
|
||||
const jsonContent = JSON.stringify(block.image_url, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle audio content
|
||||
if (block.input_audio) {
|
||||
const jsonContent = JSON.stringify(block.input_audio, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockType} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LogChatMessageView({ message, audioFormat }: LogChatMessageViewProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{/* Role header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium capitalize">{message.role}</span>
|
||||
{message.tool_call_id && <span className="text-muted-foreground text-xs">Tool Call ID: {message.tool_call_id}</span>}
|
||||
</div>
|
||||
|
||||
{/* Handle reasoning content */}
|
||||
{message.reasoning && (
|
||||
<>
|
||||
{isJson(message.reasoning) ? (
|
||||
<CollapsibleBox title="Reasoning" onCopy={() => JSON.stringify(cleanJson(message.reasoning), null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.reasoning), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Reasoning" onCopy={() => message.reasoning || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar text-muted-foreground max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap italic">
|
||||
{message.reasoning}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle refusal content */}
|
||||
{message.refusal && (
|
||||
<>
|
||||
{isJson(message.refusal) ? (
|
||||
<CollapsibleBox title="Refusal" onCopy={() => JSON.stringify(cleanJson(message.refusal), null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.refusal), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Refusal" onCopy={() => message.refusal || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap text-red-800">
|
||||
{message.refusal}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle content */}
|
||||
{message.content && (
|
||||
<>
|
||||
{typeof message.content === "string" ? (
|
||||
<>
|
||||
{isJson(message.content) ? (
|
||||
<CollapsibleBox
|
||||
title="Content"
|
||||
onCopy={() => JSON.stringify(cleanJson(message.content as string), null, 2)}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={250}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.content), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Content" onCopy={() => (message.content as string) || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
Array.isArray(message.content) &&
|
||||
message.content.map((block, blockIndex) => <ContentBlockView key={blockIndex} block={block} index={blockIndex} />)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle tool calls */}
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<>
|
||||
{message.tool_calls.map((toolCall, index) => {
|
||||
const jsonContent = JSON.stringify(toolCall, null, 2);
|
||||
return (
|
||||
<CollapsibleBox key={index} title={`Tool Call: ${toolCall.function?.name || `#${index + 1}`}`} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle annotations */}
|
||||
{message.annotations && message.annotations.length > 0 && (
|
||||
<CollapsibleBox title="Annotations" onCopy={() => JSON.stringify(message.annotations, null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.annotations, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle audio output */}
|
||||
{message.audio && (
|
||||
<CollapsibleBox title="Audio Output" collapsedHeight={150}>
|
||||
<div className="space-y-4 px-6 py-4">
|
||||
{message.audio.transcript && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">Transcript:</div>
|
||||
<div className="font-mono text-xs break-words whitespace-pre-wrap">{message.audio.transcript}</div>
|
||||
</div>
|
||||
)}
|
||||
{message.audio.data && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">Audio:</div>
|
||||
<AudioPlayer src={message.audio.data} format={audioFormat} />
|
||||
</div>
|
||||
)}
|
||||
{message.audio.id && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
ID: {message.audio.id} | Expires:{" "}
|
||||
{message.audio.expires_at && Number.isFinite(message.audio.expires_at)
|
||||
? new Date(message.audio.expires_at * 1000).toLocaleString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
ui/app/workspace/logs/views/logEntryDetailsView.tsx
Normal file
49
ui/app/workspace/logs/views/logEntryDetailsView.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
isBeta?: boolean;
|
||||
valueClassName?: string;
|
||||
label: string;
|
||||
value: React.ReactNode | null;
|
||||
hideExpandable?: boolean;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
export default function LogEntryDetailsView(props: Props) {
|
||||
if (props.value === null) {
|
||||
return null;
|
||||
}
|
||||
const orientation = props.orientation || "vertical";
|
||||
return (
|
||||
<div
|
||||
className={cn("items-top flex flex-col gap-2", {
|
||||
[`${props.className}`]: props.className !== undefined,
|
||||
"items-start": props.align === "left" || props.align === undefined,
|
||||
"items-end": props.align === "right",
|
||||
})}
|
||||
>
|
||||
<div className={props.containerClassName}>
|
||||
{props.label !== "" && (
|
||||
<div className="text-muted-foreground flex shrink-0 flex-row items-center gap-2 pb-2 text-xs font-medium">
|
||||
{props.label.toUpperCase().replace(/_/g, " ")}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("text-md flex text-xs font-medium overflow-ellipsis transition-transform delay-75", {
|
||||
"w-full flex-col items-center gap-2": orientation === "horizontal",
|
||||
"flex-row items-start gap-2": orientation === "vertical",
|
||||
[`${props.valueClassName}`]: props.valueClassName !== undefined,
|
||||
"text-end": props.align === "right",
|
||||
})}
|
||||
>
|
||||
<div className="text-bifrost-gray-300 flex-1 text-sm break-all">
|
||||
{typeof props.value === "boolean" ? String(props.value) : props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
ui/app/workspace/logs/views/logResponsesMessageView.tsx
Normal file
451
ui/app/workspace/logs/views/logResponsesMessageView.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ResponsesMessage, ResponsesMessageContentBlock } from "@/lib/types/logs";
|
||||
import { cleanJson, isJson } from "@/lib/utils/validation";
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
|
||||
interface LogResponsesMessageViewProps {
|
||||
messages: ResponsesMessage[];
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: ResponsesMessageContentBlock; index: number }) {
|
||||
const getBlockTitle = (type: string) => {
|
||||
switch (type) {
|
||||
case "input_text":
|
||||
return "Input Text";
|
||||
case "input_image":
|
||||
return "Input Image";
|
||||
case "input_file":
|
||||
return "Input File";
|
||||
case "input_audio":
|
||||
return "Input Audio";
|
||||
case "output_text":
|
||||
return "Output Text";
|
||||
case "reasoning_text":
|
||||
return "Reasoning Text";
|
||||
case "refusal":
|
||||
return "Refusal";
|
||||
default:
|
||||
return type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
};
|
||||
|
||||
const blockTitle = getBlockTitle(block.type);
|
||||
|
||||
// Handle text content
|
||||
if (block.text) {
|
||||
if (isJson(block.text)) {
|
||||
const jsonContent = JSON.stringify(cleanJson(block.text), null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => block.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs whitespace-pre-wrap">{block.text}</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle image content
|
||||
if (block.image_url) {
|
||||
const jsonContent = JSON.stringify(
|
||||
{
|
||||
image_url: block.image_url,
|
||||
...(block.detail && { detail: block.detail }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle file content
|
||||
if (block.file_id || block.file_data || block.file_url) {
|
||||
const jsonContent = JSON.stringify(
|
||||
{
|
||||
...(block.filename && { filename: block.filename }),
|
||||
...(block.file_id && { file_id: block.file_id }),
|
||||
...(block.file_url && { file_url: block.file_url }),
|
||||
...(block.file_data && { file_data: "[Base64 encoded data]" }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle audio content
|
||||
if (block.input_audio) {
|
||||
const jsonContent = JSON.stringify(block.input_audio, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle refusal content
|
||||
if (block.refusal) {
|
||||
return (
|
||||
<CollapsibleBox title={blockTitle} onCopy={() => block.refusal || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs text-red-800">{block.refusal}</div>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle annotations
|
||||
if (block.annotations && block.annotations.length > 0) {
|
||||
const jsonContent = JSON.stringify(block.annotations, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title="Annotations" onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle log probabilities
|
||||
if (block.logprobs && block.logprobs.length > 0) {
|
||||
const jsonContent = JSON.stringify(block.logprobs, null, 2);
|
||||
return (
|
||||
<CollapsibleBox title="Log Probabilities" onCopy={() => jsonContent} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={150}
|
||||
wrap={true}
|
||||
code={jsonContent}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MessageView({ message, index }: { message: ResponsesMessage; index: number }) {
|
||||
const getMessageTitle = () => {
|
||||
if (message.type) {
|
||||
switch (message.type) {
|
||||
case "reasoning":
|
||||
return "Reasoning";
|
||||
case "message":
|
||||
return message.role ? `${message.role.charAt(0).toUpperCase() + message.role.slice(1)} Message` : "Message";
|
||||
case "function_call":
|
||||
return `Function Call: ${message.name || "Unknown"}`;
|
||||
case "function_call_output":
|
||||
return `Function Call Output${message.call_id ? `: ${message.call_id}` : ""}`;
|
||||
case "file_search_call":
|
||||
return "File Search";
|
||||
case "web_search_call":
|
||||
return "Web Search";
|
||||
case "computer_call":
|
||||
return "Computer Action";
|
||||
case "computer_call_output":
|
||||
return "Computer Action Output";
|
||||
case "code_interpreter_call":
|
||||
return "Code Interpreter";
|
||||
case "mcp_call":
|
||||
return "MCP Tool Call";
|
||||
case "custom_tool_call":
|
||||
return "Custom Tool Call";
|
||||
case "custom_tool_call_output":
|
||||
return "Custom Tool Output";
|
||||
case "image_generation_call":
|
||||
return "Image Generation";
|
||||
case "refusal":
|
||||
return "Refusal";
|
||||
default:
|
||||
return message.type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
return message.role ? `${message.role.charAt(0).toUpperCase() + message.role.slice(1)}` : "Message";
|
||||
};
|
||||
|
||||
if (message.type == "reasoning" && (!message.summary || message.summary.length === 0) && !message.encrypted_content && !message.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageTitle = getMessageTitle();
|
||||
|
||||
return (
|
||||
<div key={`message-${index}`} className="flex w-full flex-col gap-2">
|
||||
{/* Message title header */}
|
||||
<div className="text-sm font-medium">{messageTitle}</div>
|
||||
|
||||
{/* Handle reasoning content */}
|
||||
{message.type === "reasoning" && message.summary && message.summary.length > 0 && (
|
||||
<>
|
||||
{message.summary.every((item) => item.type === "summary_text") ? (
|
||||
// Display as readable text when all items are summary_text
|
||||
message.summary.map((reasoningContent, idx) => (
|
||||
<CollapsibleBox key={idx} title={`Summary #${idx + 1}`} onCopy={() => reasoningContent.text || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{reasoningContent.text}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
))
|
||||
) : (
|
||||
// Fallback to JSON display for mixed or non-text types
|
||||
<CollapsibleBox title="Summary" onCopy={() => JSON.stringify(message.summary, null, 2)} collapsedHeight={100}>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={300}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.summary, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle encrypted reasoning content */}
|
||||
{message.type === "reasoning" && message.encrypted_content && (
|
||||
<CollapsibleBox title="Encrypted Reasoning Content" onCopy={() => message.encrypted_content || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.encrypted_content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle regular content */}
|
||||
{message.content && (
|
||||
<>
|
||||
{typeof message.content === "string" ? (
|
||||
<>
|
||||
{isJson(message.content) ? (
|
||||
<CollapsibleBox
|
||||
title="Content"
|
||||
onCopy={() => JSON.stringify(cleanJson(message.content as string), null, 2)}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={250}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.content), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
) : (
|
||||
<CollapsibleBox title="Content" onCopy={() => (message.content as string) || ""} collapsedHeight={100}>
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
Array.isArray(message.content) &&
|
||||
message.content.map((block, blockIndex) => <ContentBlockView key={blockIndex} block={block} index={blockIndex} />)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Handle tool call specific fields */}
|
||||
{(message.call_id || message.name || message.arguments) && (
|
||||
<CollapsibleBox
|
||||
title="Tool Details"
|
||||
onCopy={() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
...(message.call_id && { call_id: message.call_id }),
|
||||
...(message.name && { name: message.name }),
|
||||
...(message.arguments && { arguments: isJson(message.arguments) ? cleanJson(message.arguments) : message.arguments }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(
|
||||
{
|
||||
...(message.call_id && { call_id: message.call_id }),
|
||||
...(message.name && { name: message.name }),
|
||||
...(message.arguments && { arguments: isJson(message.arguments) ? cleanJson(message.arguments) : message.arguments }),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle function call output */}
|
||||
{message.output !== undefined && (
|
||||
<CollapsibleBox
|
||||
title="Output"
|
||||
onCopy={() => (typeof message.output === "string" ? message.output : JSON.stringify(message.output, null, 2))}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
{typeof message.output === "string" ? (
|
||||
isJson(message.output) ? (
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(cleanJson(message.output), null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
) : (
|
||||
<div className="custom-scrollbar max-h-[400px] overflow-y-auto px-6 py-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{message.output}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(message.output, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
|
||||
{/* Handle additional tool-specific fields */}
|
||||
{Object.keys(message).some(
|
||||
(key) => !["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(key),
|
||||
) && (
|
||||
<CollapsibleBox
|
||||
title="Additional Fields"
|
||||
onCopy={() =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(message).filter(
|
||||
([key]) =>
|
||||
!["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
collapsedHeight={100}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(message).filter(
|
||||
([key]) =>
|
||||
!["id", "type", "status", "role", "content", "call_id", "name", "arguments", "summary", "encrypted_content", "output"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogResponsesMessageView({ messages }: LogResponsesMessageViewProps) {
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="text-muted-foreground px-6 py-4 text-center text-sm">No responses messages available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<MessageView key={index} message={message} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
ui/app/workspace/logs/views/logsHeaderView.tsx
Normal file
191
ui/app/workspace/logs/views/logsHeaderView.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ColumnConfigDropdown, type ColumnConfigEntry } from "@/components/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getErrorMessage, useRecalculateLogCostsMutation } from "@/lib/store";
|
||||
import type { LogFilters as LogFiltersType } from "@/lib/types/logs";
|
||||
import { getRangeForPeriod, TIME_PERIODS } from "@/lib/utils/timeRange";
|
||||
import { Calculator, MoreVertical, Radio, RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LogsHeaderViewProps {
|
||||
filters: LogFiltersType;
|
||||
onFiltersChange: (filters: LogFiltersType) => void;
|
||||
fetchLogs: () => Promise<void>;
|
||||
fetchStats: () => Promise<void>;
|
||||
fetchHistogram: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
polling: boolean;
|
||||
onPollToggle: (enabled: boolean) => void;
|
||||
period: string;
|
||||
onPeriodChange: (period: string, from: Date, to: Date) => void;
|
||||
/** Column config for the ColumnConfigDropdown */
|
||||
columnEntries: ColumnConfigEntry[];
|
||||
columnLabels: Record<string, string>;
|
||||
onToggleColumnVisibility: (id: string) => void;
|
||||
onResetColumns: () => void;
|
||||
}
|
||||
|
||||
export function LogsHeaderView({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
fetchLogs,
|
||||
fetchStats,
|
||||
fetchHistogram,
|
||||
loading = false,
|
||||
polling,
|
||||
onPollToggle,
|
||||
period,
|
||||
onPeriodChange,
|
||||
columnEntries,
|
||||
columnLabels,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumns,
|
||||
}: LogsHeaderViewProps) {
|
||||
const [openMoreActionsPopover, setOpenMoreActionsPopover] = useState(false);
|
||||
const [localSearch, setLocalSearch] = useState(filters.content_search || "");
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const filtersRef = useRef<LogFiltersType>(filters);
|
||||
const [recalculateCosts] = useRecalculateLogCostsMutation();
|
||||
|
||||
const [startTime, setStartTime] = useState<Date | undefined>(filters.start_time ? new Date(filters.start_time) : undefined);
|
||||
const [endTime, setEndTime] = useState<Date | undefined>(filters.end_time ? new Date(filters.end_time) : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setStartTime(filters.start_time ? new Date(filters.start_time) : undefined);
|
||||
setEndTime(filters.end_time ? new Date(filters.end_time) : undefined);
|
||||
}, [filters.start_time, filters.end_time]);
|
||||
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearch(filters.content_search || "");
|
||||
}, [filters.content_search]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRecalculateCosts = useCallback(async () => {
|
||||
try {
|
||||
const response = await recalculateCosts({ filters }).unwrap();
|
||||
await fetchLogs();
|
||||
await fetchStats();
|
||||
setOpenMoreActionsPopover(false);
|
||||
toast.success(`Recalculated costs for ${response.updated} logs`, {
|
||||
description: `${response.updated} logs updated, ${response.skipped} logs skipped, ${response.remaining} logs remaining`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err));
|
||||
}
|
||||
}, [filters, recalculateCosts, fetchLogs, fetchStats]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalSearch(value);
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
onFiltersChange({ ...filtersRef.current, content_search: value });
|
||||
}, 500);
|
||||
},
|
||||
[onFiltersChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex grow items-center justify-between space-x-2">
|
||||
<Button
|
||||
data-testid="logs-refresh-btn"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7.5 disabled:opacity-100"
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
fetchStats();
|
||||
fetchHistogram();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="logs-live-btn"
|
||||
variant={polling ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7.5"
|
||||
onClick={() => onPollToggle(!polling)}
|
||||
>
|
||||
{polling ? <Radio className="h-4 w-4 animate-pulse" /> : <Radio className="h-4 w-4" />}
|
||||
Live
|
||||
</Button>
|
||||
<div className="border-input flex h-7.5 flex-1 items-center gap-2 rounded-sm border">
|
||||
<Search className="mr-0.5 ml-2 size-4" />
|
||||
<Input
|
||||
type="text"
|
||||
className="!h-7 rounded-tl-none rounded-tr-sm rounded-br-sm rounded-bl-none border-none bg-slate-50 shadow-none outline-none focus-visible:ring-0"
|
||||
placeholder="Search logs"
|
||||
value={localSearch}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateTimePickerWithRange
|
||||
triggerTestId="filter-date-range"
|
||||
dateTime={{ from: startTime, to: endTime }}
|
||||
predefinedPeriod={period || undefined}
|
||||
onDateTimeUpdate={(p) => {
|
||||
setStartTime(p.from);
|
||||
setEndTime(p.to);
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
start_time: p.from?.toISOString(),
|
||||
end_time: p.to?.toISOString(),
|
||||
});
|
||||
}}
|
||||
preDefinedPeriods={TIME_PERIODS}
|
||||
onPredefinedPeriodChange={(periodValue) => {
|
||||
if (!periodValue) return;
|
||||
const { from, to } = getRangeForPeriod(periodValue);
|
||||
setStartTime(from);
|
||||
setEndTime(to);
|
||||
// Relative period: store it in URL and update timestamps via parent
|
||||
onPeriodChange(periodValue, from, to);
|
||||
}}
|
||||
/>
|
||||
<Popover open={openMoreActionsPopover} onOpenChange={setOpenMoreActionsPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7.5 w-7.5">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="bg-accent w-[250px] p-2" align="end">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem className="hover:bg-accent/50 cursor-pointer" onSelect={handleRecalculateCosts}>
|
||||
<Calculator className="text-muted-foreground size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">Recalculate costs</span>
|
||||
<span className="text-muted-foreground text-xs">For all logs that don't have a cost</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColumnConfigDropdown
|
||||
entries={columnEntries}
|
||||
labels={columnLabels}
|
||||
onToggleVisibility={onToggleColumnVisibility}
|
||||
onReset={onResetColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
ui/app/workspace/logs/views/logsTable.tsx
Normal file
279
ui/app/workspace/logs/views/logsTable.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
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 { ChevronLeft, ChevronRight, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<LogEntry>[];
|
||||
data: LogEntry[];
|
||||
totalItems: number;
|
||||
pagination: Pagination;
|
||||
onPaginationChange: (pagination: Pagination) => void;
|
||||
onRowClick?: (log: LogEntry, columnId: string) => void;
|
||||
polling: boolean;
|
||||
loading?: boolean;
|
||||
onRefresh: () => void;
|
||||
/** 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,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
onRowClick,
|
||||
polling,
|
||||
loading,
|
||||
onRefresh,
|
||||
columnEntries,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
onToggleColumnVisibility,
|
||||
onTogglePin,
|
||||
onReorderColumns,
|
||||
}: DataTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: pagination.sort_by, desc: pagination.order === "desc" }]);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const calculatedPageSize = useTablePageSize(tableContainerRef);
|
||||
|
||||
const fixedColumnIds = useMemo(() => new Set<string>([]), []);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
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 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 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,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div ref={tableContainerRef} className="min-h-0 flex-1 overflow-hidden rounded-sm border">
|
||||
<Table containerClassName="h-full overflow-auto">
|
||||
<thead className={cn("[&_tr]:border-b px-2 sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a]")}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="hover:bg-muted/50 dark:hover:bg-muted/75 data-[state=selected]:bg-muted border-b transition-colors"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DraggableColumnHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
isConfigurable={!fixedColumnIds.has(header.column.id)}
|
||||
pinStyle={buildPinStyle(header.column, pinOffsets)}
|
||||
pinnedHeaderClassName="bg-[#f9f9f9] dark:bg-[#27272a]"
|
||||
className={cn(
|
||||
header.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
|
||||
header.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
|
||||
)}
|
||||
onHide={onToggleColumnVisibility}
|
||||
onPin={onTogglePin}
|
||||
onDrop={handleColumnDrop}
|
||||
cellRef={setHeaderCellRef(header.column.id)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="h-12 text-center">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 text-sm">
|
||||
{polling ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Waiting for new logs...
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
data-testid="logs-table-refresh-btn"
|
||||
className="hover:text-foreground inline-flex items-center gap-1.5 transition-colors"
|
||||
variant={"ghost"}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/50 group/table-row min-h-[40px] cursor-pointer">
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const pinned = cell.column.getIsPinned();
|
||||
const size = cell.column.getSize();
|
||||
return (
|
||||
<TableCell
|
||||
onClick={() => 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())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results found. Try adjusting your filters and/or time range.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex shrink-0 items-center justify-between text-xs" data-testid="pagination">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
{startItem.toLocaleString()}-{endItem.toLocaleString()} of {totalItems.toLocaleString()} entries
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
data-testid="prev-page"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Page</span>
|
||||
<span>{currentPage}</span>
|
||||
<span>of {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={totalPages === 0 || currentPage >= totalPages}
|
||||
data-testid="next-page"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{ name: "", value: 0 },
|
||||
{ name: " ", value: 0 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 13, className: "fill-zinc-500", dy: 5 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 13, className: "fill-zinc-500" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
domain={[0, 1]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <EmptyChart />;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
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 }: CustomTooltipProps) {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0]?.payload;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-zinc-200 bg-white px-3 py-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs text-zinc-500">
|
||||
{formatFullTimestamp(data.timestamp)}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="mt-2 flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Total</span>
|
||||
</span>
|
||||
<span className="font-medium">{data.count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Success</span>
|
||||
</span>
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{data.success.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span className="text-zinc-600 dark:text-zinc-400">Error</span>
|
||||
</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
{data.error.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsVolumeChart({
|
||||
data,
|
||||
loading,
|
||||
onTimeRangeChange,
|
||||
onResetZoom,
|
||||
isZoomed,
|
||||
startTime,
|
||||
endTime,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: LogsVolumeChartProps) {
|
||||
// State for drag selection
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||
const [refAreaRight, setRefAreaRight] = useState<number | null>(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 (
|
||||
<Card className="rounded-sm px-2 py-2 shadow-none">
|
||||
<Collapsible open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger
|
||||
data-testid="logs-volume-chart-trigger"
|
||||
className="flex items-center gap-2 hover:opacity-80"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`text-muted-foreground h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Request Volume
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<div className="mr-2 flex items-center gap-4">
|
||||
{isOpen && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-muted-foreground">Success</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span className="text-muted-foreground">Error</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isZoomed && onResetZoom && (
|
||||
<button
|
||||
data-testid="logs-volume-chart-reset-zoom"
|
||||
onClick={onResetZoom}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down overflow-hidden">
|
||||
<div className="mt-2 h-32 select-none">
|
||||
{loading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : hasValidData ? (
|
||||
<ChartErrorBoundary
|
||||
resetKey={`${startTime}-${endTime}-${chartData.length}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 6, right: 4, left: 12, bottom: 0 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
barCategoryGap={1}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-zinc-200 dark:stroke-zinc-700"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
type="number"
|
||||
domain={[-0.5, chartData.length - 0.5]}
|
||||
tick={{ fontSize: 11, className: "fill-zinc-500", dy: 5 }}
|
||||
tickLine={true}
|
||||
axisLine={false}
|
||||
tickFormatter={(idx) =>
|
||||
chartData[Math.round(idx)]?.formattedTime || ""
|
||||
}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, className: "fill-zinc-500" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
tickFormatter={(v) => formatRequest(v)}
|
||||
domain={[0, (dataMax: number) => Math.max(dataMax, 5)]}
|
||||
allowDataOverflow={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="success"
|
||||
stackId="requests"
|
||||
barSize={30}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.7}
|
||||
radius={[0, 0, 0, 0]}
|
||||
cursor="pointer"
|
||||
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="error"
|
||||
stackId="requests"
|
||||
fill="#ef4444"
|
||||
barSize={30}
|
||||
fillOpacity={0.7}
|
||||
radius={[2, 2, 0, 0]}
|
||||
cursor="pointer"
|
||||
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
||||
/>
|
||||
{refAreaLeft !== null &&
|
||||
refAreaRight !== null &&
|
||||
chartData[refAreaLeft] &&
|
||||
chartData[refAreaRight] && (
|
||||
<ReferenceArea
|
||||
x1={refAreaLeft}
|
||||
x2={refAreaRight}
|
||||
strokeOpacity={0.3}
|
||||
fill="#6366f1"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartErrorBoundary>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
167
ui/app/workspace/logs/views/ocrView.tsx
Normal file
167
ui/app/workspace/logs/views/ocrView.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { BifrostOCRResponse, OCRDocument } from "@/lib/types/logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { ChevronLeft, ChevronRight, FileText } from "lucide-react";
|
||||
|
||||
function getImageSrc(b64: string): string {
|
||||
if (b64.startsWith("/9j/")) return `data:image/jpeg;base64,${b64}`;
|
||||
if (b64.startsWith("iVBOR")) return `data:image/png;base64,${b64}`;
|
||||
if (b64.startsWith("UklGR")) return `data:image/webp;base64,${b64}`;
|
||||
if (b64.startsWith("R0lGO")) return `data:image/gif;base64,${b64}`;
|
||||
return `data:image/png;base64,${b64}`;
|
||||
}
|
||||
|
||||
interface OCRViewProps {
|
||||
ocrInput?: OCRDocument;
|
||||
ocrOutput?: BifrostOCRResponse;
|
||||
}
|
||||
|
||||
export default function OCRView({ ocrInput, ocrOutput }: OCRViewProps) {
|
||||
const pages = ocrOutput?.pages ?? [];
|
||||
const totalPages = pages.length;
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalPages === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else {
|
||||
setCurrentIndex((prev) => Math.min(prev, totalPages - 1));
|
||||
}
|
||||
}, [totalPages]);
|
||||
|
||||
const goToPrevious = () => setCurrentIndex((prev) => (prev === 0 ? totalPages - 1 : prev - 1));
|
||||
const goToNext = () => setCurrentIndex((prev) => (prev === totalPages - 1 ? 0 : prev + 1));
|
||||
|
||||
const currentPage = pages[currentIndex] ?? null;
|
||||
const pageImages = currentPage?.images?.filter((img) => img.image_base64) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* OCR Input */}
|
||||
{ocrInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
OCR Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">TYPE</div>
|
||||
<div className="font-mono text-xs">{ocrInput.type === "document_url" ? "Document" : "Image"}</div>
|
||||
</div>
|
||||
{(ocrInput.document_url || ocrInput.image_url) && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
{ocrInput.type === "document_url" ? "DOCUMENT URL" : "IMAGE URL"}
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{ocrInput.document_url ?? ocrInput.image_url}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR Output */}
|
||||
{ocrOutput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
OCR Output
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{ocrOutput.usage_info && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">PAGES PROCESSED</div>
|
||||
<div className="font-mono text-xs">{ocrOutput.usage_info.pages_processed}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DOCUMENT SIZE</div>
|
||||
<div className="font-mono text-xs">{(ocrOutput.usage_info.doc_size_bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrOutput.document_annotation && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DOCUMENT ANNOTATION</div>
|
||||
<div className="font-mono text-xs">{ocrOutput.document_annotation}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage && (
|
||||
<>
|
||||
{currentPage.dimensions && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DIMENSIONS</div>
|
||||
<div className="font-mono text-xs">{currentPage.dimensions.width} × {currentPage.dimensions.height}px</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">DPI</div>
|
||||
<div className="font-mono text-xs">{currentPage.dimensions.dpi}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage.markdown ? (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">MARKDOWN</div>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight
|
||||
maxHeight={400}
|
||||
wrap
|
||||
code={currentPage.markdown}
|
||||
lang="markdown"
|
||||
readonly
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground font-mono text-xs">No text extracted from this page.</div>
|
||||
)}
|
||||
|
||||
{pageImages.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">EXTRACTED IMAGES ({pageImages.length})</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pageImages.map((img) => (
|
||||
<img
|
||||
key={img.id}
|
||||
src={getImageSrc(img.image_base64!)}
|
||||
alt={`Image ${img.id}`}
|
||||
className="max-h-48 max-w-48 rounded border object-contain"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={goToPrevious} aria-label="Previous page" title="Previous page" data-testid="ocr-view-pagination-prev-button">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Page {currentIndex + 1} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={goToNext} aria-label="Next page" title="Next page" data-testid="ocr-view-pagination-next-button">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
ui/app/workspace/logs/views/pluginLogsView.tsx
Normal file
81
ui/app/workspace/logs/views/pluginLogsView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { PluginLogEntry } from "@/lib/types/logs";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
info: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
warn: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface PluginLogsViewProps {
|
||||
pluginLogs: string;
|
||||
}
|
||||
|
||||
export default function PluginLogsView({ pluginLogs }: PluginLogsViewProps) {
|
||||
let parsed: Record<string, PluginLogEntry[]>;
|
||||
try {
|
||||
const raw: unknown = JSON.parse(pluginLogs);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
parsed = Object.fromEntries(Object.entries(raw as Record<string, unknown>).filter(([, value]) => Array.isArray(value))) as Record<
|
||||
string,
|
||||
PluginLogEntry[]
|
||||
>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginNames = Object.keys(parsed);
|
||||
if (pluginNames.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="py-3 text-sm font-semibold">Plugin Logs</div>
|
||||
<div className="flex flex-col gap-2 pb-3">
|
||||
{pluginNames.map((name) => (
|
||||
<PluginSection key={name} name={name} entries={parsed[name]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSection({ name, entries }: { name: string; entries: PluginLogEntry[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const sorted = [...entries].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`plugin-logs-toggle-${name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hover:bg-muted/50 flex w-full items-center gap-2 px-4 py-2 text-left text-sm"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span className="font-medium">{name}</span>
|
||||
<span className="text-muted-foreground text-xs">({entries.length})</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="custom-scrollbar max-h-[300px] overflow-y-auto border-t">
|
||||
{sorted.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 border-b px-4 py-1.5 font-mono text-xs last:border-b-0">
|
||||
<span className="text-muted-foreground shrink-0">{format(new Date(entry.timestamp), "HH:mm:ss.SSS")}</span>
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ${levelColors[entry.level] || levelColors.info}`}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="break-words whitespace-pre-wrap">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
ui/app/workspace/logs/views/speechView.tsx
Normal file
73
ui/app/workspace/logs/views/speechView.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BifrostSpeech, SpeechInput } from "@/lib/types/logs";
|
||||
import { AlertCircle, Play, Volume2 } from "lucide-react";
|
||||
import React, { Component } from "react";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
|
||||
interface SpeechViewProps {
|
||||
speechInput?: SpeechInput;
|
||||
speechOutput?: BifrostSpeech;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Error boundary specifically for audio player errors
|
||||
class AudioErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Audio player error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to load audio player: {this.state.error?.message || "Unknown error"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SpeechView({ speechInput, speechOutput, isStreaming }: SpeechViewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Speech Input */}
|
||||
{speechInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
Speech Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="font-mono text-xs">{speechInput.input}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speech Output */}
|
||||
{(speechOutput || isStreaming) && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Play className="h-4 w-4" />
|
||||
Speech Output
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<AudioErrorBoundary>
|
||||
<AudioPlayer src={speechOutput?.audio || ""} />
|
||||
</AudioErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
ui/app/workspace/logs/views/transcriptionView.tsx
Normal file
158
ui/app/workspace/logs/views/transcriptionView.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { BifrostTranscribe, TranscriptionInput } from "@/lib/types/logs";
|
||||
import { Clock, FileAudio, Mic } from "lucide-react";
|
||||
import AudioPlayer from "./audioPlayer";
|
||||
|
||||
interface TranscriptionViewProps {
|
||||
transcriptionInput?: TranscriptionInput;
|
||||
transcriptionOutput?: BifrostTranscribe;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export default function TranscriptionView({ transcriptionInput, transcriptionOutput, isStreaming }: TranscriptionViewProps) {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(1);
|
||||
return `${mins}:${secs.padStart(4, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Transcription Input */}
|
||||
{transcriptionInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<FileAudio className="h-4 w-4" />
|
||||
Transcription Input
|
||||
</div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">AUDIO FILE</div>
|
||||
{/* Audio Controls */}
|
||||
<AudioPlayer src={transcriptionInput.file} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcription Output */}
|
||||
{(transcriptionOutput || isStreaming) && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Mic className="h-4 w-4" />
|
||||
Transcription Output
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{!transcriptionOutput && isStreaming ? (
|
||||
<div className="font-mono text-xs">Output was streamed and is not available.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Main Transcription Text */}
|
||||
<div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput?.text}</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
{(transcriptionOutput?.task || transcriptionOutput?.language || transcriptionOutput?.duration) && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{transcriptionOutput?.task && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">TASK</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.task}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionOutput?.language && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DETECTED LANGUAGE</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.language}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcriptionOutput?.duration && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">DURATION</div>
|
||||
<div className="font-mono text-xs">{transcriptionOutput.duration.toFixed(1)}s</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Words with Timing */}
|
||||
{transcriptionOutput?.words && transcriptionOutput.words.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">WORD-LEVEL TIMING</div>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transcriptionOutput.words.map((word, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs"
|
||||
title={`${formatTime(word.start)} - ${formatTime(word.end)}`}
|
||||
>
|
||||
<span>{word.word}</span>
|
||||
<span className="text-muted-foreground text-xs">{formatTime(word.start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segments */}
|
||||
{transcriptionOutput?.segments && transcriptionOutput.segments.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">SEGMENTS</div>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto">
|
||||
{transcriptionOutput.segments.map((segment) => (
|
||||
<div key={segment.id} className="rounded border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Segment {segment.id}
|
||||
</Badge>
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(segment.start)} - {formatTime(segment.end)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">{segment.text}</div>
|
||||
<div className="text-muted-foreground mt-2 flex gap-4 text-xs">
|
||||
<span>Avg LogProb: {segment.avg_logprob.toFixed(3)}</span>
|
||||
<span>No Speech: {(segment.no_speech_prob * 100).toFixed(1)}%</span>
|
||||
<span>Temp: {segment.temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Probabilities */}
|
||||
{transcriptionOutput?.logprobs && transcriptionOutput.logprobs.length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">LOG PROBABILITIES</div>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={200}
|
||||
wrap={true}
|
||||
code={JSON.stringify(transcriptionOutput.logprobs, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "off",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
ui/app/workspace/logs/views/videoView.tsx
Normal file
155
ui/app/workspace/logs/views/videoView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ExternalLink, Video } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BifrostVideoDownloadOutput, BifrostVideoGenerationOutput, BifrostVideoListOutput } from "@/lib/types/logs";
|
||||
|
||||
import CollapsibleBox from "./collapsibleBox";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
|
||||
interface VideoGenerationInput {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
type VideoOutput = BifrostVideoGenerationOutput | BifrostVideoDownloadOutput;
|
||||
|
||||
interface VideoViewProps {
|
||||
videoInput?: VideoGenerationInput;
|
||||
videoOutput?: VideoOutput;
|
||||
videoListOutput?: BifrostVideoListOutput;
|
||||
requestType?: string;
|
||||
}
|
||||
|
||||
function getMethodTypeLabel(requestType?: string): string {
|
||||
if (!requestType) return "Video";
|
||||
const normalized = requestType.toLowerCase();
|
||||
if (normalized.includes("video_download")) return "Video Download";
|
||||
if (normalized.includes("video_retrieve")) return "Video Retrieve";
|
||||
if (normalized.includes("video_generation")) return "Video Generation";
|
||||
if (normalized.includes("video_list")) return "Video List";
|
||||
return "Video";
|
||||
}
|
||||
|
||||
export default function VideoView({ videoInput, videoOutput, videoListOutput, requestType }: VideoViewProps) {
|
||||
const methodTypeLabel = getMethodTypeLabel(requestType);
|
||||
const isDownload = requestType?.toLowerCase().includes("video_download");
|
||||
const downloadOutput = isDownload && videoOutput ? (videoOutput as BifrostVideoDownloadOutput) : null;
|
||||
const generationOutput = !isDownload && videoOutput ? (videoOutput as BifrostVideoGenerationOutput) : null;
|
||||
const outputURL = generationOutput?.videos?.[0]?.url;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{videoInput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Video className="h-4 w-4" />
|
||||
{methodTypeLabel} Input
|
||||
</div>
|
||||
<div className="space-y-2 p-6">
|
||||
<div className="text-muted-foreground text-xs font-medium">PROMPT</div>
|
||||
<div className="font-mono text-xs">{videoInput.prompt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoOutput && (
|
||||
<div className="w-full rounded-sm border">
|
||||
<div className="flex items-center gap-2 border-b px-6 py-2 text-sm font-medium">
|
||||
<Video className="h-4 w-4" />
|
||||
{methodTypeLabel} Output
|
||||
</div>
|
||||
<div className="space-y-3 p-6">
|
||||
{downloadOutput ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{downloadOutput.video_id && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">VIDEO ID</div>
|
||||
<div className="font-mono text-xs break-all">{downloadOutput.video_id}</div>
|
||||
</div>
|
||||
)}
|
||||
{downloadOutput.content_type && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">CONTENT TYPE</div>
|
||||
<div className="font-mono text-xs">{downloadOutput.content_type}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">Video content was successfully downloaded (content is not stored in logs)</p>
|
||||
</>
|
||||
) : generationOutput ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{generationOutput.status && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">STATUS</div>
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{generationOutput.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{generationOutput.progress !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">PROGRESS</div>
|
||||
<div className="font-mono text-xs">{generationOutput.progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{generationOutput.id && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs font-medium">VIDEO ID</div>
|
||||
<div className="font-mono text-xs break-all">{generationOutput.id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generationOutput.error && (generationOutput.error.message || generationOutput.error.code) && (
|
||||
<div className="flex items-start gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground font-medium">Error from provider</div>
|
||||
{generationOutput.error.code && <div className="font-medium">{generationOutput.error.code}</div>}
|
||||
{generationOutput.error.message && <div className="text-muted-foreground">{generationOutput.error.message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputURL && (
|
||||
<div className="space-y-2">
|
||||
<video className="w-full rounded-sm border bg-black" controls preload="metadata" src={outputURL}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
<a
|
||||
href={outputURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline"
|
||||
>
|
||||
Open video URL
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoListOutput && (
|
||||
<CollapsibleBox
|
||||
title={`Video List Output (${videoListOutput.data?.length ?? 0})`}
|
||||
onCopy={() => JSON.stringify(videoListOutput, null, 2)}
|
||||
>
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
shouldAdjustInitialHeight={true}
|
||||
maxHeight={450}
|
||||
wrap={true}
|
||||
code={JSON.stringify(videoListOutput.data, null, 2)}
|
||||
lang="json"
|
||||
readonly={true}
|
||||
options={{ scrollBeyondLastLine: false, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
|
||||
/>
|
||||
</CollapsibleBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user