mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
567ebcb3e9
commit
d495da4ce4
6 changed files with 489 additions and 1 deletions
14
ui/litellm-dashboard/src/app/chat/logs/page.tsx
Normal file
14
ui/litellm-dashboard/src/app/chat/logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
104
ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx
Normal file
104
ui/litellm-dashboard/src/components/chat/LogsPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
348
ui/litellm-dashboard/src/components/chat/LogsPanel.tsx
Normal file
348
ui/litellm-dashboard/src/components/chat/LogsPanel.tsx
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue