mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-20 14:03:24 +00:00
Merge branch 'refs/heads/dev/3.1' into dev/docs
This commit is contained in:
commit
7aa18a0c49
9 changed files with 560 additions and 32 deletions
|
|
@ -2280,7 +2280,7 @@ export type AgentAnalysisSubagentRow = {
|
|||
|
||||
export type AgentAnalysisTraceRunKind = "agent" | "llm" | "route" | "subagent" | "tool";
|
||||
|
||||
export type AgentAnalysisTraceRunStatus = "error" | "success";
|
||||
export type AgentAnalysisTraceRunStatus = "error" | "partial" | "success";
|
||||
|
||||
export type AgentAnalysisTracePayloadPreview = {
|
||||
kind: "empty" | "json" | "text";
|
||||
|
|
@ -2318,6 +2318,7 @@ export type AgentAnalysisTraceRun = {
|
|||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
concurrentRequests: number;
|
||||
costUsd?: number;
|
||||
depth: number;
|
||||
durationMs: number;
|
||||
endedAt: string;
|
||||
|
|
|
|||
|
|
@ -1882,15 +1882,67 @@ function extractSubagentModel(
|
|||
}
|
||||
|
||||
for (const payload of requestPayloads) {
|
||||
const match = stringifyForSearch(payload).match(/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s);
|
||||
if (match?.[1]?.trim()) {
|
||||
return match[1].trim();
|
||||
const model = extractPayloadSubagentModel(payload);
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPayloadSubagentModel(payload: unknown): string | undefined {
|
||||
if (!isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const systemModel = extractSubagentModelFromContent(payload.system);
|
||||
if (systemModel) {
|
||||
return systemModel;
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.messages)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const message of payload.messages.slice(0, 2)) {
|
||||
if (!isRecord(message) || message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const model = extractSubagentModelFromContent(message.content);
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSubagentModelFromContent(content: unknown): string | undefined {
|
||||
if (typeof content === "string") {
|
||||
return extractSubagentModelFromText(content);
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const block of content) {
|
||||
const text = typeof block === "string"
|
||||
? block
|
||||
: isRecord(block) && typeof block.text === "string"
|
||||
? block.text
|
||||
: undefined;
|
||||
const model = text ? extractSubagentModelFromText(text) : undefined;
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSubagentModelFromText(text: string): string | undefined {
|
||||
const match = text.match(/<CCR-SUBAGENT-MODEL>(.*?)<\/CCR-SUBAGENT-MODEL>/s);
|
||||
const model = match?.[1]?.trim();
|
||||
return model && model.toLowerCase() !== "provider/model" ? model : undefined;
|
||||
}
|
||||
|
||||
function parseLogBodyPayloads(body: RequestLogBody | undefined): unknown[] {
|
||||
if (!body || body.encoding !== "utf8" || !body.text.trim()) {
|
||||
return [];
|
||||
|
|
@ -2568,6 +2620,7 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace {
|
|||
cacheReadTokens: totals.cacheReadTokens,
|
||||
cacheWriteTokens: totals.cacheWriteTokens,
|
||||
concurrentRequests: totals.maxConcurrentRequests,
|
||||
costUsd: totals.costUsd,
|
||||
depth: 0,
|
||||
durationMs,
|
||||
endedAt: isoFromMs(endMs),
|
||||
|
|
@ -2579,7 +2632,11 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace {
|
|||
outputTokens: totals.outputTokens,
|
||||
sessionId,
|
||||
startedAt: isoFromMs(startMs),
|
||||
status: totals.errorCount > 0 ? "error" : "success",
|
||||
status: totals.errorCount === 0
|
||||
? "success"
|
||||
: totals.errorCount === totals.requestCount
|
||||
? "error"
|
||||
: "partial",
|
||||
totalTokens: totals.totalTokens
|
||||
}
|
||||
];
|
||||
|
|
@ -2704,6 +2761,7 @@ function requestTraceRun({
|
|||
cacheReadTokens: request.cacheReadTokens,
|
||||
cacheWriteTokens: request.cacheWriteTokens,
|
||||
concurrentRequests: request.concurrentRequests,
|
||||
costUsd: request.costUsd,
|
||||
depth,
|
||||
durationMs: request.durationMs,
|
||||
endedAt: isoFromMs(request.endedAtMs),
|
||||
|
|
|
|||
|
|
@ -126,6 +126,11 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
|
|||
);
|
||||
record.model = "custom-model";
|
||||
record.providerName = "custom-provider";
|
||||
record.requestHeaders = {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "openai-codex test",
|
||||
"x-codex-session-id": "custom-pricing-session"
|
||||
};
|
||||
record.pricing = {
|
||||
inputUsdPerMillionTokens: 2,
|
||||
outputUsdPerMillionTokens: 8
|
||||
|
|
@ -141,6 +146,19 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
|
|||
let page = await store.list({ pageSize: 10 });
|
||||
let detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.costUsd, 6);
|
||||
let analysis = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "custom-pricing-session"
|
||||
});
|
||||
assert.equal(
|
||||
analysis.selectedSession?.trace.runs.find((run) => run.id === analysis.selectedSession?.trace.rootRunId)?.costUsd,
|
||||
6
|
||||
);
|
||||
assert.equal(
|
||||
analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd,
|
||||
6
|
||||
);
|
||||
|
||||
assert.equal(await store.updateFromRawTrace({
|
||||
model: "custom-model",
|
||||
|
|
@ -151,6 +169,15 @@ test("RequestLogStore applies persisted custom model pricing to raw trace usage
|
|||
page = await store.list({ pageSize: 10 });
|
||||
detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.costUsd, 12);
|
||||
analysis = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "custom-pricing-session"
|
||||
});
|
||||
assert.equal(
|
||||
analysis.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.costUsd,
|
||||
12
|
||||
);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
|
|
|
|||
|
|
@ -987,6 +987,10 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
|
|||
assert.equal(selected.selectedSession?.trace.toolRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.llmRunCount, 1);
|
||||
assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true);
|
||||
assert.equal(
|
||||
selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status,
|
||||
"success"
|
||||
);
|
||||
|
||||
const inputPayload = await store.getTracePayload({
|
||||
callId: "call-read",
|
||||
|
|
@ -1010,6 +1014,189 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async
|
|||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore ignores subagent markers in tool definitions and placeholder text", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-subagent-detection-test-"));
|
||||
try {
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const baseTime = Date.now() - 5000;
|
||||
|
||||
async function recordClaudeRequest({ body, offsetMs, sessionId }) {
|
||||
const startedAtMs = baseTime + offsetMs;
|
||||
await store.record({
|
||||
completedAt: new Date(startedAtMs + 100).toISOString(),
|
||||
durationMs: 100,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerName: "test-provider",
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
...body,
|
||||
model: "claude-test"
|
||||
}), "utf8"),
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli test",
|
||||
"x-ccr-route-reason": "default",
|
||||
"x-ccr-routed-model": "test-provider/claude-test",
|
||||
"x-claude-code-session-id": sessionId
|
||||
},
|
||||
requestId: `${sessionId}-request`,
|
||||
responseBodyText: JSON.stringify({ model: "claude-test" }),
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt: new Date(startedAtMs).toISOString(),
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
}
|
||||
|
||||
await recordClaudeRequest({
|
||||
body: {
|
||||
messages: [{ content: "normal main-agent request", role: "user" }],
|
||||
tools: [{
|
||||
description: "Use <CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL> when spawning an agent.",
|
||||
input_schema: {
|
||||
properties: {
|
||||
prompt: {
|
||||
description: "Start with <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-MODEL>.",
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
type: "object"
|
||||
},
|
||||
name: "Agent"
|
||||
}]
|
||||
},
|
||||
offsetMs: 0,
|
||||
sessionId: "tool-description-session"
|
||||
});
|
||||
await recordClaudeRequest({
|
||||
body: {
|
||||
messages: [{ content: "normal request", role: "user" }],
|
||||
system: "Example: <CCR-SUBAGENT-MODEL>Provider/model</CCR-SUBAGENT-MODEL>"
|
||||
},
|
||||
offsetMs: 1000,
|
||||
sessionId: "placeholder-session"
|
||||
});
|
||||
await recordClaudeRequest({
|
||||
body: {
|
||||
messages: [{
|
||||
content: "<CCR-SUBAGENT-MODEL>Provider/claude-opus</CCR-SUBAGENT-MODEL>\nInspect the repository.",
|
||||
role: "user"
|
||||
}]
|
||||
},
|
||||
offsetMs: 2000,
|
||||
sessionId: "real-subagent-session"
|
||||
});
|
||||
|
||||
const toolDescription = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "claude-code",
|
||||
sessionId: "tool-description-session"
|
||||
});
|
||||
assert.equal(toolDescription.selectedSession?.trace.subagentRunCount, 0);
|
||||
assert.equal(toolDescription.selectedSession?.subagents.length, 0);
|
||||
|
||||
const placeholder = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "claude-code",
|
||||
sessionId: "placeholder-session"
|
||||
});
|
||||
assert.equal(placeholder.selectedSession?.trace.subagentRunCount, 0);
|
||||
assert.equal(placeholder.selectedSession?.subagents.length, 0);
|
||||
|
||||
const realSubagent = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "claude-code",
|
||||
sessionId: "real-subagent-session"
|
||||
});
|
||||
assert.equal(realSubagent.selectedSession?.trace.subagentRunCount, 1);
|
||||
assert.equal(realSubagent.selectedSession?.subagents[0]?.model, "Provider/claude-opus");
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore distinguishes partial session failures from failed sessions", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-status-test-"));
|
||||
try {
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const baseTime = Date.now() - 5000;
|
||||
|
||||
async function recordAgentRequest({ offsetMs, requestId, sessionId, statusCode }) {
|
||||
const startedAtMs = baseTime + offsetMs;
|
||||
await store.record({
|
||||
completedAt: new Date(startedAtMs + 100).toISOString(),
|
||||
durationMs: 100,
|
||||
...(statusCode >= 400 ? { error: "upstream request failed" } : {}),
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
providerName: "test-provider",
|
||||
providerProtocol: "openai_chat_completions",
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
messages: [{ content: "continue task", role: "user" }],
|
||||
model: "gpt-test",
|
||||
session_id: sessionId
|
||||
}), "utf8"),
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "openai-codex test",
|
||||
"x-codex-session-id": sessionId
|
||||
},
|
||||
requestId,
|
||||
responseBodyText: JSON.stringify({ model: "gpt-test" }),
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt: new Date(startedAtMs).toISOString(),
|
||||
statusCode,
|
||||
url: "http://127.0.0.1:3456/v1/chat/completions"
|
||||
});
|
||||
}
|
||||
|
||||
await recordAgentRequest({
|
||||
offsetMs: 0,
|
||||
requestId: "mixed-failed",
|
||||
sessionId: "session-mixed",
|
||||
statusCode: 502
|
||||
});
|
||||
await recordAgentRequest({
|
||||
offsetMs: 1000,
|
||||
requestId: "mixed-recovered",
|
||||
sessionId: "session-mixed",
|
||||
statusCode: 200
|
||||
});
|
||||
await recordAgentRequest({
|
||||
offsetMs: 2000,
|
||||
requestId: "failed-only",
|
||||
sessionId: "session-failed",
|
||||
statusCode: 502
|
||||
});
|
||||
|
||||
const mixed = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "session-mixed"
|
||||
});
|
||||
const mixedTrace = mixed.selectedSession?.trace;
|
||||
assert.equal(
|
||||
mixedTrace?.runs.find((run) => run.id === mixedTrace.rootRunId)?.status,
|
||||
"partial"
|
||||
);
|
||||
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "error"), true);
|
||||
assert.equal(mixedTrace?.runs.some((run) => run.kind === "llm" && run.status === "success"), true);
|
||||
|
||||
const failed = await store.analyze({
|
||||
range: "30d",
|
||||
sessionAgent: "codex",
|
||||
sessionId: "session-failed"
|
||||
});
|
||||
const failedTrace = failed.selectedSession?.trace;
|
||||
assert.equal(
|
||||
failedTrace?.runs.find((run) => run.id === failedTrace.rootRunId)?.status,
|
||||
"error"
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore agent analysis cache ratio denominator includes cache tokens when total tokens omit cache", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-cache-ratio-test-"));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3517,7 +3517,7 @@ function AgentSessionDetailCard({
|
|||
<AnalysisEmptyState label={t("No session requests")} />
|
||||
) : (
|
||||
<div className={cn("max-h-[260px]", agentListFrameClassName)}>
|
||||
<table className={cn("min-w-[980px]", agentListTableClassName)}>
|
||||
<table className={cn("min-w-[1060px]", agentListTableClassName)}>
|
||||
<thead className={agentListHeadClassName}>
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">{t("Time")}</th>
|
||||
|
|
@ -3525,7 +3525,8 @@ function AgentSessionDetailCard({
|
|||
<th className="px-3 py-2 font-semibold">{t("Route")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Model")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Tools")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -3538,6 +3539,7 @@ function AgentSessionDetailCard({
|
|||
<td className="max-w-[300px] px-3 py-2" title={`${request.provider}/${request.model}`}>{request.provider}/{request.model}</td>
|
||||
<td className="px-3 py-2 text-right" title={request.tools.join(", ")}>{formatCompactNumber(request.toolCallCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(request.totalTokens)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatUsdCost(request.costUsd ?? 0)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatDuration(request.durationMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -3557,19 +3559,22 @@ function AgentSessionDetailCard({
|
|||
const agentListSurfaceClassName = "rounded-md border border-border/70 bg-card/70 shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
|
||||
const agentListFrameClassName = cn("overflow-auto", agentListSurfaceClassName);
|
||||
const agentListTableClassName = "w-full border-collapse text-left text-[11px]";
|
||||
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur";
|
||||
const agentListHeadClassName = "sticky top-0 z-10 border-b border-border/70 bg-muted/80 text-muted-foreground backdrop-blur [&_th]:min-w-[64px] [&_th]:whitespace-nowrap";
|
||||
const agentListBodyClassName = "divide-y divide-border/50";
|
||||
|
||||
function agentListRowClassName({
|
||||
danger,
|
||||
selected
|
||||
selected,
|
||||
warning
|
||||
}: {
|
||||
danger?: boolean;
|
||||
selected?: boolean;
|
||||
warning?: boolean;
|
||||
} = {}) {
|
||||
return cn(
|
||||
"bg-card/40 transition-colors hover:bg-muted/30",
|
||||
danger && "bg-rose-500/5 hover:bg-rose-500/10",
|
||||
warning && "bg-amber-500/5 hover:bg-amber-500/10",
|
||||
selected && "bg-teal-500/10 shadow-[inset_2px_0_0_rgba(20,184,166,0.7)] hover:bg-teal-500/15"
|
||||
);
|
||||
}
|
||||
|
|
@ -3598,22 +3603,26 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
|
|||
<AnalysisEmptyState label={t("No trace runs")} />
|
||||
) : (
|
||||
<div className={cn("max-h-[420px]", agentListFrameClassName)}>
|
||||
<table className={cn("min-w-[1180px]", agentListTableClassName)}>
|
||||
<table className={cn("min-w-[1260px]", agentListTableClassName)}>
|
||||
<thead className={agentListHeadClassName}>
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">{t("Run")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Timeline")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Status")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Target")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cache")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Concurrency")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={agentListBodyClassName}>
|
||||
{trace.runs.map((run) => (
|
||||
<tr className={agentListRowClassName({ danger: run.status === "error" })} key={run.id}>
|
||||
<tr className={agentListRowClassName({
|
||||
danger: run.status === "error",
|
||||
warning: run.status === "partial"
|
||||
})} key={run.id}>
|
||||
<td className="max-w-[360px] px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2" style={{ paddingLeft: `${Math.min(run.depth, 8) * 16}px` }}>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", traceRunDotClass(run))} />
|
||||
|
|
@ -3636,8 +3645,8 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={cn("border", run.status === "error" ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700")} variant="outline">
|
||||
{t(run.status === "error" ? "Error" : "Success")}
|
||||
<Badge className={cn("border", traceRunStatusBadgeClass(run.status))} variant="outline">
|
||||
{t(traceRunStatusLabel(run.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="max-w-[260px] px-3 py-2" title={traceRunTarget(run)}>
|
||||
|
|
@ -3645,6 +3654,7 @@ function AgentTracePanel({ trace }: { trace: AgentTraceDetail }) {
|
|||
</td>
|
||||
<td className="px-3 py-2 text-right">{run.totalTokens > 0 ? formatCompactNumber(run.totalTokens) : "-"}</td>
|
||||
<td className="px-3 py-2 text-right">{run.cacheReadTokens + run.cacheWriteTokens > 0 ? formatCompactNumber(run.cacheReadTokens + run.cacheWriteTokens) : "-"}</td>
|
||||
<td className="px-3 py-2 text-right">{run.costUsd !== undefined ? formatUsdCost(run.costUsd) : "-"}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(run.concurrentRequests)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatDuration(run.durationMs)}</td>
|
||||
</tr>
|
||||
|
|
@ -4000,6 +4010,7 @@ function traceRunBarStyle(run: AgentAnalysisTraceRun, traceDurationMs: number):
|
|||
|
||||
function traceRunDotClass(run: AgentAnalysisTraceRun): string {
|
||||
if (run.status === "error") return "bg-rose-500";
|
||||
if (run.status === "partial") return "bg-amber-500";
|
||||
if (run.kind === "agent") return "bg-teal-500";
|
||||
if (run.kind === "route") return "bg-cyan-500";
|
||||
if (run.kind === "subagent") return "bg-amber-500";
|
||||
|
|
@ -4009,6 +4020,7 @@ function traceRunDotClass(run: AgentAnalysisTraceRun): string {
|
|||
|
||||
function traceRunBarClass(run: AgentAnalysisTraceRun): string {
|
||||
if (run.status === "error") return "bg-rose-500";
|
||||
if (run.status === "partial") return "bg-amber-500";
|
||||
if (run.kind === "agent") return "bg-teal-500";
|
||||
if (run.kind === "route") return "bg-cyan-500";
|
||||
if (run.kind === "subagent") return "bg-amber-500";
|
||||
|
|
@ -4016,6 +4028,18 @@ function traceRunBarClass(run: AgentAnalysisTraceRun): string {
|
|||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
function traceRunStatusBadgeClass(status: AgentAnalysisTraceRun["status"]): string {
|
||||
if (status === "error") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
if (status === "partial") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
function traceRunStatusLabel(status: AgentAnalysisTraceRun["status"]): string {
|
||||
if (status === "error") return "Error";
|
||||
if (status === "partial") return "Partial failure";
|
||||
return "Success";
|
||||
}
|
||||
|
||||
function formatRouteReason(value: string | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -4041,7 +4065,7 @@ function AgentSessionsCard({
|
|||
<AnalysisEmptyState label={t("No session activity")} />
|
||||
) : (
|
||||
<div className={cn("h-full", agentListFrameClassName)}>
|
||||
<table className={cn("min-w-[1260px]", agentListTableClassName)}>
|
||||
<table className={cn("min-w-[1420px]", agentListTableClassName)}>
|
||||
<thead className={agentListHeadClassName}>
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">{t("Session")}</th>
|
||||
|
|
@ -4054,6 +4078,8 @@ function AgentSessionsCard({
|
|||
<th className="px-3 py-2 text-right font-semibold">{t("Tools")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Subagents")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Errors")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cache rate")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Models")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("Providers")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("UA")}</th>
|
||||
|
|
@ -4077,6 +4103,8 @@ function AgentSessionsCard({
|
|||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.toolCallCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.subagentCallCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatCompactNumber(session.errorCount)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatPercent(session.cacheRatio)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{formatUsdCost(session.costUsd)}</td>
|
||||
<td className="max-w-[240px] px-3 py-2" title={session.models.join(", ")}>{session.models.join(", ") || "-"}</td>
|
||||
<td className="max-w-[220px] px-3 py-2" title={session.providers.join(", ")}>{session.providers.join(", ") || "-"}</td>
|
||||
<td className="max-w-[220px] px-3 py-2 font-mono" title={session.userAgent}>{compactUserAgent(session.userAgent)}</td>
|
||||
|
|
@ -4143,7 +4171,7 @@ function UsageAnalysisCard({
|
|||
{visibleColumns.map((column) => (
|
||||
<th className="px-3 py-2 font-semibold" key={column.key}>{column.label}</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Tokens")}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Token")}</th>
|
||||
{showCost ? <th className="px-3 py-2 text-right font-semibold">{t("Cost")}</th> : null}
|
||||
<th className="px-3 py-2 text-right font-semibold">{t("Requests")}</th>
|
||||
{showTokenBreakdown ? <th className="px-3 py-2 text-right font-semibold">{t("Input")}</th> : null}
|
||||
|
|
|
|||
|
|
@ -782,7 +782,7 @@ function logTableColumnLabel(columnId: LogTableColumnId, t: (value: string) => s
|
|||
case "credential":
|
||||
return t("Credential");
|
||||
case "tokens":
|
||||
return t("令牌");
|
||||
return t("Token");
|
||||
case "duration":
|
||||
return t("持续时间");
|
||||
}
|
||||
|
|
@ -849,7 +849,7 @@ function LogMobileCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px]">
|
||||
<LogCompactMetric label={t("令牌")} value={tokenSummary} />
|
||||
<LogCompactMetric label={t("Token")} value={tokenSummary} />
|
||||
<LogCompactMetric label={t("持续时间")} value={formatDuration(item.durationMs)} />
|
||||
{hasCredentialInfo ? <LogCompactMetric label={t("Credential")} value={logCredentialCellLabel(item)} /> : null}
|
||||
<LogCompactMetric label={t("Provider")} value={item.provider || "-"} />
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Stream": "Stream",
|
||||
"Streaming": "Streaming",
|
||||
"Non-streaming": "Non-streaming",
|
||||
"Token": "Token",
|
||||
"令牌": "Tokens",
|
||||
"成本": "Cost",
|
||||
"持续时间": "Duration",
|
||||
|
|
@ -880,7 +881,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Cache": "缓存",
|
||||
"Cache rate": "缓存率",
|
||||
"Cache ratio": "缓存率",
|
||||
"Cache tokens": "缓存令牌",
|
||||
"Cache tokens": "缓存 Token",
|
||||
"Cache write": "缓存写入",
|
||||
"Cancel": "取消",
|
||||
"Channel": "频道",
|
||||
|
|
@ -1045,6 +1046,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Model overrides are optional; empty fields keep Claude Code defaults.": "模型设置是可选项;留空会保留 Claude Code 默认设置。",
|
||||
"Display name": "显示名称",
|
||||
"Double click to copy": "双击复制",
|
||||
"Duration": "持续时间",
|
||||
"Edit": "编辑",
|
||||
"Edit bot": "编辑 Bot",
|
||||
"Edit API Key": "编辑 API 密钥",
|
||||
|
|
@ -1120,7 +1122,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Idle seconds": "空闲秒数",
|
||||
"Idle seconds must be between 30 and 86400.": "空闲秒数必须在 30 到 86400 之间。",
|
||||
"Input": "输入",
|
||||
"Input tokens": "输入令牌",
|
||||
"Input tokens": "输入 Token",
|
||||
"Integration ID": "集成 ID",
|
||||
"Install": "安装",
|
||||
"Install and restart": "安装并重启",
|
||||
|
|
@ -1208,7 +1210,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Open profiles": "查看配置档案",
|
||||
"Open providers": "查看供应商",
|
||||
"Output": "输出",
|
||||
"Output tokens": "输出令牌",
|
||||
"Output tokens": "输出 Token",
|
||||
"Observability": "可观测",
|
||||
"Off": "关闭",
|
||||
"On failure": "失败时",
|
||||
|
|
@ -1300,6 +1302,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Profile stopping is only available in the Electron app.": "配置档案停止功能仅在 Electron App 中可用。",
|
||||
"Profile ready": "配置档案已就绪",
|
||||
"Password": "密码",
|
||||
"Partial failure": "部分失败",
|
||||
"Recent Errors": "最近错误",
|
||||
"Recent Requests": "最近请求",
|
||||
"Refresh": "刷新",
|
||||
|
|
@ -1528,7 +1531,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"AI Fuel Cockpit": "AI 燃料仪表",
|
||||
"Token Calendar Poster": "Token 日历海报",
|
||||
"Spend Receipt": "消费小票",
|
||||
"tokens routed through CCR": "通过 CCR 路由的令牌",
|
||||
"tokens routed through CCR": "通过 CCR 路由的 Token",
|
||||
"Top model": "最高频模型",
|
||||
"Top provider": "最高频供应商",
|
||||
"No provider activity": "暂无供应商活动",
|
||||
|
|
@ -1569,7 +1572,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Subagent": "子代理",
|
||||
"Subagent Routing": "Subagent 路由",
|
||||
"Subagent calls": "Subagent 调用",
|
||||
"Subagents": "Subagent",
|
||||
"Subagents": "子代理",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
"System proxy": "系统代理",
|
||||
|
|
@ -1581,15 +1584,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Thread": "线程",
|
||||
"Thread ID": "线程 ID",
|
||||
"Thinking": "思考",
|
||||
"Token Mix": "令牌构成",
|
||||
"Token": "Token",
|
||||
"Token Mix": "Token 构成",
|
||||
"Total": "总计",
|
||||
"Total tokens": "总令牌",
|
||||
"Total tokens": "总 Token",
|
||||
"Username": "用户名",
|
||||
"Today": "今天",
|
||||
"Token threshold": "令牌阈值",
|
||||
"Token threshold": "Token 阈值",
|
||||
"Truncated": "已截断",
|
||||
"Tokens": "令牌",
|
||||
"tokens": "令牌",
|
||||
"Tokens": "Token",
|
||||
"tokens": "Token",
|
||||
"day": "天",
|
||||
"days": "天",
|
||||
"Tool": "工具",
|
||||
|
|
@ -1812,10 +1816,10 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"5h quota": "5 小时额度",
|
||||
"Granted balance": "赠送余额",
|
||||
"Individual limit": "个人额度",
|
||||
"Lifetime tokens": "累计令牌",
|
||||
"Lifetime tokens": "累计 Token",
|
||||
"Manual resets": "主动重置次数",
|
||||
"Monthly budget": "月度预算",
|
||||
"Peak daily tokens": "日峰值令牌",
|
||||
"Peak daily tokens": "日峰值 Token",
|
||||
"Primary quota": "主额度",
|
||||
"Secondary quota": "副额度",
|
||||
"Topped-up balance": "充值余额",
|
||||
|
|
@ -1844,7 +1848,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Copy API key": "复制 API 密钥",
|
||||
"Model settings": "模型设置",
|
||||
"Context, pricing, reasoning, web search, and image": "上下文、价格、推理、网页搜索与图片",
|
||||
"Context window (tokens)": "上下文窗口(令牌)",
|
||||
"Context window (tokens)": "上下文窗口(Token)",
|
||||
"Custom pricing": "自定义价格",
|
||||
"Pricing": "价格",
|
||||
"Preset": "预设",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import assert from "node:assert/strict";
|
|||
import test from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { RequestLogPage } from "@ccr/core/contracts/app.ts";
|
||||
import type { AgentAnalysisSessionRow, AgentAnalysisTraceRun, RequestLogEntry, RequestLogPage } from "@ccr/core/contracts/app.ts";
|
||||
import { AgentAnalysisView } from "@ccr/ui/pages/home/components/dashboard.tsx";
|
||||
import { LogsView } from "@ccr/ui/pages/home/components/network-logs.tsx";
|
||||
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
|
||||
import { createEmptyAgentAnalysis } from "@ccr/ui/pages/home/shared/usage.ts";
|
||||
|
||||
const emptyLogPage: RequestLogPage = {
|
||||
generatedAt: "2026-07-23T00:00:00.000Z",
|
||||
|
|
@ -20,6 +22,39 @@ const emptyLogPage: RequestLogPage = {
|
|||
totalPages: 1
|
||||
};
|
||||
|
||||
const sampleRequestLogEntry: RequestLogEntry = {
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
client: "claude-code",
|
||||
costUsd: 0.01,
|
||||
createdAt: "2026-07-23T00:00:00.000Z",
|
||||
credentialChain: [],
|
||||
credentialSaturated: false,
|
||||
durationMs: 120,
|
||||
id: 1,
|
||||
inputTokens: 100,
|
||||
isStream: true,
|
||||
method: "POST",
|
||||
model: "claude-sonnet-4",
|
||||
ok: true,
|
||||
outputTokens: 50,
|
||||
path: "/v1/messages",
|
||||
provider: "anthropic",
|
||||
reasoningTokens: 0,
|
||||
requestBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false },
|
||||
requestHeaders: {},
|
||||
requestId: "req-token-copy",
|
||||
routeAttemptCount: 1,
|
||||
routeHopCount: 1,
|
||||
routeTraceTruncated: false,
|
||||
retryAttempts: [],
|
||||
responseBody: { encoding: "utf8", sizeBytes: 2, text: "{}", truncated: false },
|
||||
responseHeaders: {},
|
||||
statusCode: 200,
|
||||
totalTokens: 150,
|
||||
url: "/v1/messages"
|
||||
};
|
||||
|
||||
test("LogsView keeps disabled request logs discoverable with an enable action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
|
|
@ -59,3 +94,162 @@ test("LogsView explains filtered empty results and translates page sizes", () =>
|
|||
assert.match(html, /25 \/ page/);
|
||||
assert.doesNotMatch(html, /\/ 页/);
|
||||
});
|
||||
|
||||
test("LogsView keeps Chinese token column copy as Token", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<LogsView
|
||||
error=""
|
||||
filter={{ page: 1, pageSize: 25, status: "all" }}
|
||||
loading={false}
|
||||
page={{ ...emptyLogPage, items: [sampleRequestLogEntry], total: 1 }}
|
||||
refreshLogs={() => undefined}
|
||||
updateFilter={() => undefined}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.match(html, /Token/);
|
||||
assert.doesNotMatch(html, /令牌/);
|
||||
});
|
||||
|
||||
test("AgentAnalysisView keeps session headings horizontal and shows cache rate and cost", () => {
|
||||
const session: AgentAnalysisSessionRow = {
|
||||
agent: "claude-code",
|
||||
avgDurationMs: 420,
|
||||
cacheRatio: 0.375,
|
||||
cacheReadTokens: 300,
|
||||
cacheTokens: 300,
|
||||
cacheWriteTokens: 100,
|
||||
client: "claude-code",
|
||||
costUsd: 1.25,
|
||||
durationMs: 900,
|
||||
errorCount: 0,
|
||||
id: "session-cache-cost",
|
||||
inputTokens: 500,
|
||||
lastSeenAt: "2026-07-23T00:01:00.000Z",
|
||||
maxConcurrentRequests: 1,
|
||||
maxDurationMs: 500,
|
||||
models: ["claude-sonnet-4"],
|
||||
outputTokens: 100,
|
||||
p50DurationMs: 420,
|
||||
p95DurationMs: 500,
|
||||
p99DurationMs: 500,
|
||||
providers: ["anthropic"],
|
||||
requestCount: 2,
|
||||
sessionCount: 1,
|
||||
startedAt: "2026-07-23T00:00:00.000Z",
|
||||
subagentCallCount: 0,
|
||||
successRate: 1,
|
||||
toolCallCount: 1,
|
||||
topTools: [{ count: 1, name: "Read" }],
|
||||
totalTokens: 1000
|
||||
};
|
||||
const snapshot = {
|
||||
...createEmptyAgentAnalysis("24h"),
|
||||
scannedRequestCount: 2,
|
||||
selectedSession: {
|
||||
endpoints: [],
|
||||
errors: [],
|
||||
models: [],
|
||||
requests: [{
|
||||
agent: session.agent,
|
||||
cacheReadTokens: 300,
|
||||
cacheWriteTokens: 100,
|
||||
client: session.client,
|
||||
concurrentRequests: 1,
|
||||
costUsd: 0.25,
|
||||
createdAt: session.startedAt,
|
||||
durationMs: 420,
|
||||
id: 42,
|
||||
inputTokens: 500,
|
||||
method: "POST",
|
||||
model: "claude-sonnet-4",
|
||||
ok: true,
|
||||
outputTokens: 100,
|
||||
path: "/v1/messages",
|
||||
provider: "anthropic",
|
||||
requestId: "request-with-cost",
|
||||
routeReason: "default",
|
||||
sessionId: session.id,
|
||||
statusCode: 200,
|
||||
toolCallCount: 1,
|
||||
tools: ["Read"],
|
||||
totalTokens: 1000
|
||||
}],
|
||||
routes: [],
|
||||
session,
|
||||
statusCodes: [],
|
||||
subagents: [],
|
||||
tools: [],
|
||||
totals: session,
|
||||
trace: {
|
||||
agent: session.agent,
|
||||
durationMs: session.durationMs,
|
||||
endedAt: session.lastSeenAt,
|
||||
errorCount: 1,
|
||||
id: `${session.agent}:${session.id}`,
|
||||
llmRunCount: 0,
|
||||
maxDepth: 0,
|
||||
rootRunId: `agent:${session.agent}:${session.id}`,
|
||||
runCount: 1,
|
||||
runs: [{
|
||||
agent: session.agent,
|
||||
cacheReadTokens: session.cacheReadTokens,
|
||||
cacheWriteTokens: session.cacheWriteTokens,
|
||||
concurrentRequests: session.maxConcurrentRequests,
|
||||
costUsd: 0.75,
|
||||
depth: 0,
|
||||
durationMs: session.durationMs,
|
||||
endedAt: session.lastSeenAt,
|
||||
id: `agent:${session.agent}:${session.id}`,
|
||||
inputTokens: session.inputTokens,
|
||||
kind: "agent",
|
||||
name: "Claude Code session",
|
||||
offsetMs: 0,
|
||||
outputTokens: session.outputTokens,
|
||||
sessionId: session.id,
|
||||
startedAt: session.startedAt,
|
||||
status: "partial",
|
||||
totalTokens: session.totalTokens
|
||||
} satisfies AgentAnalysisTraceRun],
|
||||
sessionId: session.id,
|
||||
startedAt: session.startedAt,
|
||||
subagentRunCount: 0,
|
||||
toolRunCount: 0
|
||||
}
|
||||
},
|
||||
sessions: [session]
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<AgentAnalysisView
|
||||
agentFilter="all"
|
||||
error=""
|
||||
loading={false}
|
||||
range="24h"
|
||||
refreshAnalysis={() => undefined}
|
||||
setAgentFilter={() => undefined}
|
||||
setRange={() => undefined}
|
||||
setSelectedSession={() => undefined}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.match(html, /持续时间/);
|
||||
assert.match(html, /子代理/);
|
||||
assert.match(html, /缓存率/);
|
||||
assert.match(html, /38%/);
|
||||
assert.match(html, /成本/);
|
||||
assert.match(html, /Token/);
|
||||
assert.doesNotMatch(html, /令牌/);
|
||||
assert.match(html, /\$1\.25/);
|
||||
assert.match(html, /\$0\.25/);
|
||||
assert.match(html, /\$0\.75/);
|
||||
assert.match(html, /部分失败/);
|
||||
assert.match(html, /border-amber-200/);
|
||||
assert.match(html, /min-w-\[64px\]/);
|
||||
assert.match(html, /whitespace-nowrap/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import test from "node:test";
|
|||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView } from "@ccr/ui/pages/home/components/dashboard.tsx";
|
||||
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
|
||||
import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx";
|
||||
import { providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
|
||||
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
|
||||
|
|
@ -74,6 +75,34 @@ test("OverviewView renders every overview widget type", () => {
|
|||
assert.match(html, /Spend Receipt/);
|
||||
});
|
||||
|
||||
test("OverviewView keeps Chinese token copy as Token", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppI18nContext.Provider value={appCopy.zh}>
|
||||
<OverviewView
|
||||
overviewWidgets={[
|
||||
{ enabled: true, id: "metric-total", metric: "total-tokens", size: "1:1", type: "metric", variant: "card" },
|
||||
{ enabled: true, id: "trend", size: "3:2", type: "usage-trend", variant: "composed" },
|
||||
{ enabled: true, id: "activity", size: "4:2", type: "token-activity", variant: "heatmap" },
|
||||
{ enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "donut" },
|
||||
{ enabled: true, id: "share-usage", size: "1:4", type: "share-usage-wrapped", variant: "card" },
|
||||
{ enabled: true, id: "share-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" }
|
||||
]}
|
||||
providerAccounts={accountSnapshots()}
|
||||
refreshProviderAccounts={() => undefined}
|
||||
setUsageRange={() => undefined}
|
||||
usageRange="30d"
|
||||
usageStats={usageStats("30d")}
|
||||
onWidgetsChange={() => undefined}
|
||||
/>
|
||||
</AppI18nContext.Provider>
|
||||
);
|
||||
|
||||
assert.match(html, /Token/);
|
||||
assert.match(html, /Token 构成/);
|
||||
assert.match(html, /总 Token/);
|
||||
assert.doesNotMatch(html, /令牌/);
|
||||
});
|
||||
|
||||
test("overview status dates accept ISO usage buckets", () => {
|
||||
assert.equal(parseStatusBucketDate("2026-06-20T00:00:00.000Z")?.toISOString(), "2026-06-20T00:00:00.000Z");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue