feat(quota): per-pool usage-log endpoint + card

This commit is contained in:
diegosouzapw
2026-05-31 11:31:20 -03:00
parent 18b615f5f0
commit 8c11acaa33
7 changed files with 523 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ import AllocationTable from "./AllocationTable";
import BurnRateChart from "./BurnRateChart";
import StackedAllocationBar from "./StackedAllocationBar";
import AccountQuotaRow from "./AccountQuotaRow";
import UsageLogCard from "./UsageLogCard";
export interface PoolCardProps {
pool: QuotaPool;
@@ -179,6 +180,9 @@ export default function PoolCard({
<BurnRateChart usage={usage} />
</div>
)}
{/* Usage log — collapsible, collapsed by default */}
<UsageLogCard poolId={pool.id} keyLabels={keyLabels} />
</Card>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import type { ConsumptionEvent } from "@/lib/db/quotaConsumption";
export interface UsageLogCardProps {
poolId: string;
/** Optional map from apiKeyId to display label */
keyLabels?: Record<string, string>;
}
function formatTime(epochMs: number): string {
try {
return new Date(epochMs).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return String(epochMs);
}
}
/**
* UsageLogCard — collapsible footer card that shows the N most-recent
* quota_consumption events for a pool, sourced from
* GET /api/quota/pools/[id]/log.
*
* Fail-soft: on error / loading / no data → renders an empty-state message.
* Never throws; never crashes the parent PoolCard.
*
* Collapsed by default so pool cards stay compact.
*/
export default function UsageLogCard({ poolId, keyLabels }: UsageLogCardProps) {
const t = useTranslations("quotaShare");
const [open, setOpen] = useState(false);
const [events, setEvents] = useState<ConsumptionEvent[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!open) return;
let alive = true;
fetch(`/api/quota/pools/${poolId}/log`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!alive) return;
const raw: unknown = data?.events;
setEvents(Array.isArray(raw) ? (raw as ConsumptionEvent[]) : [] ?? []);
setLoaded(true);
})
.catch(() => {
if (alive) {
setEvents([] ?? []);
setLoaded(true);
}
});
return () => {
alive = false;
};
}, [open, poolId]);
const keyLabel = (apiKeyId: string): string =>
keyLabels?.[apiKeyId] ?? apiKeyId.slice(0, 10) + "…";
return (
<div className="mt-2 pt-2 border-t border-border/30">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center gap-1 text-[10px] uppercase tracking-wide font-bold text-text-muted hover:text-text-main w-full text-left cursor-pointer"
>
<span
className={`material-symbols-outlined text-[13px] transition-transform ${open ? "rotate-90" : ""}`}
>
chevron_right
</span>
{t("logTitle")}
</button>
{open && (
<div className="mt-1.5">
{!loaded ? (
<div className="text-[11px] text-text-muted italic">{t("loading")}</div>
) : events.length === 0 ? (
<div className="text-[11px] text-text-muted italic">{t("logEmpty")}</div>
) : (
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto pr-1">
{events.map((ev, i) => (
<div
key={`${ev.apiKeyId}-${ev.dimensionKey}-${ev.bucketIndex}-${i}`}
className="flex items-center gap-1.5 text-[11px] text-text-muted"
>
<span className="tabular-nums text-text-muted/60 shrink-0 w-[52px]">
{formatTime(ev.updatedAt)}
</span>
<span className="truncate max-w-[80px]" title={ev.apiKeyId}>
{keyLabel(ev.apiKeyId)}
</span>
<span className="text-text-muted/50">·</span>
<span className="truncate max-w-[80px]">{ev.unit}</span>
<span className="text-text-muted/50">·</span>
<span className="tabular-nums text-text-main/80">
{ev.consumed.toFixed(0)} {ev.window}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
/**
* GET /api/quota/pools/[id]/log — recent consumption events for a pool
*
* Returns the most-recent rows from quota_consumption whose dimension_key
* starts with "<poolId>:" (i.e. all buckets that belong to this pool).
*
* Query params:
* limit — integer, default 50, max 200
*
* Auth: requireManagementAuth (management-gated, NOT local-only — read-only data, no spawning)
* Sanitization: all error responses via buildErrorBody (Hard Rule #12)
*
* Part of: Task 7 — Quota Share UX polish (plan 2026-05-31).
*/
import { NextResponse } from "next/server";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { listConsumptionForPool } from "@/lib/db/quotaConsumption";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export async function GET(request: Request, { params }: RouteParams): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const url = new URL(request.url);
const rawLimit = url.searchParams.get("limit");
const parsedLimit = rawLimit !== null ? parseInt(rawLimit, 10) : 50;
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 200)
: 50;
const events = listConsumptionForPool(id, limit);
return NextResponse.json({ events });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get pool log";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}

View File

@@ -7896,7 +7896,9 @@
"wizardSingleProviderNote": "A pool uses a single provider",
"wizardPreviewMoreModels": "+{count} more",
"accountQuotaTitle": "Account quota",
"accountQuotaNone": "—"
"accountQuotaNone": "—",
"logTitle": "Usage log",
"logEmpty": "No usage yet"
},
"plugins": {
"title": "Plugins",

View File

@@ -5406,7 +5406,9 @@
"wizardSingleProviderNote": "Um pool usa um provedor só",
"wizardPreviewMoreModels": "+{count} mais",
"accountQuotaTitle": "Cota da conta",
"accountQuotaNone": "—"
"accountQuotaNone": "—",
"logTitle": "Log de uso",
"logEmpty": "Sem uso ainda"
},
"requestLogger": {
"recording": "Recording",

View File

@@ -36,6 +36,30 @@ interface BucketRow {
consumed: number;
}
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* A single consumption event row, as returned by listConsumptionForPool.
* Derived from the quota_consumption table; the poolId is stripped from
* dimension_key (format "<poolId>:<unit>:<window>") and the unit/window
* fragments are surfaced separately.
*/
export interface ConsumptionEvent {
apiKeyId: string;
/** The full dimension_key string: "<poolId>:<unit>:<window>" */
dimensionKey: string;
/** The <unit> segment of dimension_key (e.g. "tokens", "requests", "usd") */
unit: string;
/** The <window> segment of dimension_key (e.g. "hourly", "daily") */
window: string;
bucketIndex: number;
consumed: number;
/** epoch ms */
updatedAt: number;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -123,6 +147,63 @@ export function getPair(
};
}
// ---------------------------------------------------------------------------
// List recent consumption for a pool
// ---------------------------------------------------------------------------
interface ConsumptionRow {
api_key_id: string;
dimension_key: string;
bucket_index: number;
consumed: number;
updated_at: number;
}
/**
* Return the most-recent consumption rows for a given pool, ordered by
* updated_at DESC. The poolId is the first segment of dimension_key
* (format "<poolId>:<unit>:<window>"), so we filter with a LIKE prefix.
*
* @param poolId The quota pool identifier.
* @param limit Maximum rows to return (caller should clamp; default 50).
* @returns Array of ConsumptionEvent (may be empty if no data yet).
*/
export function listConsumptionForPool(poolId: string, limit: number): ConsumptionEvent[] {
const safeLimit = Math.max(1, Math.min(limit, 500));
// dimension_key format: "<poolId>:<unit>:<window>"
// The LIKE pattern uses "%" — escape literal "%" or "_" in poolId defensively.
const prefix = poolId.replace(/[%_\\]/g, "\\$&") + ":%";
const rows = getDb()
.prepare<ConsumptionRow>(
`SELECT api_key_id, dimension_key, bucket_index, consumed, updated_at
FROM quota_consumption
WHERE dimension_key LIKE ? ESCAPE '\\'
ORDER BY updated_at DESC
LIMIT ?`
)
.all(prefix, safeLimit);
return rows.map((r) => {
const parts = r.dimension_key.split(":");
// parts[0] = poolId, parts[1] = unit, parts[2..] = window (rejoin in case window has colons)
const unit = parts[1] ?? "";
const window = parts.slice(2).join(":") ?? "";
return {
apiKeyId: r.api_key_id,
dimensionKey: r.dimension_key,
unit,
window,
bucketIndex: r.bucket_index,
consumed: r.consumed,
updatedAt: r.updated_at,
};
});
}
// ---------------------------------------------------------------------------
// GC
// ---------------------------------------------------------------------------
/**
* Delete rows whose updated_at is strictly less than maxUpdatedAtMs.
* Used by GC background job to clean up stale bucket rows.

View File

@@ -0,0 +1,273 @@
/**
* tests/unit/quota-pool-log-route.test.ts
*
* Task 7 — source-level assertions for the usage-log endpoint + card.
*
* Follows the same pattern as:
* tests/unit/budget-route-auth.test.ts (source-scan)
* tests/unit/quota-account-quota-row.test.ts (source-scan)
*
* We use source-scan assertions (readFileSync) so the test works with
* Node.js native test runner without a Next.js / DOM setup.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const ROUTE_PATH = join(
ROOT,
"src/app/api/quota/pools/[id]/log/route.ts"
);
const USAGE_LOG_CARD_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx"
);
const POOL_CARD_PATH = join(
ROOT,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx"
);
const CONSUMPTION_DB_PATH = join(
ROOT,
"src/lib/db/quotaConsumption.ts"
);
const EN_PATH = join(ROOT, "src/i18n/messages/en.json");
const PT_PATH = join(ROOT, "src/i18n/messages/pt-BR.json");
// ── Load sources ─────────────────────────────────────────────────────────────
const routeSrc = readFileSync(ROUTE_PATH, "utf8");
const usageLogCardSrc = readFileSync(USAGE_LOG_CARD_PATH, "utf8");
const poolCardSrc = readFileSync(POOL_CARD_PATH, "utf8");
const consumptionDbSrc = readFileSync(CONSUMPTION_DB_PATH, "utf8");
// ── Route: auth ───────────────────────────────────────────────────────────────
test("log route: imports requireManagementAuth", () => {
assert.ok(
routeSrc.includes("requireManagementAuth"),
"route must import and call requireManagementAuth"
);
});
test("log route: calls requireManagementAuth before any data access", () => {
// Find the GET handler body (after "export async function GET")
const handlerIdx = routeSrc.indexOf("export async function GET");
assert.ok(handlerIdx >= 0, "GET handler must exist");
const handlerBody = routeSrc.slice(handlerIdx);
// Within the handler body, auth call must come before listConsumptionForPool call
const authIdx = handlerBody.indexOf("requireManagementAuth(request)");
const listIdx = handlerBody.indexOf("listConsumptionForPool(");
assert.ok(authIdx >= 0, "requireManagementAuth call must be in GET handler");
assert.ok(listIdx >= 0, "listConsumptionForPool call must be in GET handler");
assert.ok(
authIdx < listIdx,
"auth check must come before listConsumptionForPool call"
);
});
test("log route: returns early when authError is truthy", () => {
assert.ok(
routeSrc.includes("if (authError) return authError"),
"route must return authError immediately"
);
});
// ── Route: error sanitization ─────────────────────────────────────────────────
test("log route: uses buildErrorBody from open-sse/utils/error", () => {
assert.ok(
routeSrc.includes("buildErrorBody"),
"route must use buildErrorBody for error responses — Hard Rule #12"
);
assert.ok(
routeSrc.includes("open-sse/utils/error") || routeSrc.includes("@omniroute/open-sse/utils/error"),
"route must import buildErrorBody from open-sse/utils/error"
);
});
test("log route: does NOT put raw err.stack in response (stack trace guard)", () => {
// Ensure err.stack is never used in the response body
assert.ok(
!routeSrc.includes("err.stack"),
"route must not leak err.stack in response"
);
});
// ── Route: response shape ─────────────────────────────────────────────────────
test("log route: returns { events } shape", () => {
assert.ok(
routeSrc.includes("{ events }") || routeSrc.includes("{events}") || routeSrc.includes("events:"),
"route must return { events } in the response body"
);
});
test("log route: calls listConsumptionForPool", () => {
assert.ok(
routeSrc.includes("listConsumptionForPool"),
"route must call listConsumptionForPool from quotaConsumption"
);
});
test("log route: parses limit query param with default 50", () => {
assert.ok(
routeSrc.includes("50"),
"route must use 50 as the default limit"
);
});
test("log route: clamps limit to max 200", () => {
assert.ok(
routeSrc.includes("200"),
"route must clamp limit to a maximum of 200"
);
});
test("log route: has dynamic export (force-dynamic)", () => {
assert.ok(
routeSrc.includes('dynamic = "force-dynamic"') || routeSrc.includes("dynamic = 'force-dynamic'"),
"route must export dynamic = 'force-dynamic'"
);
});
// ── DB module: listConsumptionForPool ─────────────────────────────────────────
test("quotaConsumption: exports listConsumptionForPool", () => {
assert.ok(
consumptionDbSrc.includes("export function listConsumptionForPool"),
"quotaConsumption.ts must export listConsumptionForPool"
);
});
test("quotaConsumption: listConsumptionForPool filters by poolId prefix", () => {
assert.ok(
consumptionDbSrc.includes("LIKE"),
"listConsumptionForPool must use LIKE to filter by poolId prefix in dimension_key"
);
});
test("quotaConsumption: exports ConsumptionEvent type", () => {
assert.ok(
consumptionDbSrc.includes("ConsumptionEvent"),
"quotaConsumption.ts must export the ConsumptionEvent type"
);
});
// ── UsageLogCard structural assertions ────────────────────────────────────────
test("UsageLogCard: fetches /api/quota/pools/${poolId}/log endpoint", () => {
assert.ok(
usageLogCardSrc.includes("/api/quota/pools/") && usageLogCardSrc.includes("/log"),
"UsageLogCard must fetch the /api/quota/pools/[id]/log endpoint"
);
});
test("UsageLogCard: is collapsible (toggle state)", () => {
assert.ok(
usageLogCardSrc.includes("useState") &&
(usageLogCardSrc.includes("open") || usageLogCardSrc.includes("expanded") || usageLogCardSrc.includes("collapsed")),
"UsageLogCard must be collapsible with a toggle state"
);
});
test("UsageLogCard: renders logTitle i18n key", () => {
assert.ok(
usageLogCardSrc.includes("logTitle"),
"UsageLogCard must render the t('logTitle') key"
);
});
test("UsageLogCard: renders logEmpty i18n key for empty state", () => {
assert.ok(
usageLogCardSrc.includes("logEmpty"),
"UsageLogCard must render the t('logEmpty') key"
);
});
test("UsageLogCard: fail-soft — guards events with ?? []", () => {
assert.ok(
usageLogCardSrc.includes("?? []"),
"UsageLogCard must guard events with ?? [] for fail-soft behavior"
);
});
test("UsageLogCard: uses useTranslations for i18n", () => {
assert.ok(
usageLogCardSrc.includes("useTranslations"),
"UsageLogCard must use useTranslations from next-intl"
);
});
test("UsageLogCard: cleans up fetch with alive flag", () => {
assert.ok(
usageLogCardSrc.includes("alive = false"),
"UsageLogCard must set alive = false in useEffect cleanup to prevent state-after-unmount"
);
});
// ── PoolCard mounts UsageLogCard ──────────────────────────────────────────────
test("PoolCard: imports UsageLogCard", () => {
assert.ok(
poolCardSrc.includes("UsageLogCard"),
"PoolCard.tsx must import and render UsageLogCard"
);
});
test("PoolCard: mounts <UsageLogCard in JSX", () => {
assert.ok(
poolCardSrc.includes("<UsageLogCard"),
"PoolCard.tsx must render <UsageLogCard .../>"
);
});
test("PoolCard: passes poolId prop to UsageLogCard", () => {
assert.ok(
poolCardSrc.includes("poolId="),
"PoolCard.tsx must pass poolId prop to UsageLogCard"
);
});
// ── i18n parity ───────────────────────────────────────────────────────────────
const LOG_KEYS = ["logTitle", "logEmpty"] as const;
test("i18n: logTitle and logEmpty present in en.json", () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, Record<string, string>>;
for (const k of LOG_KEYS) {
assert.equal(
typeof en["quotaShare"]?.[k],
"string",
`en.json missing quotaShare.${k}`
);
}
});
test("i18n: logTitle and logEmpty present in pt-BR.json", () => {
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<string, Record<string, string>>;
for (const k of LOG_KEYS) {
assert.equal(
typeof pt["quotaShare"]?.[k],
"string",
`pt-BR.json missing quotaShare.${k}`
);
}
});
test("i18n: parity between en and pt-BR for log keys", () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, Record<string, string>>;
const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record<string, Record<string, string>>;
for (const k of LOG_KEYS) {
assert.ok(k in (en["quotaShare"] ?? {}), `en.json missing quotaShare.${k}`);
assert.ok(k in (pt["quotaShare"] ?? {}), `pt-BR.json missing quotaShare.${k}`);
}
});