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
19 changes: 18 additions & 1 deletion frontend/src/components/copy-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("CopyButton", () => {

expect(writeText).toHaveBeenCalledWith("secret-value");
expect(toastSuccess).toHaveBeenCalledWith("Copied to clipboard");
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy Copied" })).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(1_200);
Expand All @@ -64,4 +64,21 @@ describe("CopyButton", () => {

expect(toastError).toHaveBeenCalledWith("Failed to copy");
});

it("supports icon-only copy buttons with accessible labeling", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

render(<CopyButton value="secret-value" label="Copy Request ID" iconOnly />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy Request ID" }));
await Promise.resolve();
});

expect(writeText).toHaveBeenCalledWith("secret-value");
expect(screen.getByRole("button", { name: "Copy Request ID Copied" })).toBeInTheDocument();
});
});
16 changes: 12 additions & 4 deletions frontend/src/components/copy-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
export type CopyButtonProps = {
value: string;
label?: string;
iconOnly?: boolean;
};

export function CopyButton({ value, label = "Copy" }: CopyButtonProps) {
export function CopyButton({ value, label = "Copy", iconOnly = false }: CopyButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
Expand All @@ -24,9 +25,16 @@ export function CopyButton({ value, label = "Copy" }: CopyButtonProps) {
};

return (
<Button type="button" variant="outline" size="sm" onClick={handleCopy}>
{copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
{copied ? "Copied" : label}
<Button
type="button"
variant="outline"
size={iconOnly ? "icon-sm" : "sm"}
onClick={handleCopy}
aria-label={copied ? `${label} Copied` : label}
title={copied ? "Copied" : label}
>
{copied ? <Check className={iconOnly ? "h-4 w-4" : "mr-2 h-4 w-4"} /> : <Copy className={iconOnly ? "h-4 w-4" : "mr-2 h-4 w-4"} />}
{iconOnly ? null : copied ? "Copied" : label}
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { RecentRequestsTable } from "@/features/dashboard/components/recent-requests-table";

const ISO = "2026-01-01T12:00:00+00:00";

const { toastSuccess, toastError } = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));

vi.mock("sonner", () => ({
toast: {
success: toastSuccess,
error: toastError,
},
}));

const PAGINATION_PROPS = {
total: 1,
limit: 25,
Expand All @@ -16,9 +27,19 @@ const PAGINATION_PROPS = {
};

describe("RecentRequestsTable", () => {
it("renders rows with status badges and supports error expansion", async () => {
const user = userEvent.setup();
beforeEach(() => {
toastSuccess.mockReset();
toastError.mockReset();
});

it("renders rows with status badges and supports request details and copy actions", async () => {
const longError = "Rate limit reached while processing this request ".repeat(3);
const writeText = vi.fn().mockResolvedValue(undefined);

Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

render(
<RecentRequestsTable
Expand Down Expand Up @@ -63,13 +84,32 @@ describe("RecentRequestsTable", () => {
expect(screen.getByText("Requested priority")).toBeInTheDocument();
expect(screen.getByText("WS")).toBeInTheDocument();
expect(screen.getByText("Rate limit")).toBeInTheDocument();
expect(screen.getByText("rate_limit_exceeded")).toBeInTheDocument();

const viewButton = screen.getByRole("button", { name: "View" });
await user.click(viewButton);
const viewButton = screen.getByRole("button", { name: "View Details" });
fireEvent.click(viewButton);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(screen.getByText("Error Detail")).toBeInTheDocument();
expect(screen.getByText("Request Details")).toBeInTheDocument();
expect(screen.getByText("req-1")).toBeInTheDocument();
expect(screen.getAllByText("rate_limit_exceeded")[0]).toBeInTheDocument();
expect(dialog.textContent).toContain("Rate limit reached while processing this request");

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy Request ID" }));
await Promise.resolve();
});

expect(writeText).toHaveBeenCalledWith("req-1");
expect(toastSuccess).toHaveBeenCalledWith("Copied to clipboard");
expect(screen.getByRole("button", { name: "Copy Request ID Copied" })).toBeInTheDocument();

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy Error" }));
await Promise.resolve();
});

expect(writeText).toHaveBeenCalledWith(longError);
});

it("renders empty state", () => {
Expand Down Expand Up @@ -108,4 +148,40 @@ describe("RecentRequestsTable", () => {

expect(screen.getAllByText("--")[0]).toBeInTheDocument();
});

it("shows details action for error-code-only rows", async () => {
render(
<RecentRequestsTable
{...PAGINATION_PROPS}
accounts={[]}
requests={[
{
requestedAt: ISO,
accountId: "acc-legacy",
apiKeyName: null,
requestId: "req-error-code",
model: "gpt-5.1",
serviceTier: null,
requestedServiceTier: null,
actualServiceTier: null,
transport: "http",
status: "error",
errorCode: "upstream_error",
errorMessage: null,
tokens: 1,
cachedInputTokens: null,
reasoningEffort: null,
costUsd: 0,
latencyMs: 1,
},
]}
/>,
);

expect(screen.getAllByText("upstream_error")[0]).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "View Details" }));

expect(screen.getByRole("dialog")).toHaveTextContent("upstream_error");
expect(screen.getByRole("dialog")).toHaveTextContent("Full Error");
});
});
122 changes: 98 additions & 24 deletions frontend/src/features/dashboard/components/recent-requests-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Inbox } from "lucide-react";
import { useMemo, useState } from "react";

import { isEmailLabel } from "@/components/blur-email";
import { CopyButton } from "@/components/copy-button";
import { usePrivacyStore } from "@/hooks/use-privacy";
import { EmptyState } from "@/components/empty-state";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -70,7 +71,7 @@ export function RecentRequestsTable({
onLimitChange,
onOffsetChange,
}: RecentRequestsTableProps) {
const [viewingError, setViewingError] = useState<string | null>(null);
const [selectedRequest, setSelectedRequest] = useState<RequestLog | null>(null);
const blurred = usePrivacyStore((s) => s.blurred);

const accountLabelMap = useMemo(() => {
Expand Down Expand Up @@ -107,7 +108,7 @@ export function RecentRequestsTable({
<div className="space-y-3">
<div className="rounded-xl border bg-card">
<div className="relative overflow-x-auto">
<Table className="min-w-[1040px] table-fixed">
<Table className="min-w-[1160px] table-fixed">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-28 pl-4 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Time</TableHead>
Expand All @@ -118,16 +119,16 @@ export function RecentRequestsTable({
<TableHead className="w-24 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Status</TableHead>
<TableHead className="w-24 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Tokens</TableHead>
<TableHead className="w-16 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Cost</TableHead>
<TableHead className="w-28 pr-4 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Error</TableHead>
<TableHead className="w-72 pr-4 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((request) => {
const time = formatTimeLong(request.requestedAt);
const accountLabel = request.accountId ? (accountLabelMap.get(request.accountId) ?? request.accountId) : "—";
const isEmailLabel = !!(request.accountId && emailLabelIds.has(request.accountId));
const errorMessage = request.errorMessage || request.errorCode || "-";
const hasLongError = errorMessage !== "-" && errorMessage.length > 72;
const errorPreview = request.errorMessage || request.errorCode || "-";
const hasError = !!(request.errorCode || request.errorMessage);
const visibleServiceTier = request.actualServiceTier ?? request.serviceTier;
const showRequestedTier =
!!request.requestedServiceTier && request.requestedServiceTier !== visibleServiceTier;
Expand Down Expand Up @@ -195,23 +196,32 @@ export function RecentRequestsTable({
<TableCell className="text-right align-top font-mono text-xs tabular-nums">
{formatCurrency(request.costUsd)}
</TableCell>
<TableCell className="overflow-hidden pr-4 align-top">
<div className="flex items-center gap-1.5">
<p className="min-w-0 truncate text-xs text-muted-foreground">
{errorMessage}
</p>
{hasLongError ? (
<TableCell className="pr-4 align-top whitespace-normal">
{hasError ? (
<div className="space-y-2">
{request.errorCode ? (
<div>
<Badge variant="outline" className="max-w-full font-mono text-[10px]">
<span className="truncate">{request.errorCode}</span>
</Badge>
</div>
) : null}
<p className="line-clamp-2 break-words text-xs leading-relaxed text-muted-foreground">
{errorPreview}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 shrink-0 px-1.5 text-[11px]"
onClick={() => setViewingError(errorMessage)}
className="h-6 px-2 text-[11px]"
onClick={() => setSelectedRequest(request)}
>
View
View Details
</Button>
) : null}
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
);
Expand All @@ -232,20 +242,84 @@ export function RecentRequestsTable({
/>
</div>

<Dialog open={viewingError !== null} onOpenChange={(open) => { if (!open) setViewingError(null); }}>
<DialogContent className="max-h-[80vh] sm:max-w-lg">
<Dialog open={selectedRequest !== null} onOpenChange={(open) => { if (!open) setSelectedRequest(null); }}>
<DialogContent className="max-h-[85vh] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Error Detail</DialogTitle>
<DialogDescription>Full error message from the request.</DialogDescription>
<DialogTitle>Request Details</DialogTitle>
<DialogDescription>Inspect request metadata and copy the fields you need.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-y-auto rounded-md bg-muted/50 p-3">
<p className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{viewingError}
</p>
<div className="grid gap-4 overflow-y-auto">
<div className="space-y-3 rounded-md border bg-muted/30 p-4">
<RequestDetailField
label="Request ID"
value={selectedRequest?.requestId ?? "—"}
mono
copyValue={selectedRequest?.requestId ?? ""}
copyLabel="Copy Request ID"
compactCopy
/>
<div className="grid gap-3 sm:grid-cols-3">
<RequestDetailField label="Status" value={selectedRequest ? (REQUEST_STATUS_LABELS[selectedRequest.status] ?? selectedRequest.status) : "—"} />
<RequestDetailField label="Model" value={selectedRequest ? formatModelLabel(selectedRequest.model, selectedRequest.reasoningEffort, selectedRequest.actualServiceTier ?? selectedRequest.serviceTier) : "—"} mono />
<RequestDetailField label="Transport" value={selectedRequest?.transport ? (TRANSPORT_LABELS[selectedRequest.transport] ?? selectedRequest.transport) : "—"} />
<RequestDetailField label="Time" value={selectedRequest ? `${formatTimeLong(selectedRequest.requestedAt).time} ${formatTimeLong(selectedRequest.requestedAt).date}` : "—"} />
<RequestDetailField label="Error Code" value={selectedRequest?.errorCode ?? "—"} mono />
</div>
</div>

<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Full Error</h3>
{selectedRequest?.errorMessage ? (
<CopyButton value={selectedRequest.errorMessage} label="Copy Error" iconOnly />
) : null}
</div>
<div className="max-h-[36vh] overflow-y-auto rounded-md bg-muted/50 p-3">
<p className="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{selectedRequest?.errorMessage ?? selectedRequest?.errorCode ?? "No error detail recorded."}
</p>
</div>
</div>
</div>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
</div>
);
}

type RequestDetailFieldProps = {
label: string;
value: string;
mono?: boolean;
copyValue?: string;
copyLabel?: string;
compactCopy?: boolean;
};

function RequestDetailField({
label,
value,
mono = false,
copyValue,
copyLabel = "Copy",
compactCopy = false,
}: RequestDetailFieldProps) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
{label}
</div>
{copyValue ? (
<CopyButton value={copyValue} label={copyLabel} iconOnly={compactCopy} />
) : null}
</div>
<div className="flex flex-col items-start gap-2">
<p className={`min-w-0 flex-1 break-all text-sm leading-relaxed ${mono ? "font-mono" : ""}`}>
{value}
</p>
</div>
</div>
);
}
20 changes: 20 additions & 0 deletions openspec/changes/improve-request-log-error-details/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Why

The dashboard request logs table currently exposes error information in a way that is difficult to use during debugging. Long error messages are truncated into a narrow table cell, operators cannot reliably discover that more detail exists, and the UI does not provide a direct way to copy the error text or request identifier for follow-up investigation.

Request logs are an operator workflow, not decorative telemetry. When a request fails, the dashboard should make the failure easy to inspect, distinguish, and copy without forcing users to leave the page or guess at hidden affordances.

## What Changes

- Improve the dashboard request logs interaction model so error rows always expose a visible path to full request details.
- Add a request-detail surface for request-log rows that shows the full error code and error message alongside existing request metadata.
- Add copy actions for high-value debugging fields such as request id and full error text.
- Replace the current single-line error truncation with a richer preview that remains scannable in the table while preserving density.
- Preserve existing request-log filtering and pagination behavior while adding detail interactions.

## Impact

- Specs: `openspec/specs/frontend-architecture/spec.md`
- Frontend: dashboard recent-requests table, request-detail interaction state, copy affordances, accessibility and keyboard flow
- Backend: no API contract change required if the existing request-log payload remains sufficient
- Tests: frontend interaction coverage for request details, copy actions, and preview behavior
Loading
Loading