Q75: charts and billing use full-period statistics computed per org

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 12:41:44 -04:00
1 parent daea9797d4
commit 8b4df5e11f
14 files changed
+231 -83

No files matched your search

+4
View File
@@ -32,6 +32,10 @@ export const zh: Record<string, string> = {
"superseded": "已被取代",
"stalled": "已停滞",
"Resume": "恢复",
"{n} in the last 7 days": "近 7 天 {n}",
"Success rate (7 days)": "成功率(近 7 天)",
"{n} tokens in the last 7 days": "近 7 天 {n} token",
"{n} tokens in the last 7 days · {count} calls without usage": "近 7 天 {n} token · {count} 次调用未报告用量",
"Spent this month": "本月已花费",
"${spent} of ${allocated} allocated to agents": "已花 ${spent} / 分配给 Agent 的 ${allocated}",
"{count} calls not priced": "{count} 次调用未计价",
+5 -5
View File
@@ -14,7 +14,7 @@ import type {
import { NewTaskDialog } from "./NewTaskDialog";
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types";
import { t } from "../i18n/i18n";
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
@@ -37,7 +37,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
const chatMessages = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((s) => s.orgId === orgId), [snapshot.chatSpaces, orgId]);
const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]);
const usageStats = requireUsageStats(snapshot.usageStats, orgId);
const [newTaskOpen, setNewTaskOpen] = useState(false);
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
@@ -69,9 +69,9 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
<div style={METRICS}>
<AgentWorkloadChart agents={agents} tasks={tasks} />
<RunActivityChart taskRuns={taskRuns} />
<SuccessRateChart taskRuns={taskRuns} />
<CostTrendChart providerRequests={providerRequests} />
<RunActivityChart days={usageStats.days} />
<SuccessRateChart days={usageStats.days} />
<CostTrendChart days={usageStats.days} />
</div>
<div style={ACTIVITY}>
+3 -1
View File
@@ -5,7 +5,7 @@ import { AdvancedPanel } from "./settings/AdvancedPanel";
import { IntegrationKeysPanel } from "./settings/IntegrationKeysPanel";
import { t } from "../i18n/i18n";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
import { activeOrgId, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types";
type SettingsTab = "general" | "providers" | "integrations" | "advanced";
@@ -30,6 +30,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]);
const providerAuthProfiles = useMemo(() => snapshot.providerAuthProfiles.filter((item) => item.orgId === orgId), [snapshot.providerAuthProfiles, orgId]);
const usage = requireUsageStats(snapshot.usageStats, orgId);
const providerRateLimits = useMemo(() => snapshot.providerRateLimits.filter((item) => item.orgId === orgId), [snapshot.providerRateLimits, orgId]);
const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]);
const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]);
@@ -68,6 +69,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
workspaces={workspaces}
providerRequests={providerRequests}
providerRateLimits={providerRateLimits}
usage={usage}
hiringPolicies={hiringPolicies}
actions={actions}
/>
+15 -40
View File
@@ -1,5 +1,6 @@
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
import { useState, type ReactNode } from "react";
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
import type { AgentRecord, TaskRecord } from "../../../../shared/domain";
import { TASK_STATUS } from "../../../../shared/domain";
import { t } from "../../i18n/i18n";
@@ -60,13 +61,13 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
);
}
export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt));
export function RunActivityChart({ days }: { days: UsageDays }) {
const buckets = days.map((day) => ({ label: weekdayFormat.format(new Date(day.dayStart)), count: day.runs }));
const total = buckets.reduce((s, b) => s + b.count, 0);
const max = Math.max(1, ...buckets.map((b) => b.count));
const barW = (CHART_W - 6 * BAR_GAP) / 7;
const [hover, setHover] = useState<number | null>(null);
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} this week", { n: total });
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} in the last 7 days", { n: total });
return (
<ChartCard title={t("Run activity")} readout={readout}>
@@ -96,16 +97,16 @@ export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
);
}
export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const completed = taskRuns.filter((r) => r.state === "completed").length;
const failed = taskRuns.filter((r) => r.state === "failed").length;
export function SuccessRateChart({ days }: { days: UsageDays }) {
const completed = days.reduce((sum, day) => sum + day.completedRuns, 0);
const failed = days.reduce((sum, day) => sum + day.failedRuns, 0);
const total = completed + failed;
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
const circumference = 2 * Math.PI * 42;
const offset = circumference - (rate / 100) * circumference;
return (
<ChartCard title={t("Success rate")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
<ChartCard title={t("Success rate (7 days)")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
<circle
@@ -121,19 +122,10 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
}
/** Daily model tokens as the providers reported them; calls without reported usage are counted, not guessed. */
export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ createdAt: string; inputTokens: number | null; outputTokens: number | null }> }) {
const buckets = last7DayBuckets(providerRequests.map((r) => r.createdAt));
const tokenBuckets = buckets.map((b, i) => {
const dayStart = dayStartMs(i);
const dayEnd = dayStart + 86400000;
const tokens = providerRequests
.filter((r) => { const t = new Date(r.createdAt).getTime(); return t >= dayStart && t < dayEnd; })
.reduce((s, r) => s + (r.inputTokens ?? 0) + (r.outputTokens ?? 0), 0);
return { ...b, tokens };
});
export function CostTrendChart({ days }: { days: UsageDays }) {
const tokenBuckets = days.map((day) => ({ label: weekdayFormat.format(new Date(day.dayStart)), tokens: day.tokens }));
const totalTokens = tokenBuckets.reduce((s, b) => s + b.tokens, 0);
// Same seven calendar days as the chart.
const unreported = providerRequests.filter((r) => r.inputTokens === null && new Date(r.createdAt).getTime() >= dayStartMs(0)).length;
const unreported = days.reduce((sum, day) => sum + day.callsWithoutUsage, 0);
const max = Math.max(1, ...tokenBuckets.map((b) => b.tokens));
const coords = tokenBuckets.map((b, i) => ({
x: (i / 6) * (CHART_W - 10) + 5,
@@ -143,8 +135,8 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{
const points = coords.map((c) => `${c.x},${c.y}`).join(" ");
const [hover, setHover] = useState<number | null>(null);
const week = unreported > 0
? t("{n} tokens this week · {count} calls without usage", { n: compact(totalTokens), count: unreported })
: t("{n} tokens this week", { n: compact(totalTokens) });
? t("{n} tokens in the last 7 days · {count} calls without usage", { n: compact(totalTokens), count: unreported })
: t("{n} tokens in the last 7 days", { n: compact(totalTokens) });
const readout = hover !== null && tokenBuckets[hover]
? `${tokenBuckets[hover]!.label} · ${compact(tokenBuckets[hover]!.tokens)}`
: week;
@@ -173,21 +165,4 @@ function compact(count: number): string {
const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" });
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
const now = Date.now();
return Array.from({ length: 7 }, (_, i) => {
const offset = 6 - i;
const start = new Date(now - offset * 86400000);
start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + 86400000);
const count = dates.filter((d) => { const t = new Date(d).getTime(); return t >= start.getTime() && t < end.getTime(); }).length;
return { label: weekdayFormat.format(start), count };
});
}
function dayStartMs(bucketIndex: number): number {
const offset = 6 - bucketIndex;
const start = new Date(Date.now() - offset * 86400000);
start.setHours(0, 0, 0, 0);
return start.getTime();
}
type UsageDays = UsageStatsRecord["days"];
@@ -1,3 +1,4 @@
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
import type {
AgentRecord,
@@ -21,6 +22,7 @@ export function AdvancedPanel({
workspaces,
providerRequests,
providerRateLimits,
usage,
hiringPolicies,
actions,
}: {
@@ -31,6 +33,7 @@ export function AdvancedPanel({
workspaces: WorkspaceRecord[];
providerRequests: ProviderRequestRecord[];
providerRateLimits: ProviderRateLimitStateRecord[];
usage: UsageStatsRecord;
hiringPolicies: HiringPolicyRecord[];
actions: SnapshotActions;
}) {
@@ -53,7 +56,7 @@ export function AdvancedPanel({
)}
<div style={{ marginTop: 28 }}>
<BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} providerRateLimits={providerRateLimits} hiringPolicies={hiringPolicies} />
<BillingPanel agents={agents} providers={providers} usage={usage} providerRateLimits={providerRateLimits} hiringPolicies={hiringPolicies} />
</div>
</div>
);
@@ -1,20 +1,22 @@
import { Avatar, Pill } from "../../shared/ui";
import type { AgentRecord, HiringPolicyRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain";
import type { AgentRecord, HiringPolicyRecord, ProviderRecord } from "../../../../shared/domain";
import { money } from "../types";
import { buildAgentSpend } from "./billing-helpers";
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
import { t } from "../../i18n/i18n";
export function BillingPanel({
agents,
providers,
providerRequests,
usage,
providerRateLimits,
hiringPolicies,
}: {
agents: AgentRecord[];
providers: ProviderRecord[];
providerRequests: ProviderRequestRecord[];
/** Last 30 days over every call, computed in the main process. */
usage: UsageStatsRecord;
providerRateLimits: ProviderRateLimitStateRecord[];
hiringPolicies: HiringPolicyRecord[];
}) {
@@ -23,15 +25,8 @@ export function BillingPanel({
const unpricedCalls = providerRateLimits.reduce((s, usage) => s + usage.unpricedRequests + usage.requestsWithoutUsage, 0);
const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0);
const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0);
const monthRequests = providerRequests.filter((r) => withinDays(r.createdAt, 30));
const sum = (pick: (request: ProviderRequestRecord) => number | null) => monthRequests.reduce((s, r) => s + (pick(r) ?? 0), 0);
const inputTokens = sum((r) => r.inputTokens);
const cachedTokens = sum((r) => r.cachedInputTokens);
const outputTokens = sum((r) => r.outputTokens);
const reasoningTokens = sum((r) => r.reasoningTokens);
const unreported = monthRequests.filter((r) => r.inputTokens === null).length;
const failedRequests = monthRequests.filter((r) => r.state === "failed");
const agentSpend = buildAgentSpend(agents, monthRequests);
const { inputTokens, cachedInputTokens: cachedTokens, outputTokens, reasoningTokens, callsWithoutUsage: unreported } = usage.month;
const agentSpend = buildAgentSpend(agents, usage.agentSpend);
return (
<div>
@@ -40,8 +35,8 @@ export function BillingPanel({
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
<StatTile label={t("Monthly allocation")} value={money(totalBudget)} />
<StatTile label={t("Budget cap")} value={totalCap > 0 ? money(totalCap) : "—"} />
<StatTile label={t("Requests (30d)")} value={String(monthRequests.length)} />
<StatTile label={t("Failures (30d)")} value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
<StatTile label={t("Requests (30d)")} value={String(usage.month.requests)} />
<StatTile label={t("Failures (30d)")} value={String(usage.month.failures)} tone={usage.month.failures > 0 ? "red" : undefined} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
@@ -95,8 +90,8 @@ export function BillingPanel({
<div>{t("Provider")}</div><div>{t("Requests")}</div><div>{t("Errors")}</div><div>{t("Agents")}</div>
</div>
{providers.map((provider, index) => {
const requests = monthRequests.filter((r) => r.providerId === provider.id);
const errors = requests.filter((r) => r.state === "failed").length;
const activity = usage.providerActivity.find((row) => row.providerId === provider.id);
const errors = activity?.failures ?? 0;
const boundAgents = agents.filter((a) => a.modelProviderId === provider.id).length;
return (
<div key={provider.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === providers.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
@@ -104,7 +99,7 @@ export function BillingPanel({
<div style={{ fontWeight: 540 }}>{provider.label}</div>
{provider.model && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.model}</div>}
</div>
<div className="num mono">{requests.length}</div>
<div className="num mono">{activity?.requests ?? 0}</div>
<div>{errors > 0 ? <Pill tone="amber">{errors}</Pill> : <span className="num mono" style={{ color: "var(--text-3)" }}>0</span>}</div>
<div className="num mono" style={{ color: "var(--text-2)" }}>{boundAgents}</div>
</div>
@@ -123,7 +118,3 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?:
</div>
);
}
function withinDays(iso: string, days: number): boolean {
return Date.now() - new Date(iso).getTime() < days * 24 * 60 * 60 * 1000;
}
@@ -1,4 +1,5 @@
import type { AgentRecord, ProviderRequestRecord } from "../../../../shared/domain";
import type { AgentRecord } from "../../../../shared/domain";
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
export interface AgentSpendRow {
agent: AgentRecord;
@@ -8,14 +9,9 @@ export interface AgentSpendRow {
}
/** Each call counts once, for the agent that made it, whatever provider the agent uses now. */
export function buildAgentSpend(agents: AgentRecord[], requests: ProviderRequestRecord[]): AgentSpendRow[] {
export function buildAgentSpend(agents: AgentRecord[], spend: UsageStatsRecord["agentSpend"]): AgentSpendRow[] {
return agents.map((agent) => {
const agentRequests = requests.filter((r) => r.agentId === agent.id);
return {
agent,
requestCount: agentRequests.length,
tokens: agentRequests.reduce((s, r) => s + (r.inputTokens ?? 0) + (r.outputTokens ?? 0), 0),
spentCents: agentRequests.reduce((s, r) => s + (r.costCents ?? 0), 0),
};
const entry = spend.find((row) => row.agentId === agent.id);
return { agent, requestCount: entry?.requests ?? 0, tokens: entry?.tokens ?? 0, spentCents: entry?.costCents ?? 0 };
}).sort((a, b) => b.spentCents - a.spentCents || b.requestCount - a.requestCount);
}
+7
View File
@@ -79,3 +79,10 @@ export function formatRelative(iso: string, reference: Date = new Date()): strin
if (weeks < 5) return t("{n}w ago", { n: weeks });
return new Date(iso).toLocaleDateString();
}
/** The active org's period statistics; the snapshot carries one entry per org. */
export function requireUsageStats(stats: AppSnapshot["usageStats"], orgId: string): AppSnapshot["usageStats"][number] {
const found = stats.find((item) => item.orgId === orgId);
if (!found) throw new Error(`No usage statistics for organization ${orgId}.`);
return found;
}