feat(webui): clear-all usage records endpoint + dialog action
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user