first commit
This commit is contained in:
2241
ui/app/workspace/logs/sheets/logDetailView.tsx
Normal file
2241
ui/app/workspace/logs/sheets/logDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
131
ui/app/workspace/logs/sheets/logDetailsSheet.tsx
Normal file
131
ui/app/workspace/logs/sheets/logDetailsSheet.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useGetLogByIdQuery } from "@/lib/store/apis/logsApi";
|
||||
import { useGetPromptQuery } from "@/lib/store/apis/promptsApi";
|
||||
import type { LogEntry } from "@/lib/types/logs";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { LogDetailView } from "./logDetailView";
|
||||
|
||||
interface LogDetailSheetProps {
|
||||
log: LogEntry | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
handleDelete: (log: LogEntry) => void;
|
||||
onNavigate?: (direction: "prev" | "next") => void;
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
onViewSession?: (sessionId: string, logId: string) => void;
|
||||
onFilterByParentRequestId?: (parentRequestId: string) => void;
|
||||
}
|
||||
|
||||
export function LogDetailSheet({
|
||||
log,
|
||||
open,
|
||||
onOpenChange,
|
||||
handleDelete,
|
||||
onNavigate,
|
||||
hasPrev = false,
|
||||
hasNext = false,
|
||||
onViewSession,
|
||||
onFilterByParentRequestId,
|
||||
}: LogDetailSheetProps) {
|
||||
const [pollingInterval, setPollingInterval] = useState(0);
|
||||
const {
|
||||
data: fullLog,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetLogByIdQuery(log?.id ?? "", {
|
||||
skip: !open || !log?.id,
|
||||
pollingInterval,
|
||||
});
|
||||
|
||||
const shouldPoll = isError || fullLog?.status === "processing";
|
||||
|
||||
const isFullDataReady = log != null && (isError || (fullLog?.id === log.id && !isLoading));
|
||||
// Prefer full log when loaded; otherwise list row — enables prompt fetch in parallel with getLogById
|
||||
const selectedPromptId = log ? (fullLog?.id === log.id ? fullLog : log).selected_prompt_id : undefined;
|
||||
const { data: selectedPromptData } = useGetPromptQuery(selectedPromptId ?? "", {
|
||||
skip: !open || !selectedPromptId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPollingInterval(shouldPoll ? 2000 : 0);
|
||||
}, [shouldPoll]);
|
||||
|
||||
// Keyboard navigation: arrow up/down to navigate between logs
|
||||
useHotkeys("up", () => onNavigate?.("prev"), {
|
||||
enabled: open && hasPrev,
|
||||
preventDefault: true,
|
||||
});
|
||||
useHotkeys("down", () => onNavigate?.("next"), {
|
||||
enabled: open && hasNext,
|
||||
preventDefault: true,
|
||||
});
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
// Show a loader only on the initial fetch, not during background polling refetches.
|
||||
const displayLog: LogEntry = isFullDataReady && fullLog ? fullLog : log;
|
||||
const resolvedSelectedPromptName = selectedPromptData?.prompt?.name ?? displayLog.selected_prompt_name ?? "";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="border-secondary flex w-full flex-col gap-4 overflow-x-hidden border p-8 sm:max-w-[60%]">
|
||||
{!isFullDataReady ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<SheetTitle className="sr-only">Loading log details</SheetTitle>
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<LogDetailView
|
||||
log={displayLog}
|
||||
resolvedSelectedPromptName={resolvedSelectedPromptName}
|
||||
handleDelete={handleDelete}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onFilterByParentRequestId={onFilterByParentRequestId}
|
||||
headerAction={
|
||||
<>
|
||||
{displayLog.parent_request_id && onViewSession ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="session-button-view"
|
||||
onClick={() => onViewSession(displayLog.parent_request_id as string, displayLog.id)}
|
||||
>
|
||||
View Session
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onNavigate?.("prev")}
|
||||
aria-label="Previous log"
|
||||
data-testid="logdetails-prev-button"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onNavigate?.("next")}
|
||||
aria-label="Next log"
|
||||
data-testid="logdetails-next-button"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
22
ui/app/workspace/logs/sheets/observabilityConfigSheet.tsx
Normal file
22
ui/app/workspace/logs/sheets/observabilityConfigSheet.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import ObservabilityView from "@/app/workspace/config/views/observabilityView";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface ObservabilityConfigSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ObservabilityConfigSheet({ open, onOpenChange }: ObservabilityConfigSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
|
||||
<SheetHeader className="flex flex-row items-center px-0">
|
||||
<SheetTitle>Observability settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-2">
|
||||
<ObservabilityView />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
22
ui/app/workspace/logs/sheets/observabilitySettingsSheet.tsx
Normal file
22
ui/app/workspace/logs/sheets/observabilitySettingsSheet.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import LoggingView from "@/app/workspace/config/views/loggingView";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface ObservabilitySettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ObservabilitySettingsSheet({ open, onOpenChange }: ObservabilitySettingsSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col overflow-x-hidden px-8 pt-6 sm:max-w-3xl">
|
||||
<SheetHeader className="">
|
||||
<SheetTitle className="text-lg font-semibold">Logging settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<LoggingView />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
328
ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
Normal file
328
ui/app/workspace/logs/sheets/sessionDetailsSheet.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import type { ProviderName } from "@/lib/constants/logs";
|
||||
import { RequestTypeColors, RequestTypeLabels, Status, StatusBarColors } from "@/lib/constants/logs";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useGetLogSessionSummaryByIdQuery, useLazyGetLogSessionByIdQuery } from "@/lib/store/apis/logsApi";
|
||||
import { LogEntry } from "@/lib/types/logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDown, ArrowUp, Loader2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LogMessageCell } from "../views/columns";
|
||||
|
||||
const SESSION_LOG_PAGE_SIZE = 500;
|
||||
|
||||
const HIGHLIGHTED_ROW =
|
||||
"border-l-2 border-l-sky-500 bg-sky-500/[0.08] shadow-[inset_0_0_0_1px_rgba(56,189,248,0.18)] hover:bg-sky-500/[0.24] hover:shadow-[inset_0_0_0_1px_rgba(56,189,248,0.38)] dark:hover:bg-sky-400/[0.18]";
|
||||
|
||||
function formatDurationFromMs(durationMs?: number) {
|
||||
if (!durationMs || durationMs <= 0) return "0s";
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
interface SummaryCard {
|
||||
label: string;
|
||||
value: string;
|
||||
helper?: string;
|
||||
size?: "sm";
|
||||
}
|
||||
|
||||
interface SessionDetailsSheetProps {
|
||||
sessionId: string | null;
|
||||
highlightedLogId?: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLogClick?: (log: LogEntry) => void;
|
||||
onFilterByParentRequestId?: (parentRequestId: string) => void;
|
||||
}
|
||||
|
||||
export function SessionDetailsSheet({
|
||||
sessionId,
|
||||
highlightedLogId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onLogClick,
|
||||
onFilterByParentRequestId,
|
||||
}: SessionDetailsSheetProps) {
|
||||
const [triggerGetSession] = useLazyGetLogSessionByIdQuery();
|
||||
const [sessionLogs, setSessionLogs] = useState<LogEntry[]>([]);
|
||||
const [loadingSession, setLoadingSession] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [fetchedCount, setFetchedCount] = useState(0);
|
||||
const fetchedCountRef = useRef(fetchedCount);
|
||||
const totalCountRef = useRef(totalCount);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { data: sessionSummary } = useGetLogSessionSummaryByIdQuery(sessionId || "", {
|
||||
skip: !open || !sessionId,
|
||||
pollingInterval: 5000,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const summaryCards: SummaryCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Logs",
|
||||
value: (sessionSummary?.count || 0).toLocaleString(),
|
||||
helper: sessionSummary && sessionLogs.length < sessionSummary.count ? `(${sessionLogs.length.toLocaleString()} loaded)` : undefined,
|
||||
},
|
||||
{
|
||||
label: "Total Cost",
|
||||
value: `$${(sessionSummary?.total_cost || 0).toFixed(4)}`,
|
||||
},
|
||||
{
|
||||
label: "Total Tokens",
|
||||
value: (sessionSummary?.total_tokens || 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: "Started",
|
||||
value: sessionSummary?.started_at ? format(new Date(sessionSummary.started_at), "MMM d, yyyy hh:mm:ss aa") : "N/A",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
label: "Latest Update",
|
||||
value: sessionSummary?.latest_at ? format(new Date(sessionSummary.latest_at), "MMM d, yyyy hh:mm:ss aa") : "N/A",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
label: "Duration",
|
||||
value: formatDurationFromMs(sessionSummary?.duration_ms),
|
||||
},
|
||||
],
|
||||
[sessionSummary, sessionLogs.length],
|
||||
);
|
||||
|
||||
const sortSessionLogs = useCallback(
|
||||
(logs: LogEntry[]) =>
|
||||
[...logs].sort((a, b) =>
|
||||
sortOrder === "asc"
|
||||
? new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
: new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
const loadSessionPage = useCallback(
|
||||
async (offset: number, reset = false) => {
|
||||
if (!sessionId) return;
|
||||
setLoadingSession(true);
|
||||
try {
|
||||
const result = await triggerGetSession({
|
||||
sessionId,
|
||||
pagination: { limit: SESSION_LOG_PAGE_SIZE, offset, order: sortOrder },
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error("Failed to load session logs", {
|
||||
description: getErrorMessage(result.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.data) {
|
||||
if (reset && result.data.count === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setTotalCount(result.data.count);
|
||||
setHasMore(result.data.has_more);
|
||||
setFetchedCount(offset + result.data.returned_count);
|
||||
setSessionLogs((prev) => {
|
||||
const next = reset ? result.data!.logs : [...prev, ...result.data!.logs];
|
||||
const seen = new Map<string, LogEntry>();
|
||||
for (const log of next) {
|
||||
seen.set(log.id, log);
|
||||
}
|
||||
return sortSessionLogs(Array.from(seen.values()));
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingSession(false);
|
||||
}
|
||||
},
|
||||
[onOpenChange, sessionId, sortOrder, sortSessionLogs, triggerGetSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchedCountRef.current = fetchedCount;
|
||||
}, [fetchedCount]);
|
||||
useEffect(() => {
|
||||
totalCountRef.current = totalCount;
|
||||
}, [totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) {
|
||||
return;
|
||||
}
|
||||
setSessionLogs([]);
|
||||
setFetchedCount(0);
|
||||
setTotalCount(0);
|
||||
fetchedCountRef.current = 0;
|
||||
totalCountRef.current = 0;
|
||||
setHasMore(false);
|
||||
loadSessionPage(0, true);
|
||||
}, [open, sessionId, sortOrder, loadSessionPage]);
|
||||
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-medium">Session</div>
|
||||
{sessionId && onFilterByParentRequestId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code
|
||||
className="text-primary hover:text-primary/80 cursor-pointer text-sm break-all underline-offset-2 hover:underline"
|
||||
onClick={() => onFilterByParentRequestId(sessionId)}
|
||||
>
|
||||
{sessionId}
|
||||
</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Filter this session</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<code className="text-sm break-all">{sessionId}</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="session-details-sort-btn"
|
||||
onClick={() => setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))}
|
||||
>
|
||||
{sortOrder === "asc" ? <ArrowUp className="mr-2 h-4 w-4" /> : <ArrowDown className="mr-2 h-4 w-4" />}
|
||||
{sortOrder === "asc" ? "Earliest first" : "Latest first"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{summaryCards.map((card) => (
|
||||
<Card key={card.label} className="py-4 shadow-none">
|
||||
<CardContent className="px-4">
|
||||
<div className="text-muted-foreground text-xs">{card.label}</div>
|
||||
<div
|
||||
className={
|
||||
card.size === "sm"
|
||||
? "font-mono text-sm leading-5 break-words sm:text-base"
|
||||
: "truncate font-mono text-xl font-medium sm:text-2xl"
|
||||
}
|
||||
>
|
||||
{card.helper ? (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{card.value}</span>
|
||||
<span className="text-muted-foreground text-sm">{card.helper}</span>
|
||||
</div>
|
||||
) : (
|
||||
card.value
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-sm border">
|
||||
<Table containerClassName="h-full overflow-auto">
|
||||
<TableHeader className="sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a]">
|
||||
<TableRow>
|
||||
<TableHead className="w-2"></TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingSession && sessionLogs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading session...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sessionLogs.length ? (
|
||||
sessionLogs.map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn("cursor-pointer transition-colors", log.id === highlightedLogId ? HIGHLIGHTED_ROW : "hover:bg-muted/40")}
|
||||
onClick={() => onLogClick?.(log)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className={`h-6 w-1 rounded-sm ${StatusBarColors[log.status as Status]}`} />
|
||||
</TableCell>
|
||||
<TableCell className="relative text-xs">
|
||||
{log.id === highlightedLogId ? (
|
||||
<div className="bg-background pointer-events-none absolute -top-1.5 left-1 z-10 rounded-full border border-sky-400/45 px-1.5 py-0 text-[9px] leading-tight font-semibold tracking-wide text-sky-600 uppercase dark:text-sky-300">
|
||||
Current
|
||||
</div>
|
||||
) : null}
|
||||
{format(new Date(log.timestamp), "yyyy-MM-dd hh:mm:ss aa (XXX)")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`${RequestTypeColors[log.object as keyof typeof RequestTypeColors]} text-xs`}>
|
||||
{RequestTypeLabels[log.object as keyof typeof RequestTypeLabels]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[360px]">
|
||||
<LogMessageCell log={log} maxWidth="max-w-[360px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="font-mono text-xs uppercase">
|
||||
<RenderProviderIcon provider={log.provider as ProviderIconType} size="sm" />
|
||||
{log.provider as ProviderName}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate font-mono text-xs">{log.model || "N/A"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground h-24 text-center">
|
||||
No logs found for this session.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{hasMore ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="session-details-load-more-btn"
|
||||
onClick={() => loadSessionPage(fetchedCount)}
|
||||
disabled={loadingSession}
|
||||
>
|
||||
{loadingSession ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user