refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-24 18:43:18 +08:00
committed by GitHub
parent c7465625a4
commit 3f868ac385
50 changed files with 2391 additions and 1190 deletions
@@ -9,6 +9,7 @@
* 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 { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
@@ -60,10 +61,23 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
/**
* Error panel: stats + a recent-errors table (the server already takes the top N, newest first).
* The table is table-fixed with in-cell truncation: a long message doesn't break the layout, and the full text goes into title.
* The message column shows **one line per error by default** (kept compact — an error storm can
* fill the table); clicking a message expands it in place to the full text (wrapping, newlines
* preserved — the upstream detail after the code, e.g. a provider's 402 body, is what matters),
* 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 }) {
const { total, unexpected, topCode, recent } = errors;
// Message rows expanded to their full text (index into `recent`); one line each by default.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
return (
<div>
@@ -93,7 +107,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
<table className="w-full table-fixed text-xs">
<thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500">
<tr>
<Th className="w-32">{S.usage.errorsColTime}</Th>
<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 ·
tool_failed:exec_command), and truncating it would hide which tool failed. */}
@@ -110,21 +124,29 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
key={`${e.ts}-${i}`}
className="border-t border-gray-100 dark:border-gray-800/60"
>
<td className="py-1.5 pr-2 font-mono tabular-nums text-gray-400">
<td className="py-1.5 pr-2 align-top font-mono tabular-nums text-gray-400">
{formatDateTime(e.ts)}
</td>
<td className="py-1.5 pr-2 font-mono text-gray-500 dark:text-gray-400">
<span className="block truncate" title={`${e.source} · ${e.code}`}>
<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>
</td>
<td className="py-1.5 pr-2">
<td className="py-1.5 pr-2 align-top">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
</td>
<td className="py-1.5 text-gray-500 dark:text-gray-400">
<span className="block truncate" title={e.message}>
<td className="py-1.5 align-top text-gray-500 dark:text-gray-400">
{/* One line by default; click to expand to the full message (wrapping), click again to collapse. */}
<button
type="button"
title={e.message}
onClick={() => toggle(i)}
className={`block w-full cursor-pointer text-left transition-colors hover:text-gray-700 dark:hover:text-gray-300 ${
expanded.has(i) ? "whitespace-pre-wrap break-words" : "truncate"
}`}
>
{e.message}
</span>
</button>
</td>
</tr>
);
@@ -16,8 +16,8 @@ import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import { formatMoney, humanizeTokens } from "../../lib/format";
import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog";
@@ -81,7 +81,7 @@ function SummaryCard({
<SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted />
{/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */}
<SummaryRow
label={S.usage.colCost}
label={S.common.cost}
value={formatMoney(bucket.cost, currency)}
sup={bucket.hasUncosted}
/>
@@ -159,7 +159,7 @@ export function UsagePage() {
});
setData(res);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
setError(apiErrorText(e));
}
}, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]);