first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ConnectorsPage from "./page";
export const Route = createFileRoute("/workspace/logs/connectors")({
component: ConnectorsPage,
});

View File

@@ -0,0 +1,5 @@
import ObservabilityConnectorsView from "@/app/workspace/observability/views/observabilityView";
export default function ConnectorsPage() {
return <ObservabilityConnectorsView />;
}

View 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,
});

View 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 });
},
});

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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;
}

View 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>
);
}

View 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"})');
});
});

View 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];
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}