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
383 changes: 102 additions & 281 deletions gui/src/pages/Debug.tsx

Large diffs are not rendered by default.

322 changes: 52 additions & 270 deletions gui/src/pages/Startup.tsx

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions gui/src/pages/Storage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n, type TFn, type TKey, type Locale } from "../i18n/shared";
import { EmptyState } from "../ui";
import { IconRefresh } from "../icons";
Expand Down Expand Up @@ -115,20 +115,24 @@ export default function Storage({ apiBase }: { apiBase: string }) {
const { t, locale } = useI18n();
const [data, setData] = useState<StorageReport | null>(null);
const [loading, setLoading] = useState(true);
const loadGenerationRef = useRef(0);

const fetchStorage = useCallback(async (signal?: AbortSignal) => {
const generation = ++loadGenerationRef.current;
setLoading(true);
try {
const res = await fetch(`${apiBase}/api/storage`, { signal });
if (!res.ok) throw new Error("fetch failed");
const json = await res.json() as StorageReport;
if (signal?.aborted) return;
if (signal?.aborted || generation !== loadGenerationRef.current) return;
setData(json);
} catch {
if (signal?.aborted) return;
if (signal?.aborted || generation !== loadGenerationRef.current) return;
setData(null);
} finally {
if (!signal?.aborted) setLoading(false);
// Only the current request may clear loading — a superseded abort must not
// settle the UI while a newer fetch is still in flight.
if (generation === loadGenerationRef.current) setLoading(false);
}
}, [apiBase]);

Expand All @@ -140,6 +144,9 @@ export default function Storage({ apiBase }: { apiBase: string }) {
}, 0);
return () => {
window.clearTimeout(timeout);
// Invalidate before abort so a superseded request's finally cannot clear
// loading in the gap before the deferred replacement increments generation.
loadGenerationRef.current += 1;
controller.abort();
};
}, [fetchStorage]);
Expand Down
28 changes: 14 additions & 14 deletions gui/src/pages/Usage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n, type TFn, type Locale } from "../i18n/shared";
import { formatTokens } from "../format-tokens";
import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters";
import { EmptyState, Notice } from "../ui";
import { modelLabel } from "../model-display";

Expand Down Expand Up @@ -84,14 +85,6 @@ function formatPct(ratio: number): string {
return `${Math.round(ratio * 100)}%`;
}

function formatEstimatedUsdValue(value: number, locale: Locale): string {
if (!Number.isFinite(value) || value < 0) return "\u2014";
return `~$${new Intl.NumberFormat(locale, {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
}).format(value)}`;
}

// Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color
// across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart.
function modelColor(model: string, provider: string): string {
Expand Down Expand Up @@ -292,7 +285,7 @@ function UsageSummaryCards({
<div className="usage-cost-row" role="note">
<span className="muted">{t("usage.cost.total")}</span>
<span className="stat-value mono usage-cost-value">
{formatEstimatedUsdValue(summary.estimatedCostUsd, locale)}
{formatUsdEstimate(summary.estimatedCostUsd, locale)}
</span>
<span className="muted text-caption">{t("usage.cost.disclaimer")}</span>
{((summary.unpricedRequests ?? 0) + (summary.unmeteredRequests ?? 0)) > 0 && (
Expand Down Expand Up @@ -592,23 +585,27 @@ export default function Usage({ apiBase }: { apiBase: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modelQuery, setModelQuery] = useState("");
const loadGenerationRef = useRef(0);

const fetchUsage = useCallback(async (nextRange: Range, nextSurface: UsageSurface, signal: AbortSignal) => {
const generation = ++loadGenerationRef.current;
setLoading(true);
setError(null);
try {
const res = await fetch(`${apiBase}/api/usage?range=${nextRange}&surface=${nextSurface}`, { signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim());
const json = await res.json() as UsageResponse;
if (signal.aborted) return;
if (signal.aborted || generation !== loadGenerationRef.current) return;
setData(json);
} catch (cause) {
// A stale request (range/apiBase changed, or unmount) must not overwrite newer state.
if (signal.aborted) return;
if (signal.aborted || generation !== loadGenerationRef.current) return;
const detail = cause instanceof Error ? cause.message : "";
setError(detail ? `${t("usage.loadError")} ${detail}` : t("usage.loadError"));
} finally {
if (!signal.aborted) setLoading(false);
// Only the current request may clear loading — a superseded abort must not
// settle the UI while a newer fetch is still in flight.
if (generation === loadGenerationRef.current) setLoading(false);
}
}, [apiBase, t]);

Expand All @@ -619,6 +616,9 @@ export default function Usage({ apiBase }: { apiBase: string }) {
}, 0);
return () => {
window.clearTimeout(timeout);
// Invalidate before abort so a superseded request's finally cannot clear
// loading in the gap before the deferred replacement increments generation.
loadGenerationRef.current += 1;
controller.abort();
};
}, [fetchUsage, range, surface]);
Expand All @@ -629,7 +629,7 @@ export default function Usage({ apiBase }: { apiBase: string }) {
const filteredModels = useMemo(() => {
const q = modelQuery.trim().toLowerCase();
const models = data?.models ?? [];
const sorted = [...models].sort((a, b) => b.totalTokens - a.totalTokens);
const sorted = models.toSorted((a, b) => b.totalTokens - a.totalTokens);
if (!q) return sorted.slice(0, 100);
return sorted.filter(m =>
m.model.toLowerCase().includes(q) ||
Expand All @@ -639,7 +639,7 @@ export default function Usage({ apiBase }: { apiBase: string }) {
}, [data?.models, modelQuery]);

const sortedProviders = useMemo(() =>
[...(data?.providers ?? [])].sort((a, b) => b.totalTokens - a.totalTokens),
(data?.providers ?? []).toSorted((a, b) => b.totalTokens - a.totalTokens),
[data?.providers],
);

Expand Down
58 changes: 58 additions & 0 deletions gui/src/pages/debug-claude-inbound-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useI18n } from "../i18n/shared";
import type { ClaudeInboundEntry } from "./debug-shared";
import { formatClaudeInboundTime } from "./debug-shared";

export function DebugClaudeInboundPanel({ entries }: { entries: ClaudeInboundEntry[] }) {
const { t } = useI18n();

return (
<div className="card" style={{ marginBottom: 16, padding: "12px 14px" }}>
<div className="font-semibold" style={{ marginBottom: 4 }}>{t("debug.claudeInbound.title")}</div>
<div className="muted text-control" style={{ marginBottom: 10 }}>{t("debug.claudeInbound.sub")}</div>
{entries.length === 0 ? (
<div className="muted text-control">{t("debug.claudeInbound.empty")}</div>
) : (
<div style={{ overflowX: "auto" }}>
<table className="table text-label">
<thead>
<tr>
<th>{t("debug.claudeInbound.time")}</th>
<th>{t("debug.claudeInbound.endpoint")}</th>
<th>{t("debug.claudeInbound.model")}</th>
<th>thinking</th>
<th>effort</th>
<th>beta</th>
<th>metadata</th>
<th>system</th>
</tr>
</thead>
<tbody>
{entries.map(entry => (
<tr key={entry.id}>
<td className="muted mono">{formatClaudeInboundTime(entry.at)}</td>
<td className="mono">{entry.endpoint}</td>
<td className="mono" title={entry.resolvedModel}>
{entry.model}
{entry.resolvedModel && entry.resolvedModel !== entry.model && (
<span className="muted"> → {entry.resolvedModel}</span>
)}
</td>
<td className="mono">
{entry.thinkingType ?? "-"}
{entry.thinkingBudgetTokens !== undefined && <span className="muted"> ({entry.thinkingBudgetTokens})</span>}
</td>
<td className="mono">{entry.outputConfigEffort ?? "-"}</td>
<td className="mono" title={entry.anthropicBeta} style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{entry.anthropicBeta ?? "-"}</td>
<td className="mono" title={entry.metadataKeys?.join(", ")}>
{entry.hasMetadataUserId ? `user_id ${entry.userIdTag ?? ""}` : t("debug.claudeInbound.none")}
</td>
<td className="mono">{entry.hasSystem ? entry.systemTag ?? "yes" : t("debug.claudeInbound.none")}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
75 changes: 75 additions & 0 deletions gui/src/pages/debug-log-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useI18n } from "../i18n/shared";
import type { DebugLogEntry, LogStream } from "./debug-shared";
import { formatLogTime } from "./debug-shared";

export function DebugLogViewer({
debug,
stream,
streamEnabled,
entries,
scrollContainerRef,
lineVirtualizer,
}: {
debug: boolean;
stream: LogStream;
streamEnabled: boolean;
entries: DebugLogEntry[];
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
lineVirtualizer: Virtualizer<HTMLDivElement, Element>;
}) {
const { t } = useI18n();

if (!debug) return null;

if (!streamEnabled) {
return (
<div className="empty">
<div className="font-semibold" style={{ marginBottom: 6 }}>{t("debug.emptyTitle")}</div>
<div className="muted text-control" style={{ maxWidth: 560, marginInline: "auto" }}>{t("debug.empty")}</div>
</div>
);
}

if (entries.length === 0) {
return (
<div className="empty">
<div className="font-semibold" style={{ marginBottom: 6 }}>{t("debug.noLinesTitle")}</div>
<div className="muted text-control" style={{ maxWidth: 560, marginInline: "auto" }}>{t(`debug.noLines.${stream}`)}</div>
</div>
);
}

return (
<div
ref={scrollContainerRef}
className="log-detail-json"
style={{ maxHeight: "calc(100vh - 280px)", overflow: "auto" }}
>
<div
style={{
position: "relative",
height: lineVirtualizer.getTotalSize(),
width: "100%",
}}
>
{lineVirtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
ref={lineVirtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{`${formatLogTime(entries[virtualRow.index]!.at)}${entries[virtualRow.index]!.line}`}
</div>
))}
</div>
</div>
);
}
122 changes: 122 additions & 0 deletions gui/src/pages/debug-settings-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useI18n } from "../i18n/shared";
import { IconRefresh } from "../icons";
import { Switch } from "../ui";
import type { DebugSettings, LogStream } from "./debug-shared";
import { isDebugFlagEnabled } from "./debug-shared";

export function DebugSettingsPanel({
debug,
debugBusy,
stream,
onSetFlag,
onReset,
onStreamChange,
}: {
debug: DebugSettings;
debugBusy: boolean;
stream: LogStream;
onSetFlag: (flag: "debug" | "usage" | "injection" | "claude", enabled: boolean) => void;
onReset: () => void;
onStreamChange: (stream: LogStream) => void;
}) {
const { t } = useI18n();

return (
<div className="card" style={{ marginBottom: 16, padding: "12px 14px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
{(["debug", "usage", "injection", "claude"] as const).map(flag => {
const checked = isDebugFlagEnabled(debug, flag);
return (
<div key={flag} style={{ display: "inline-flex", alignItems: "center", gap: 10, minWidth: 220 }}>
<Switch
on={checked}
disabled={debugBusy}
label={t(`debug.${flag}`)}
onClick={() => onSetFlag(flag, !checked)}
/>
<span className="text-control">{t(`debug.${flag}`)}</span>
</div>
);
})}
</div>
<button type="button" className="btn btn-ghost btn-sm" disabled={debugBusy} onClick={onReset}>
{t("debug.reset")}
</button>
</div>

{(debug.enabled || debug.usage || debug.injection) && (
<div style={{ display: "inline-flex", gap: 6, marginTop: 12 }}>
{debug.enabled && (
<button
type="button"
className={`btn btn-sm${stream === "provider" ? " btn-primary" : " btn-ghost"}`}
onClick={() => onStreamChange("provider")}
>
{t("debug.streamProvider")}
</button>
)}
{debug.usage && (
<button
type="button"
className={`btn btn-sm${stream === "usage" ? " btn-primary" : " btn-ghost"}`}
onClick={() => onStreamChange("usage")}
>
{t("debug.streamUsage")}
</button>
)}
{debug.injection && (
<button
type="button"
className={`btn btn-sm${stream === "injection" ? " btn-primary" : " btn-ghost"}`}
onClick={() => onStreamChange("injection")}
>
{t("debug.streamInjection")}
</button>
)}
</div>
)}
</div>
);
}

export function DebugPageHeader({
embedded,
refreshing,
streamEnabled,
follow,
onRefresh,
onFollowChange,
}: {
embedded?: boolean;
refreshing: boolean;
streamEnabled: boolean;
follow: boolean;
onRefresh: () => void;
onFollowChange: (follow: boolean) => void;
}) {
const { t } = useI18n();

return (
<>
<div className={embedded ? "row" : "page-head"} style={embedded ? { justifyContent: "flex-end", marginBottom: 4 } : undefined}>
{!embedded && <h2>{t("debug.title")}</h2>}
<div style={{ display: "inline-flex", alignItems: "center", gap: 12 }}>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={refreshing || !streamEnabled}
onClick={onRefresh}
>
<IconRefresh /> {t("debug.refresh")}
</button>
<label className="muted text-control" style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6 }}>
<input type="checkbox" checked={follow} onChange={e => onFollowChange(e.target.checked)} />
{t("debug.follow")}
</label>
</div>
</div>
<p className="page-sub">{t("debug.subtitle")}</p>
</>
);
}
Loading
Loading