feat(server,web): page the cost center's error table, abbreviate the source, drop command-tool noise (#103)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 5 authored and GitHub committed 2026-07-29 16:16:15 +08:00
1 parent 6b3574f5c9
commit e0a47254af
17 files changed
+598 -41

No files matched your search

+21
View File
@@ -63,6 +63,7 @@ import type {
UiPrefs,
UpdateCheckResponse,
UpdateRunResponse,
UsageErrorsPage,
UsageGroupBy,
UsageResponse,
VaultResponse,
@@ -360,6 +361,26 @@ export const importAgentTrace = (projectId: string, agentId: string, body: Trace
// Usage statistics ----------------------------------------------------------------------
/**
* One page of the cost center's error table (newest first). The dashboard response already
* carries the first page; this is for paging back to earlier ones without refetching the
* whole aggregate. Takes the dashboard's date/agent filter only — the model filter never
* applied to errors.
*/
export const getUsageErrors = (
projectId: string,
params: { offset: number; limit: number; from?: string; to?: string; agentId?: string },
) =>
apiFetch<UsageErrorsPage>(`/api/projects/${encodeURIComponent(projectId)}/usage/errors`, {
query: {
offset: String(params.offset),
limit: String(params.limit),
from: params.from,
to: params.to,
agentId: params.agentId,
},
});
export const getUsage = (
projectId: string,
params: {
+137 -11
View File
@@ -9,9 +9,11 @@
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
*/
import { useState } from "react";
import type { UsageErrors } from "@prismshadow/penguin-server/api";
import { useEffect, useState } from "react";
import type { UsageErrorItem, UsageErrors } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatDateTime } from "../../lib/format";
import { Badge } from "../../components/ui/badge";
import { Empty } from "./usage-charts";
@@ -28,6 +30,18 @@ function kindOf(kind: string): ErrorKindKey {
return kind === "unexpected" ? "unexpected" : "expected";
}
/**
* Source labels are abbreviated so the bracket stays narrow beside a long code:
* `[env] tool_failed:exec_command`, `[http] password`. Only `environment` needs shortening
* -- every other source is already short enough to read at a glance.
*/
const SOURCE_ABBREV: Readonly<Record<string, string>> = { environment: "env" };
/** `[env] tool_failed:read_file` -- the bracketed source followed by the raw error code. */
function sourceCode(source: string, code: string): string {
return `[${SOURCE_ABBREV[source] ?? source}] ${code}`;
}
/** A single small stat: name + value, one row side by side (not turned into a chart). */
function Stat({
label,
@@ -67,10 +81,79 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
* and clicking again collapses it. The full text is also in the hover title. Cells align to the
* top so an expanded multi-line message keeps the row tidy; the table scrolls past max height.
*/
export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
export function ErrorsPanel({
errors,
projectId,
filters,
}: {
errors: UsageErrors;
projectId: string;
/**
* The dashboard's own date/agent filter — a page must never widen what the summary counted.
* Memoize it at the call site: the fetch effect depends on the object's identity.
*/
filters: { from?: string; to?: string; agentId?: string };
}) {
const { total, unexpected, topCode, recent } = errors;
// Message rows expanded to their full text (index into `recent`); one line each by default.
// Page size is read off the first page rather than duplicating the server's ERROR_RECENT_N:
// whenever a second page exists at all, `recent` is exactly that many rows, so the two cannot
// drift apart into skipping or repeating rows. (Empty means a single empty page anyway.)
const pageSize = Math.max(1, recent.length);
// Paging: page 0 is the `recent` the dashboard response already carried, so it costs no
// request; later pages are fetched on demand.
const [page, setPage] = useState(0);
const [items, setItems] = useState<UsageErrorItem[]>(recent);
// The row count the pager counts pages against: seeded from the dashboard snapshot, then
// replaced by each page's own total. The snapshot goes stale (rows evicted by the row cap, an
// Agent deleted between load and click), and pinning to it computes a page count that can
// strand the caller on a page the data no longer has.
const [pagedTotal, setPagedTotal] = useState(total);
const [pageError, setPageError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// A new dashboard response invalidates the offsets being paged through, so paging goes back to
// page 0 — whose rows the effect below restores from that same response. Today the panel is
// remounted on every filter change, which resets this anyway; the reset does not rely on that.
useEffect(() => setPage(0), [recent]);
// Every value this effect reads is a dep, the page-0 branch's included: one left out would be
// served from a stale closure, and the repo has no lint rule that would catch it.
useEffect(() => {
// Page 0 is restored, never fetched — the dashboard response already carries it. Restoring
// is the whole point of the early return: leaving `items` untouched would keep the previous
// page's rows and its error on screen under a "page 1" label, with no way out short of
// changing a filter.
if (page === 0) {
setItems(recent);
setPagedTotal(total);
setPageError(null);
return;
}
let cancelled = false;
setLoading(true);
setPageError(null);
api
.getUsageErrors(projectId, { offset: page * pageSize, limit: pageSize, ...filters })
.then((res) => {
if (cancelled) return;
setItems(res.items);
setPagedTotal(res.total);
})
.catch((e: unknown) => {
// Keep the rows already on screen rather than blanking the table under an error.
if (!cancelled) setPageError(apiErrorText(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [page, pageSize, projectId, filters, recent, total]);
const pageCount = Math.max(1, Math.ceil(pagedTotal / pageSize));
// Message rows expanded to their full text (index into the current page); one line each by default.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
useEffect(() => setExpanded(new Set()), [items]);
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
@@ -94,13 +177,13 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
{topCode && (
<Stat
label={S.usage.errorsTopCode}
value={`${topCode.source} · ${topCode.code} ×${topCode.count}`}
value={`${sourceCode(topCode.source, topCode.code)} ×${topCode.count}`}
/>
)}
</div>
{/* Recent-errors table */}
{recent.length === 0 ? (
{items.length === 0 ? (
<Empty text={S.usage.errorsEmpty} />
) : (
<div className="mt-2.5 max-h-72 overflow-y-auto border-t border-gray-200 dark:border-gray-800">
@@ -109,7 +192,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
<tr>
<Th className="w-32">{S.common.time}</Th>
{/* Wide enough to fully fit the longest error code: a tool
failure's code carries the tool name (e.g. environment ·
failure's code carries the tool name (e.g. [env]
tool_failed:exec_command), and truncating it would hide which tool failed. */}
<Th className="w-72">{S.usage.errorsColCode}</Th>
<Th className="w-20">{S.usage.errorsColKind}</Th>
@@ -117,7 +200,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
</tr>
</thead>
<tbody>
{recent.map((e, i) => {
{items.map((e, i) => {
const key = kindOf(e.kind);
return (
<tr
@@ -128,9 +211,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
{formatDateTime(e.ts)}
</td>
<td className="py-1.5 pr-2 align-top font-mono text-gray-500 dark:text-gray-400">
<span className="block break-words">
{e.source} · {e.code}
</span>
<span className="block break-words">{sourceCode(e.source, e.code)}</span>
</td>
<td className="py-1.5 pr-2 align-top">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
@@ -155,6 +236,51 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
</table>
</div>
)}
{/* Pager: once there is more than one page — and unconditionally while paged away from
the first, so a later page that came back empty or shrank below the page count still
offers the way back instead of stranding the reader on a bare table. Kept outside the
scroll box so it stays reachable without scrolling to the bottom of the rows. */}
{(pageCount > 1 || page > 0) && (
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400">
{pageError !== null && <span className="mr-auto text-rose-500">{pageError}</span>}
<span className="tabular-nums">
{S.usage.errorsPageOf(page + 1, pageCount, pagedTotal)}
</span>
<PagerButton
label={S.usage.errorsNewer}
disabled={page === 0 || loading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
/>
<PagerButton
label={S.usage.errorsOlder}
disabled={page + 1 >= pageCount || loading}
onClick={() => setPage((p) => p + 1)}
/>
</div>
)}
</div>
);
}
/** Pager step button: same recessive treatment as the rest of the panel's chrome. */
function PagerButton({
label,
disabled,
onClick,
}: {
label: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="rounded-md border border-gray-200 px-2 py-0.5 transition-colors duration-150 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-gray-800 dark:hover:bg-gray-800/60 dark:disabled:hover:bg-transparent"
>
{label}
</button>
);
}
+11 -2
View File
@@ -12,7 +12,7 @@
* (stats + a recent-errors table).
* Currency follows the user's settings; a row with unconfigured pricing shows its cost as "—".
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
@@ -168,6 +168,15 @@ export function UsagePage() {
void load();
}, [load]);
// The error panel pages against this filter and depends on the object's identity, so it is
// built once per filter value rather than fresh on every render — a new object each render
// would refetch the current page on any unrelated state change (hovering a chart bucket).
// The model filter is deliberately absent: it never applied to errors.
const errorFilters = useMemo(
() => ({ from, to, ...(agentFilter ? { agentId: agentFilter } : {}) }),
[from, to, agentFilter],
);
if (!projectId) return null;
// Model filter candidates and the currently selected item's index (the option value uses the index, avoiding concatenating an id as the key).
@@ -294,7 +303,7 @@ export function UsagePage() {
{/* Errors (a single full-width panel: stats + a recent-errors table) */}
{data && (
<ChartCard title={S.usage.errors}>
<ErrorsPanel errors={data.errors} />
<ErrorsPanel errors={data.errors} projectId={projectId} filters={errorFilters} />
</ChartCard>
)}
+5
View File
@@ -868,6 +868,11 @@ When done, open index.html in a browser and self-test once.`,
errorsColKind: "Type",
errorsColMessage: "Message",
errorsEmpty: "No errors",
/** Detail-table pager: newer/older step back through pages of the same filtered set. */
errorsNewer: "Newer",
errorsOlder: "Older",
errorsPageOf: (page: number, pages: number, total: number) =>
`Page ${page} / ${pages} · ${total} total`,
},
traces: {
+5
View File
@@ -846,6 +846,11 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
errorsColKind: "类型",
errorsColMessage: "消息",
errorsEmpty: "暂无异常",
/** Detail-table pager: newer/older step back through pages of the same filtered set. */
errorsNewer: "较新",
errorsOlder: "更早",
errorsPageOf: (page: number, pages: number, total: number) =>
`第 ${page} / ${pages} 页 · 共 ${total} 条`,
},
traces: {