feat(chat-ui): add personal Logs view scoped to the current user (#33829)

* feat(chat-ui): add personal Logs view scoped to the current user

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(chat-ui): show request payload from proxy_server_request in logs detail

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(chat-ui): address logs panel review feedback (stable detail key, error state)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-07-18 14:58:12 -07:00 committed by GitHub
parent 567ebcb3e9
commit d495da4ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 489 additions and 1 deletions

View file

@ -0,0 +1,14 @@
"use client";
import { useChatShell } from "@/contexts/ChatShellContext";
import LogsPanel from "@/components/chat/LogsPanel";
export default function LogsPage() {
const { accessToken, userId } = useChatShell();
return (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
<LogsPanel accessToken={accessToken} userId={userId} />
</div>
);
}

View file

@ -28,6 +28,7 @@ describe("getChatRoutes under server_root_path", () => {
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
expect(routes.logs).toBe("/gw/ui/chat/logs");
expect(routes.usage).toBe("/gw/ui/chat/usage");
});

View file

@ -62,6 +62,20 @@ describe("ChatShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Usage" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/usage");
fireEvent.click(screen.getByRole("button", { name: "Logs" }));
expect(mockPush).toHaveBeenCalledWith("/ui/chat/logs");
});
it("marks Logs active on the logs route", () => {
mockUsePathname.mockReturnValue("/ui/chat/logs");
render(
<ChatShell>
<div />
</ChatShell>,
);
expect(screen.getByRole("button", { name: "Logs" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "Usage" })).not.toHaveAttribute("aria-current");
});
it("tolerates a trailing slash on the current pathname when matching the active route", () => {

View file

@ -2,7 +2,7 @@
import React from "react";
import { usePathname, useRouter } from "next/navigation";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3 } from "lucide-react";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { migratedHref } from "@/utils/migratedPages";
@ -16,6 +16,7 @@ export function getChatRoutes() {
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
logs: `${base}/logs`,
usage: `${base}/usage`,
};
}
@ -109,6 +110,12 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
onClick={() => router.push(routes.apiKeys)}
active={pathname === routes.apiKeys}
/>
<NavItem
icon={<ScrollText className="h-4 w-4" />}
label="Logs"
onClick={() => router.push(routes.logs)}
active={pathname === routes.logs}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"

View file

