Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/dashboard/src/pages/Insights.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/services/insightsService", () => ({
insightsService: {
getSummary: vi.fn(),
getTimeseries: vi.fn(),
getLogs: vi.fn(),
getFilters: vi.fn(),
getTopIps: vi.fn(),
getTopModels: vi.fn(),
},
}));

// The charts are lazy and need a real layout; they are not under test here.
vi.mock("@/components/insights/InsightsCharts", () => ({
RequestVolumeChart: () => null,
TokenUsageChart: () => null,
ModelDistributionChart: () => null,
TrafficByIpChart: () => null,
}));

import { insightsService } from "@/services/insightsService";
import Insights from "./Insights";

const service = insightsService as unknown as Record<string, ReturnType<typeof vi.fn>>;
const REQUEST_ID = "3f9a1c0e-7b2d-4a6f-8e1c-3b5a7d9f0e2c";

function log(requestId: string | null) {
return {
id: `log-${requestId}`, deployment_id: "dep-12345678", user_id: "apikey:k1", model: "qwen",
ip_address: "10.0.0.1", request_id: requestId, request_payload: null, latency_ms: 120,
ttft_ms: null, tokens_per_second: null, prompt_tokens: 10, completion_tokens: 5,
total_tokens: 15, status_code: 200, error_message: null, is_streaming: false,
applied_policies: null, created_at: "2026-10-26T10:00:00Z",
};
}

function renderPage(url = "/dashboard/insights") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[url]}>
<Insights />
</MemoryRouter>
</QueryClientProvider>,
);
}

beforeEach(() => {
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as never;
const totals = { requests: 2, successful_requests: 2, failed_requests: 0, success_rate: 100,
prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 };
service.getSummary.mockResolvedValue({ totals, latency_ms: { avg: 120, samples: 2 },
ttft_ms: { avg: 0, samples: 0 }, throughput: { requests_per_minute: 1, tokens_per_second: 1, avg_tokens_per_second: 1 } });
service.getTimeseries.mockResolvedValue({ granularity: "day", buckets: [] });
service.getFilters.mockResolvedValue({ deployments: [], models: [], ip_addresses: [], status_options: [], deployment_types: [] });
service.getTopIps.mockResolvedValue({ items: [] });
service.getTopModels.mockResolvedValue({ items: [] });
service.getLogs.mockResolvedValue({ items: [log(REQUEST_ID), log(null)], pagination: { limit: 25, offset: 0, total: 2 } });
});

afterEach(() => vi.clearAllMocks());

