Skip to content

Commit a8f9d74

Browse files
committed
feat: show logs chain on table (frontend)
1 parent 690c851 commit a8f9d74

6 files changed

Lines changed: 213 additions & 15 deletions

File tree

ui/app/workspace/logs/page.tsx

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
useGetUserAgentMappingsQuery,
2121
} from "@/lib/store";
2222
import { useLazyGetLogByIdQuery, useLazyGetLogsQuery } from "@/lib/store/apis/logsApi";
23-
import type { LogEntry, LogFilters, Pagination } from "@/lib/types/logs";
23+
import type { DisplayLogEntry, LogEntry, LogFilters, Pagination } from "@/lib/types/logs";
2424
import { dateUtils } from "@/lib/types/logs";
2525
import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
2626
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
@@ -31,6 +31,10 @@ import { parseAsSafeArrayOf, parseAsSafeString } from "@/lib/queryParamsParser";
3131
import { parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
3232
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3333

34+
// A fallback chain is a handful of attempts, so one page covers every realistic
35+
// chain. Capped at the list endpoint's own maximum.
36+
const chainChildrenPageLimit = 1000;
37+
3438
export default function LogsPage() {
3539
const [error, setError] = useState<string | null>(null);
3640
const [showEmptyState, setShowEmptyState] = useState(false);
@@ -103,6 +107,7 @@ export default function LogsPage() {
103107
cache_hit_types: parseAsSafeArrayOf.withDefault([]),
104108
metadata_filters: parseAsString.withDefault(""),
105109
selected_log: parseAsString.withDefault(""),
110+
grouped: parseAsBoolean.withDefault(false),
106111
},
107112
{
108113
history: "push",
@@ -114,6 +119,9 @@ export default function LogsPage() {
114119
const selectedLogId = urlState.selected_log || null;
115120
const activeLogFetchId = useRef<string | null>(null);
116121
const polling = urlState.polling;
122+
// Grouped view collapses fallback chains under their root. Disabled while a
123+
// session filter is active — that view is already scoped to one chain/session.
124+
const grouped = urlState.grouped && !urlState.parent_request_id;
117125

118126
// Convert URL state to filters and pagination for API calls
119127
const filters: LogFilters = useMemo(
@@ -302,6 +310,7 @@ export default function LogsPage() {
302310
{
303311
filters,
304312
pagination,
313+
rootsOnly: grouped,
305314
},
306315
{
307316
pollingInterval: showEmptyState || polling ? 10000 : 0,
@@ -361,6 +370,66 @@ export default function LogsPage() {
361370
[filters, setFilters],
362371
);
363372

373+
// --- Grouped view: chain expansion state -------------------------------
374+
// Children of an expanded root, keyed by root log id. Loaded lazily through
375+
// the list endpoint with the active filters plus parent_request_id, not the
376+
// sessions endpoint — the sessions endpoint ignores filters, which would show
377+
// rows the filter bar says are excluded. Filtering here keeps the expansion
378+
// consistent with child_count, which the server computes under the same
379+
// filters: every row is either a root or a child, and always matches.
380+
const [expandedChainIds, setExpandedChainIds] = useState<Set<string>>(new Set());
381+
const [chainChildren, setChainChildren] = useState<Record<string, LogEntry[]>>({});
382+
const [loadingChainIds, setLoadingChainIds] = useState<Set<string>>(new Set());
383+
const [triggerGetChainChildren] = useLazyGetLogsQuery();
384+
385+
// Collapse everything when the page of roots changes — expanded ids from the
386+
// previous page are meaningless and cached children may be stale.
387+
useEffect(() => {
388+
setExpandedChainIds(new Set());
389+
setChainChildren({});
390+
setLoadingChainIds(new Set());
391+
}, [filters, pagination, grouped]);
392+
393+
const handleToggleChain = useCallback(
394+
(log: LogEntry) => {
395+
const isExpanded = expandedChainIds.has(log.id);
396+
setExpandedChainIds((prev) => {
397+
const next = new Set(prev);
398+
if (next.has(log.id)) {
399+
next.delete(log.id);
400+
} else {
401+
next.add(log.id);
402+
}
403+
return next;
404+
});
405+
if (isExpanded || chainChildren[log.id] || loadingChainIds.has(log.id)) return;
406+
407+
setLoadingChainIds((prev) => new Set(prev).add(log.id));
408+
triggerGetChainChildren({
409+
filters: { ...filters, parent_request_id: log.id },
410+
pagination: { ...pagination, limit: chainChildrenPageLimit, offset: 0, sort_by: "timestamp", order: "asc" },
411+
}).then((result) => {
412+
setLoadingChainIds((prev) => {
413+
const next = new Set(prev);
414+
next.delete(log.id);
415+
return next;
416+
});
417+
if (result.data) {
418+
const children = result.data.logs;
419+
setChainChildren((prevCache) => ({ ...prevCache, [log.id]: children }));
420+
} else if (result.error) {
421+
setExpandedChainIds((prev) => {
422+
const next = new Set(prev);
423+
next.delete(log.id);
424+
return next;
425+
});
426+
setError(getErrorMessage(result.error));
427+
}
428+
});
429+
},
430+
[expandedChainIds, chainChildren, loadingChainIds, triggerGetChainChildren, filters, pagination],
431+
);
432+
364433
const handleDelete = useCallback(
365434
async (log: LogEntry) => {
366435
try {
@@ -500,8 +569,8 @@ export default function LogsPage() {
500569
}, [userAgentMappingsData?.mappings]);
501570

502571
const columns = useMemo(
503-
() => createColumns(handleDelete, hasDeleteAccess, metadataKeys, customAppIcons),
504-
[customAppIcons, handleDelete, hasDeleteAccess, metadataKeys],
572+
() => createColumns(handleDelete, hasDeleteAccess, metadataKeys, customAppIcons, grouped),
573+
[customAppIcons, handleDelete, hasDeleteAccess, metadataKeys, grouped],
505574
);
506575

507576
const columnIds = useMemo(
@@ -546,12 +615,36 @@ export default function LogsPage() {
546615
paramName: "cols",
547616
storageKey: "bifrost.logs.cols",
548617
defaultHidden: DEFAULT_HIDDEN_COLUMNS,
549-
fixedColumns: hasDeleteAccess ? { right: ["actions"] } : undefined,
618+
fixedColumns: {
619+
...(grouped ? { left: ["expand"] } : {}),
620+
...(hasDeleteAccess ? { right: ["actions"] } : {}),
621+
},
550622
});
551623

552624
// Navigation for log detail sheet
553625
const logs = logsData?.logs ?? [];
554626
const totalItems = logsData?.stats?.total_requests ?? 0;
627+
628+
// Grouped view: splice loaded children in below their expanded root. Children
629+
// are marked so the table can indent them; they don't affect pagination.
630+
const displayLogs: DisplayLogEntry[] = useMemo(() => {
631+
if (!grouped || expandedChainIds.size === 0) return logs;
632+
const out: DisplayLogEntry[] = [];
633+
for (const log of logs) {
634+
out.push(log);
635+
if (expandedChainIds.has(log.id)) {
636+
for (const child of chainChildren[log.id] ?? []) {
637+
out.push({ ...child, __chainChild: true });
638+
}
639+
}
640+
}
641+
return out;
642+
}, [logs, grouped, expandedChainIds, chainChildren]);
643+
644+
const tableMeta = useMemo(
645+
() => ({ expandedChainIds, loadingChainIds, onToggleChain: handleToggleChain }),
646+
[expandedChainIds, loadingChainIds, handleToggleChain],
647+
);
555648
const selectedLogFromData = useMemo(
556649
() => (selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null),
557650
[selectedLogId, logs],
@@ -595,6 +688,7 @@ export default function LogsPage() {
595688
triggerGetLogs({
596689
filters,
597690
pagination: { ...pagination, offset: newOffset },
691+
rootsOnly: grouped,
598692
}).then((result) => {
599693
if (result.data?.logs?.length) {
600694
const lastLog = result.data.logs[result.data.logs.length - 1];
@@ -620,6 +714,7 @@ export default function LogsPage() {
620714
triggerGetLogs({
621715
filters,
622716
pagination: { ...pagination, offset: newOffset },
717+
rootsOnly: grouped,
623718
}).then((result) => {
624719
if (result.data?.logs?.length) {
625720
const firstLog = result.data.logs[0];
@@ -635,7 +730,7 @@ export default function LogsPage() {
635730
}
636731
}
637732
},
638-
[selectedLogId, selectedLogIndex, logs, pagination, totalItems, filters, setUrlState, triggerGetLogs],
733+
[selectedLogId, selectedLogIndex, logs, pagination, totalItems, filters, grouped, setUrlState, triggerGetLogs],
639734
);
640735

641736
return (
@@ -665,6 +760,8 @@ export default function LogsPage() {
665760
loading={logsIsFetching}
666761
polling={polling}
667762
onPollToggle={handlePollToggle}
763+
grouped={grouped}
764+
onGroupedToggle={(enabled) => setUrlState({ grouped: enabled, offset: 0 })}
668765
period={period}
669766
onPeriodChange={handlePeriodChange}
670767
totalLogs={totalItems}
@@ -736,7 +833,8 @@ export default function LogsPage() {
736833
<div className="min-h-0 flex-1">
737834
<LogsDataTable
738835
columns={columns}
739-
data={logs}
836+
data={displayLogs}
837+
tableMeta={tableMeta}
740838
loading={logsIsFetching}
741839
totalItems={totalItems}
742840
pagination={pagination}

ui/app/workspace/logs/views/columns.tsx

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@ import {
1414
Status,
1515
StatusBarColors,
1616
} from "@/lib/constants/logs";
17-
import { ChatMessageContent, LogEntry, ResponsesMessageContentBlock } from "@/lib/types/logs";
17+
import { ChatMessageContent, DisplayLogEntry, LogEntry, ResponsesMessageContentBlock } from "@/lib/types/logs";
1818
import { cn } from "@/lib/utils";
1919
import { formatCompactNumber } from "@/lib/utils/numbers";
2020
import { ColumnDef } from "@tanstack/react-table";
2121
import { format, formatDistanceToNow } from "date-fns";
22-
import { ArrowUpDown, MoreHorizontal, Trash2 } from "lucide-react";
22+
import { ArrowUpDown, ChevronRight, CornerDownRight, Loader2, MoreHorizontal, Trash2 } from "lucide-react";
2323
import { useState } from "react";
2424

25+
// Passed to useReactTable({ meta }) by the logs page so the expander column can
26+
// read/toggle chain expansion without threading props through column factories.
27+
export interface LogsTableMeta {
28+
expandedChainIds: Set<string>;
29+
loadingChainIds: Set<string>;
30+
onToggleChain: (log: LogEntry) => void;
31+
}
32+
2533
function LogActionsMenu({ log, onDelete }: { log: LogEntry; onDelete: (log: LogEntry) => void }) {
2634
const [isOpen, setIsOpen] = useState(false);
2735

@@ -261,7 +269,51 @@ export const createColumns = (
261269
hasDeleteAccess = true,
262270
metadataKeys: string[] = [],
263271
customAppIcons: Record<string, string> = {},
272+
groupedView = false,
264273
): ColumnDef<LogEntry>[] => {
274+
// Chevron that expands a fallback chain in the grouped view. Child rows get a
275+
// corner connector instead so the hierarchy stays readable in any column order.
276+
const expandColumn: ColumnDef<LogEntry>[] = groupedView
277+
? [
278+
{
279+
id: "expand",
280+
header: "",
281+
size: 52,
282+
cell: ({ row, table }) => {
283+
const meta = table.options.meta as LogsTableMeta | undefined;
284+
const log = row.original as DisplayLogEntry;
285+
if (log.__chainChild) {
286+
return <CornerDownRight className="text-muted-foreground/70 mx-auto size-3.5" />;
287+
}
288+
const childCount = log.child_count ?? 0;
289+
if (!childCount || !meta) return null;
290+
const isExpanded = meta.expandedChainIds.has(log.id);
291+
const isLoading = meta.loadingChainIds.has(log.id);
292+
return (
293+
<button
294+
type="button"
295+
data-testid="log-chain-expand-btn"
296+
aria-label={isExpanded ? "Collapse fallback chain" : `Expand fallback chain (${childCount} attempts)`}
297+
aria-expanded={isExpanded}
298+
className="text-muted-foreground hover:text-foreground gap-1 rounded-sm transition-colors absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center cursor-pointer"
299+
onClick={(event) => {
300+
event.stopPropagation();
301+
meta.onToggleChain(log);
302+
}}
303+
>
304+
{isLoading ? (
305+
<Loader2 className="size-3.5 animate-spin" />
306+
) : (
307+
<ChevronRight className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} />
308+
)}
309+
<span className="font-mono text-[10.5px] tabular-nums">{childCount}</span>
310+
</button>
311+
);
312+
},
313+
},
314+
]
315+
: [];
316+
265317
const baseColumns: ColumnDef<LogEntry>[] = [
266318
{
267319
accessorKey: "status",
@@ -530,5 +582,5 @@ export const createColumns = (
530582
]
531583
: [];
532584

533-
return [...baseColumns, ...attributionColumns, ...metadataColumns, ...actionsColumn];
585+
return [...expandColumn, ...baseColumns, ...attributionColumns, ...metadataColumns, ...actionsColumn];
534586
};

ui/app/workspace/logs/views/logsHeaderView.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { Command, CommandItem, CommandList } from "@/components/ui/command";
44
import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange";
55
import { Input } from "@/components/ui/input";
66
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
7+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
78
import { useTimezonePreference } from "@/lib/hooks/useTimezonePreference";
89
import { getErrorMessage } from "@/lib/store";
910
import { useGetRecalculateCostStatusQuery } from "@/lib/store/apis/logsApi";
1011
import { getActiveTempToken } from "@/lib/store/apis/tempToken";
1112
import type { LogFilters as LogFiltersType, RecalcJobStatus } from "@/lib/types/logs";
1213
import { getApiBaseUrl } from "@/lib/utils/port";
1314
import { getRangeForPeriod, TIME_PERIODS } from "@/lib/utils/timeRange";
14-
import { Calculator, MoreVertical, Radio, RefreshCw, Search } from "lucide-react";
15+
import { Calculator, ListTree, MoreVertical, Radio, RefreshCw, Search } from "lucide-react";
1516
import { useCallback, useEffect, useRef, useState } from "react";
1617
import { toast } from "sonner";
1718
import { RecalculateCostDialog, type RecalculateCostMode } from "./recalculateCostDialog";
@@ -25,6 +26,9 @@ interface LogsHeaderViewProps {
2526
loading?: boolean;
2627
polling: boolean;
2728
onPollToggle: (enabled: boolean) => void;
29+
/** Grouped view: collapse fallback chains into their root request */
30+
grouped: boolean;
31+
onGroupedToggle: (enabled: boolean) => void;
2832
period: string;
2933
onPeriodChange: (period?: string, from?: Date, to?: Date) => void;
3034
/** Total logs matching the current filters/time window (stats.total_requests) */
@@ -45,6 +49,8 @@ export function LogsHeaderView({
4549
loading = false,
4650
polling,
4751
onPollToggle,
52+
grouped,
53+
onGroupedToggle,
4854
period,
4955
onPeriodChange,
5056
totalLogs,
@@ -216,6 +222,25 @@ export function LogsHeaderView({
216222
{polling ? <Radio className="h-4 w-4 animate-pulse" /> : <Radio className="h-4 w-4" />}
217223
Live
218224
</Button>
225+
<Tooltip>
226+
<TooltipTrigger asChild>
227+
<Button
228+
data-testid="logs-group-chains-btn"
229+
variant={grouped ? "default" : "outline"}
230+
size="sm"
231+
className="h-7.5"
232+
onClick={() => onGroupedToggle(!grouped)}
233+
>
234+
<ListTree className="h-4 w-4" />
235+
Group
236+
</Button>
237+
</TooltipTrigger>
238+
<TooltipContent sideOffset={6} className="max-w-64">
239+
Groups fallback attempts and linked requests under the original root request. Expand any row to view the complete request chain.
240+
<br /><br />
241+
This grouped view may load more slowly than the flat view for very large log tables.
242+
</TooltipContent>
243+
</Tooltip>
219244
<div className="border-input flex h-7.5 flex-1 items-center gap-2 rounded-sm border">
220245
<Search className="mr-0.5 ml-2 size-4" />
221246
<Input

0 commit comments

Comments
 (0)