@ -0,0 +1,104 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LogsPanel from "./LogsPanel";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";
vi.mock("../networking", () => ({
uiSpendLogsCall: vi.fn(),
uiSpendLogDetailsCall: vi.fn(),
}));
const mockedLogsCall = vi.mocked(uiSpendLogsCall);
const mockedDetailsCall = vi.mocked(uiSpendLogDetailsCall);
const sampleRow = {
request_id: "req-abc-123",
model: "gpt-4o",
status: "success",
spend: 0.0123,
total_tokens: 1500,
prompt_tokens: 1000,
completion_tokens: 500,
startTime: "2026-07-18T10:00:00Z",
endTime: "2026-07-18T10:00:02Z",
request_duration_ms: 2000,
};
const paginated = (rows: unknown[]) => ({
data: rows,
total: rows.length,
page: 1,
page_size: 50,
total_pages: rows.length > 0 ? 1 : 0,
});
describe("LogsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedLogsCall.mockResolvedValue(paginated([sampleRow]));
mockedDetailsCall.mockResolvedValue({ messages: [{ role: "user", content: "hi" }], response: { ok: true } });
});
it("scopes the query to the current user so it only shows their own logs", async () => {
renderWithProviders(<LogsPanel accessToken="tok-scope" userId="user-42" />);
await waitFor(() => expect(mockedLogsCall).toHaveBeenCalled());
expect(mockedLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "tok-scope",
params: expect.objectContaining({ user_id: "user-42" }),
}),
);
});
it("renders a row for each returned log", async () => {
renderWithProviders(<LogsPanel accessToken="tok-rows" userId="user-1" />);
expect(await screen.findByText("gpt-4o")).toBeInTheDocument();
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(screen.getByText("Success")).toBeInTheDocument();
});
it("shows an empty state when there are no logs", async () => {
mockedLogsCall.mockResolvedValue(paginated([]));
renderWithProviders(<LogsPanel accessToken="tok-empty" userId="user-1" />);
expect(await screen.findByText("No logs for this period")).toBeInTheDocument();
});
it("opens the detail dialog and lazily loads request/response when a row is clicked", async () => {
renderWithProviders(<LogsPanel accessToken="tok-detail" userId="user-1" />);
const modelCell = await screen.findByText("gpt-4o");
expect(mockedDetailsCall).not.toHaveBeenCalled();
fireEvent.click(modelCell);
expect(await screen.findByText("Request details")).toBeInTheDocument();
await waitFor(() =>
expect(mockedDetailsCall).toHaveBeenCalledWith("tok-detail", "req-abc-123", expect.any(String)),
);
});
it("shows an error state (not the empty state) when the logs query fails", async () => {
mockedLogsCall.mockRejectedValue(new Error("boom"));
renderWithProviders(<LogsPanel accessToken="tok-err" userId="user-1" />);
expect(await screen.findByText("Failed to load your logs")).toBeInTheDocument();
expect(screen.queryByText("No logs for this period")).not.toBeInTheDocument();
});
it("falls back to proxy_server_request when messages is empty for the request payload", async () => {
mockedDetailsCall.mockResolvedValue({
messages: {},
proxy_server_request: { body: { messages: [{ role: "user", content: "hello from proxy" }] } },
response: { ok: true },
});
renderWithProviders(<LogsPanel accessToken="tok-fallback" userId="user-1" />);
fireEvent.click(await screen.findByText("gpt-4o"));
expect(await screen.findByText(/hello from proxy/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,348 @@
"use client";
import React, { useState } from "react";
import moment from "moment";
import { AlertCircle, ScrollText } from "lucide-react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
const LOGS_QUERY_KEY = "chat-user-logs";
const PAGE_SIZE = 50;
interface Props {
accessToken: string;
userId: string;
}
type TimeRange = "24h" | "7d" | "30d";
const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
];
function getStartMoment(range: TimeRange): moment.Moment {
if (range === "24h") return moment().subtract(24, "hours");
if (range === "7d") return moment().subtract(7, "days");
return moment().subtract(30, "days");
}
interface LogRow {
request_id: string;
model: string;
custom_llm_provider?: string;
status?: string;
spend: number;
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
startTime: string;
endTime: string;
request_duration_ms?: number;
}
interface PaginatedLogs {
data: LogRow[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
interface LogDetails {
messages?: unknown;
response?: unknown;
proxy_server_request?: unknown;
}
function formatTokens(n: number): string {
return (n ?? 0).toLocaleString();
}
function formatCost(spend: number): string {
const value = spend ?? 0;
if (value === 0) return "$0";
if (value < 0.01) return `$${value.toFixed(6)}`;
return `$${value.toFixed(4)}`;
}
function durationMs(row: LogRow): number | null {
if (row.request_duration_ms != null) return row.request_duration_ms;
if (row.startTime && row.endTime) return Date.parse(row.endTime) - Date.parse(row.startTime);
return null;
}
function formatDuration(row: LogRow): string {
const ms = durationMs(row);
if (ms == null || Number.isNaN(ms)) return "-";
return `${(ms / 1000).toFixed(2)}s`;
}
function StatusBadge({ status }: { status?: string }) {
const isFailure = status === "failure";
return (
<span
className={`inline-flex items-center gap-1.5 text-xs ${
isFailure ? "text-red-600 dark:text-red-400" : "text-emerald-600 dark:text-emerald-400"
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${isFailure ? "bg-red-500" : "bg-emerald-500"}`} />
{isFailure ? "Failure" : "Success"}
</span>
);
}
function JsonBlock({ value }: { value: unknown }) {
if (value == null || value === "") {
return <p className="m-0 text-xs text-muted-foreground">Not available</p>;
}
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return (
<pre className="m-0 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-muted/50 p-3 font-mono text-xs">
{text}
</pre>
);
}
function LogsSkeleton() {
return (
<div className="overflow-hidden rounded-lg border">
<div className="flex flex-col gap-px">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}
function LogsEmpty() {
return (
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
<ScrollText className="mx-auto mb-3 h-6 w-6 text-muted-foreground/50" />
No logs for this period
</div>
);
}
function LogsError({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
<AlertCircle className="h-6 w-6 text-destructive/70" />
Failed to load your logs
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
function LogsTable({ rows, onRowClick }: { rows: LogRow[]; onRowClick: (row: LogRow) => void }) {
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Time</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Model</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Status</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Tokens</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Duration</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.request_id} className="cursor-pointer" onClick={() => onRowClick(row)}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{moment(row.startTime).format("MMM D, HH:mm:ss")}
</TableCell>
<TableCell className="text-sm">{row.model || "-"}</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{formatTokens(row.total_tokens)}</TableCell>
<TableCell className="text-right text-sm tabular-nums text-muted-foreground">
{formatDuration(row)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{formatCost(row.spend)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function LogDetailDialog({
log,
details,
isLoading,
onClose,
}: {
log: LogRow | null;
details: LogDetails | undefined;
isLoading: boolean;
onClose: () => void;
}) {
return (
<Dialog open={!!log} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Request details</DialogTitle>
<DialogDescription className="break-all font-mono text-xs">{log?.request_id}</DialogDescription>
</DialogHeader>
{log && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Model</div>
<div className="text-sm text-foreground">{log.model || "-"}</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Cost</div>
<div className="text-sm text-foreground">{formatCost(log.spend)}</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Tokens</div>
<div className="text-sm text-foreground">
{formatTokens(log.total_tokens)} ({formatTokens(log.prompt_tokens)} in /{" "}
{formatTokens(log.completion_tokens)} out)
</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Duration</div>
<div className="text-sm text-foreground">{formatDuration(log)}</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Request</div>
{isLoading ? (
<Skeleton className="h-16 w-full" />
) : (
<JsonBlock value={details?.proxy_server_request ?? details?.messages} />
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Response</div>
{isLoading ? <Skeleton className="h-16 w-full" /> : <JsonBlock value={details?.response} />}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
const LogsPanel: React.FC<Props> = ({ accessToken, userId }) => {
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [page, setPage] = useState(1);
const [selectedLog, setSelectedLog] = useState<LogRow | null>(null);
const startDate = getStartMoment(timeRange).utc().format("YYYY-MM-DD HH:mm:ss");
const endDate = moment().utc().format("YYYY-MM-DD HH:mm:ss");
const logsCallOptions = {
accessToken,
start_date: startDate,
end_date: endDate,
page,
page_size: PAGE_SIZE,
params: { user_id: userId, sort_by: "startTime", sort_order: "desc" as const },
};
const logsQueryOptions = {
queryKey: [LOGS_QUERY_KEY, accessToken, userId, timeRange, page],
queryFn: () => uiSpendLogsCall(logsCallOptions),
enabled: !!accessToken && !!userId,
placeholderData: keepPreviousData,
};
const { data, isLoading, isError, refetch } = useQuery(logsQueryOptions);
const logs = data as PaginatedLogs | undefined;
const rows = logs?.data ?? [];
const totalPages = logs?.total_pages ?? 0;
const total = logs?.total ?? 0;
const detailStartDate = selectedLog ? moment(selectedLog.startTime).utc().format("YYYY-MM-DD HH:mm:ss") : "";
const { data: detailData, isLoading: isDetailLoading } = useQuery({
queryKey: [LOGS_QUERY_KEY, "detail", accessToken, selectedLog?.request_id, selectedLog?.startTime],
queryFn: () => uiSpendLogDetailsCall(accessToken, selectedLog!.request_id, detailStartDate),
enabled: !!accessToken && !!selectedLog,
});
const details = detailData as LogDetails | undefined;
const renderBody = () => {
if (isLoading) return <LogsSkeleton />;
if (isError) return <LogsError onRetry={() => refetch()} />;
if (rows.length === 0) return <LogsEmpty />;
return (
<>
<LogsTable rows={rows} onRowClick={setSelectedLog} />
<div className="mt-3 flex items-center justify-between">
<p className="m-0 text-xs text-muted-foreground">
{total.toLocaleString()} request{total === 1 ? "" : "s"}
{totalPages > 1 ? ` · Page ${page} of ${totalPages}` : ""}
</p>
{totalPages > 1 && (
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
)}
</div>
</>
);
};
return (
<div className="w-full">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="mb-0.5 text-base font-semibold tracking-tight text-foreground">Your Logs</h2>
<p className="m-0 text-sm text-muted-foreground">Request logs for your account only</p>
</div>
<div className="flex gap-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={timeRange === opt.value ? "default" : "outline"}
size="sm"
onClick={() => {
setTimeRange(opt.value);
setPage(1);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
{renderBody()}
<LogDetailDialog
log={selectedLog}
details={details}
isLoading={isDetailLoading}
onClose={() => setSelectedLog(null)}
/>
</div>
);
};
export default LogsPanel;