describe("Insights request ID", () => {
it("shows each log's request ID, shortened, with the full ID on hover", async () => {
renderPage();
const cell = await screen.findByTitle(REQUEST_ID);
expect(cell).toHaveTextContent("3f9a1c0e-7b2…");
expect(screen.getByRole("columnheader", { name: "Request ID" })).toBeInTheDocument();
});

it("searches the logs by request ID, and only the logs", async () => {
renderPage();
await screen.findByTitle(REQUEST_ID);
await userEvent.type(screen.getByLabelText("Find a request by its ID"), "trace-42");
await waitFor(() =>
expect(service.getLogs).toHaveBeenLastCalledWith(expect.objectContaining({ request_id: "trace-42", offset: 0 })));
for (const call of service.getSummary.mock.calls) expect(call[0]).not.toHaveProperty("request_id");
});

it("opens with the search from the link, and says when nothing matches", async () => {
service.getLogs.mockResolvedValue({ items: [], pagination: { limit: 25, offset: 0, total: 0 } });
renderPage("/dashboard/insights?requestId=trace-404");
expect(await screen.findByText("No log with this request ID in the selected time range.")).toBeInTheDocument();
expect(screen.getByLabelText("Find a request by its ID")).toHaveValue("trace-404");
expect(service.getLogs).toHaveBeenCalledWith(expect.objectContaining({ request_id: "trace-404" }));
});
});
67 changes: 50 additions & 17 deletions apps/dashboard/src/pages/Insights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type State = {
customEnd: string;
deploymentId: string;
ipAddress: string;
requestId: string;
status: InsightsStatus;
page: number;
pageSize: number;
Expand All @@ -89,6 +90,7 @@ const createInitialState = (defaultStart: Date, now: Date): State => ({
customEnd: toLocalDateTimeInputValue(now),
deploymentId: "",
ipAddress: "",
requestId: "",
status: "all",
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
Expand Down Expand Up @@ -119,6 +121,7 @@ function createInitialStateFromParams(searchParams: URLSearchParams, defaultStar
customEnd: searchParams.get("customEnd") || baseState.customEnd,
deploymentId: searchParams.get("deploymentId") || "",
ipAddress: searchParams.get("ipAddress") || "",
requestId: searchParams.get("requestId") || "",
status: status ? (status as InsightsStatus) : baseState.status,
page,
pageSize: PAGE_SIZE_OPTIONS.includes(parsedPageSize) ? parsedPageSize : DEFAULT_PAGE_SIZE,
Expand All @@ -132,7 +135,7 @@ function reducer(state: State, action: Action): State {
case 'SET_DEPLOYMENT_TYPE':
return { ...state, deploymentType: action.payload, deploymentId: "", page: 1 };
case 'CLEAR_FILTERS':
return { ...state, deploymentId: "", ipAddress: "", status: "all", page: 1 };
return { ...state, deploymentId: "", ipAddress: "", requestId: "", status: "all", page: 1 };
default:
return state;
}
Expand All @@ -150,7 +153,7 @@ export default function Insights() {
);
const {
deploymentType, timePreset, customStart, customEnd,
deploymentId, ipAddress, status, page, pageSize
deploymentId, ipAddress, requestId, status, page, pageSize
} = state;

useEffect(() => {
Expand All @@ -170,16 +173,18 @@ export default function Insights() {
setOrDelete("customEnd", timePreset === "custom" ? customEnd : null);
setOrDelete("deploymentId", deploymentId || null);
setOrDelete("ipAddress", ipAddress.trim() || null);
setOrDelete("requestId", requestId.trim() || null);
setOrDelete("status", status === "all" ? null : status);
setOrDelete("page", page > 1 ? String(page) : null);
setOrDelete("pageSize", pageSize !== DEFAULT_PAGE_SIZE ? String(pageSize) : null);

if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
}, [deploymentType, timePreset, customStart, customEnd, deploymentId, ipAddress, status, page, pageSize, searchParams, setSearchParams]);
}, [deploymentType, timePreset, customStart, customEnd, deploymentId, ipAddress, requestId, status, page, pageSize, searchParams, setSearchParams]);

const debouncedIpAddress = useDebouncedValue(ipAddress.trim(), IP_DEBOUNCE_DELAY);
const debouncedRequestId = useDebouncedValue(requestId.trim(), IP_DEBOUNCE_DELAY);

const range = useMemo(() => {
const end = new Date();
Expand Down Expand Up @@ -307,14 +312,17 @@ export default function Insights() {
baseParams.end_time,
deploymentId,
debouncedIpAddress,
debouncedRequestId,
status,
page,
pageSize,
deploymentType,
],
// Only the logs list can be searched by request ID; the totals and charts cannot.
queryFn: () =>
insightsService.getLogs({
...baseParams,
request_id: debouncedRequestId || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
}),
Expand Down Expand Up @@ -395,17 +403,18 @@ export default function Insights() {
logsQuery.isLoading;
const hasNoData = (summary?.totals.requests ?? 0) === 0 && !isInitialLoading;

const hasActiveFilters = deploymentId !== "" || ipAddress !== "" || status !== "all";
const hasActiveFilters = deploymentId !== "" || ipAddress !== "" || requestId !== "" || status !== "all";

const clearFilters = useCallback(() => {
dispatch({ type: 'CLEAR_FILTERS' });
}, []);

const exportLogsToCSV = useCallback(() => {
if (!logs?.items.length) return;
const headers = ["Timestamp", "Deployment ID", "Model", "IP Address", "Prompt Tokens", "Completion Tokens", "Total Tokens", "Latency (ms)", "Status"];
const headers = ["Timestamp", "Request ID", "Deployment ID", "Model", "IP Address", "Prompt Tokens", "Completion Tokens", "Total Tokens", "Latency (ms)", "Status"];
const rows = logs.items.map((log) => [
log.created_at,
log.request_id || "",
log.deployment_id,
log.model,
log.ip_address || "",
Expand Down Expand Up @@ -836,23 +845,41 @@ export default function Insights() {
}
</p>
</div>
{logs?.items.length ? (
<button
type="button"
onClick={exportLogsToCSV}
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted transition-colors"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</button>
) : null}
<div className="flex items-center gap-2">
<input
id="insight-request-id"
aria-label="Find a request by its ID"
type="search"
value={requestId}
onChange={(e) => {
dispatch({ type: 'SET_FIELD', field: 'requestId', value: e.target.value });
dispatch({ type: 'SET_FIELD', field: 'page', value: 1 });
}}
placeholder="Find request ID"
maxLength={128}
autoComplete="off"
spellCheck={false}
className="h-8 w-56 rounded-md border bg-background px-3 text-xs font-mono"
/>
{logs?.items.length ? (
<button
type="button"
onClick={exportLogsToCSV}
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted transition-colors"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</button>
) : null}
</div>
</div>

<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-medium">Timestamp</th>
<th className="px-4 py-3 text-left font-medium">Request ID</th>
<th className="px-4 py-3 text-left font-medium">Deployment</th>
<th className="px-4 py-3 text-left font-medium">Model</th>
<th className="px-4 py-3 text-left font-medium">IP</th>
Expand All @@ -867,6 +894,10 @@ export default function Insights() {
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">
{new Date(log.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-xs font-mono" title={log.request_id || undefined}>
{!log.request_id ? "-"
: log.request_id.length > 14 ? `${log.request_id.slice(0, 12)}…` : log.request_id}
</td>
<td className="px-4 py-3 text-xs font-mono">
{String(log.deployment_id).slice(0, 8)}…
</td>
Expand Down Expand Up @@ -899,8 +930,10 @@ export default function Insights() {
))}
{!logsQuery.isLoading && (logs?.items.length || 0) === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
No logs found for the selected filters.
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
{debouncedRequestId
? "No log with this request ID in the selected time range."
: "No logs found for the selected filters."}
</td>
</tr>
)}
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/services/insightsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface InferenceLogItem {
user_id: string;
model: string;
ip_address: string | null;
request_id: string | null;
request_payload: Record<string, unknown> | null;
latency_ms: number | null;
ttft_ms: number | null;
Expand Down Expand Up @@ -130,6 +131,7 @@ export interface InsightsTopModelsResponse {
export interface InsightsLogsQueryParams extends InsightsQueryParams {
limit?: number;
offset?: number;
request_id?: string;
}

export interface InsightsTimeseriesQueryParams extends InsightsQueryParams {
Expand Down
Loading