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.