From 8c11acaa33d0becc2add80b063d2a94de202f981 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 31 May 2026 11:31:20 -0300 Subject: [PATCH] feat(quota): per-pool usage-log endpoint + card --- .../costs/quota-share/components/PoolCard.tsx | 4 + .../quota-share/components/UsageLogCard.tsx | 114 ++++++++ src/app/api/quota/pools/[id]/log/route.ts | 45 +++ src/i18n/messages/en.json | 4 +- src/i18n/messages/pt-BR.json | 4 +- src/lib/db/quotaConsumption.ts | 81 ++++++ tests/unit/quota-pool-log-route.test.ts | 273 ++++++++++++++++++ 7 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx create mode 100644 src/app/api/quota/pools/[id]/log/route.ts create mode 100644 tests/unit/quota-pool-log-route.test.ts diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx index ba8acdb9f4..8e8680c95e 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx @@ -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({ )} + + {/* Usage log — collapsible, collapsed by default */} + ); } diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx new file mode 100644 index 0000000000..e264ff93a8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx @@ -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; +} + +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([]); + 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 ( +
+ + + {open && ( +
+ {!loaded ? ( +
{t("loading")}
+ ) : events.length === 0 ? ( +
{t("logEmpty")}
+ ) : ( +
+ {events.map((ev, i) => ( +
+ + {formatTime(ev.updatedAt)} + + + {keyLabel(ev.apiKeyId)} + + · + {ev.unit} + · + + {ev.consumed.toFixed(0)} {ev.window} + +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/api/quota/pools/[id]/log/route.ts b/src/app/api/quota/pools/[id]/log/route.ts new file mode 100644 index 0000000000..84ef6ea6a9 --- /dev/null +++ b/src/app/api/quota/pools/[id]/log/route.ts @@ -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 ":" (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 { + 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 }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8ed99aa60c..383f45eb9a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index e914c9f982..5c6111051f 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/src/lib/db/quotaConsumption.ts b/src/lib/db/quotaConsumption.ts index 992a3f82e1..adb66e9f58 100644 --- a/src/lib/db/quotaConsumption.ts +++ b/src/lib/db/quotaConsumption.ts @@ -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 "::") and the unit/window + * fragments are surfaced separately. + */ +export interface ConsumptionEvent { + apiKeyId: string; + /** The full dimension_key string: "::" */ + dimensionKey: string; + /** The segment of dimension_key (e.g. "tokens", "requests", "usd") */ + unit: string; + /** The 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 "::"), 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: "::" + // The LIKE pattern uses "%" — escape literal "%" or "_" in poolId defensively. + const prefix = poolId.replace(/[%_\\]/g, "\\$&") + ":%"; + const rows = getDb() + .prepare( + `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. diff --git a/tests/unit/quota-pool-log-route.test.ts b/tests/unit/quota-pool-log-route.test.ts new file mode 100644 index 0000000000..7f94cc37cb --- /dev/null +++ b/tests/unit/quota-pool-log-route.test.ts @@ -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 { + assert.ok( + poolCardSrc.includes("" + ); +}); + +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>; + 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>; + 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>; + const pt = JSON.parse(readFileSync(PT_PATH, "utf8")) as Record>; + 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}`); + } +});