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:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user