Files
bifrost/ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

328 lines
11 KiB
TypeScript

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