feat(webui): clear-all usage records endpoint + dialog action

This commit is contained in:
m4
2026-08-11 15:07:23 +08:00
parent a7f7adc56b
commit 1351da2ee0
5 changed files with 148 additions and 20 deletions
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { ActorError, requireActor } from "@/lib/server/actor";
import { clearUsageRecords } from "@/lib/server/usageStore";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function DELETE(request: NextRequest) {
try {
requireActor(request);
} catch (error) {
if (error instanceof ActorError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
throw error;
}
return NextResponse.json(clearUsageRecords(), {
headers: { "Cache-Control": "no-store" },
});
}
+40
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("server-only", () => ({}));
@@ -14,6 +15,7 @@ process.env.EVOSCIENTIST_WORKSPACE_ID = "ws1_fixture";
const capabilities = await import("./capabilities/route");
const events = await import("./events/route");
const heartbeat = await import("./sources/heartbeat/route");
const records = await import("./records/route");
const summary = await import("./summary/route");
const store = await import("@/lib/server/usageStore");
const fixture = JSON.parse(
@@ -121,4 +123,42 @@ describe("usage route contract", () => {
expect(response.status).toBe(422);
expect(await response.json()).toEqual({ error: "time_range_too_large" });
});
it("clears all usage records and accepts re-delivered events", async () => {
const window =
"from=2026-07-01T00%3A00%3A00.000Z&to=2026-07-31T00%3A00%3A00.000Z";
const before = await (
await summary.GET(
request(`http://localhost/api/usage/summary?${window}`)
).json()
);
expect(BigInt(before.confirmed_call_count)).toBeGreaterThan(BigInt(0));
const cleared = records.DELETE(
new NextRequest("http://localhost/api/usage/records", {
method: "DELETE",
})
);
expect(cleared.status).toBe(200);
expect(await cleared.json()).toMatchObject({
calls_deleted: expect.any(String),
});
const after = await (
await summary.GET(
request(`http://localhost/api/usage/summary?${window}`)
).json()
);
expect(after.confirmed_call_count).toBe("0");
expect(after.unknown_call_count).toBe("0");
const reingest = await events.POST(
request("http://localhost/api/usage/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fixture),
})
);
expect(await reingest.json()).toMatchObject({ status: "accepted" });
});
});
+53 -17
View File
@@ -5,6 +5,7 @@ import {
ChevronDown,
LoaderCircle,
RefreshCw,
Trash2,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -22,6 +23,7 @@ import {
type UsageSourceStatus,
type UsageView,
usageCallTotal,
clearAllUsageRecords,
useUsageDetails,
} from "@/app/hooks/useUsage";
import { cn } from "@/lib/utils";
@@ -171,10 +173,26 @@ export function UsageDetailsDialog({
statusError: unknown;
}) {
const [view, setView] = useState<UsageView>("thread");
const [clearing, setClearing] = useState(false);
useEffect(() => {
if (open) setView(threadId ? "thread" : "workspace");
}, [open, threadId]);
const clearRecords = async () => {
if (
!window.confirm(
"Delete all recorded token usage for every chat? This cannot be undone."
)
)
return;
setClearing(true);
try {
await clearAllUsageRecords();
} finally {
setClearing(false);
}
};
const unavailable =
Boolean(statusError) ||
!status ||
@@ -289,24 +307,42 @@ export function UsageDetailsDialog({
);
})}
</div>
<Button
type="button"
variant="ghost"
size="icon"
title="Refresh token usage"
aria-label="Refresh token usage"
disabled={details.refreshing}
onClick={() => void details.refresh()}
className="size-8"
>
<RefreshCw
className={cn(
"size-4",
details.refreshing &&
"animate-spin motion-reduce:animate-none"
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
title="Delete all token usage records"
aria-label="Delete all token usage records"
disabled={clearing}
onClick={() => void clearRecords()}
className="size-8 text-muted-foreground hover:text-destructive"
>
{clearing ? (
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="size-4" />
)}
/>
</Button>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Refresh token usage"
aria-label="Refresh token usage"
disabled={details.refreshing}
onClick={() => void details.refresh()}
className="size-8"
>
<RefreshCw
className={cn(
"size-4",
details.refreshing &&
"animate-spin motion-reduce:animate-none"
)}
/>
</Button>
</div>
</div>
</div>
)}
+16 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
import useSWRInfinite from "swr/infinite";
export interface UsageSummary {
@@ -171,8 +171,21 @@ export function useUsageDetails({
};
}
export function usageCallTotal(call: UsageCall): string | null {
if (
/**
* Delete every recorded model call on the server, then revalidate all usage
* SWR keys so the dialog and the composer pill drop to zero.
*/
export async function clearAllUsageRecords(): Promise<void> {
const response = await fetch("/api/usage/records", { method: "DELETE" });
if (!response.ok) throw new Error(`Usage API returned ${response.status}`);
await mutate(
(key) => typeof key === "string" && key.startsWith("/api/usage/"),
undefined,
{ revalidate: true }
);
}
export function usageCallTotal(call: UsageCall): string | null { if (
call.usage_status !== "confirmed" ||
call.input_tokens === null ||
call.output_tokens === null
+19
View File
@@ -272,6 +272,25 @@ export async function backupUsageDatabase(destination: string): Promise<void> {
await usageDb().backup(destination);
}
/**
* Wipe every recorded model call plus its ingest bookkeeping, so a cleared
* database can accept re-delivered spooled events instead of dropping them
* as idempotency duplicates. Heartbeat source health and provider
* reconciliation aggregates are operational state, not usage records, and
* are kept.
*/
export function clearUsageRecords(
database: Database.Database = usageDb()
): { calls_deleted: string } {
return database.transaction(() => {
const calls = database.prepare("DELETE FROM model_usage").run();
database.prepare("DELETE FROM usage_event_conflicts").run();
database.prepare("DELETE FROM usage_event_inbox").run();
database.prepare("DELETE FROM usage_event_idempotency").run();
return { calls_deleted: calls.changes.toString() };
})();
}
function eventParams(event: UsageEventV1, now: string): SqlValue[] {
return [
event.deployment_id,