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:
1 parent
6b3574f5c9
commit
e0a47254af
17 files changed
+598
-41
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user