feat(stats): per-user API quota attribution + admin usage page

Track who burned how much YouTube API quota. A QuotaEvent audit log (migration
0009) records every spend with the triggering user (NULL = background/system) and
an action label, set via a request/job-scoped contextvar (quota.attribute) so no
call signatures change. User-initiated work (sync subscriptions, unsubscribe,
opt-in recent backfill, manual enrich) attributes to the user; scheduler work to
System, split by action.

- backend: QuotaEvent model + migration 0009; quota.attribute() contextvar;
  record_usage logs events; entry points wrapped (routes/sync, routes/channels,
  scheduler); GET /api/quota/my-usage + GET /api/quota/admin
- frontend: admin-only Stats page (header nav, page=stats) with daily bars +
  per-user breakdown by action and range picker; 'Your API usage' in Settings ->
  Sync for every user

Verified: attribution + endpoints compute correctly; events are per-user vs System.
This commit is contained in:
npeter83 2026-06-12 02:47:55 +02:00
parent 0dad71cf6e
commit d610647d13
15 changed files with 451 additions and 26 deletions

View file

@ -22,6 +22,7 @@ import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import Channels from "./components/Channels";
import Stats from "./components/Stats";
import SettingsPanel from "./components/SettingsPanel";
import Toaster from "./components/Toaster";
@ -157,9 +158,7 @@ export default function App() {
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "feed" ? (
<Feed filters={filters} setFilters={setFilters} view={view} />
) : (
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
onViewChannel={(id, name) => {
@ -167,6 +166,10 @@ export default function App() {
setPage("feed");
}}
/>
) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats />
) : (
<Feed filters={filters} setFilters={setFilters} view={view} />
)}
</main>
</div>

View file

@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import { BarChart3, Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
@ -48,7 +48,9 @@ export default function Header({
/>
</div>
) : (
<div className="flex-1 text-center text-sm font-semibold">Channel manager</div>
<div className="flex-1 text-center text-sm font-semibold">
{page === "stats" ? "Usage & stats" : "Channel manager"}
</div>
)}
<div className="flex items-center gap-1">
@ -134,17 +136,24 @@ function AccountMenu({
)}
<div className="mt-2 flex flex-col">
{page === "channels" ? (
{page !== "feed" && (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" />
Feed
</button>
) : (
)}
{page !== "channels" && (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
Channels
</button>
)}
{me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" />
Stats
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
Settings

View file

@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Invite, type Me } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, quotaActionLabel } from "../lib/format";
import {
configureNotifications,
getNotifSettings,
@ -322,6 +322,7 @@ function Notifications() {
function Sync({ me }: { me: Me }) {
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
@ -392,6 +393,36 @@ function Sync({ me }: { me: Me }) {
)}
</Section>
<Section title="Your API usage">
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
{usage.data.today.toLocaleString()}
</Row>
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">By action (30d)</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-muted text-sm">No usage yet.</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<button

View file

@ -0,0 +1,123 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { quotaActionLabel } from "../lib/format";
const RANGES = [7, 30, 90] as const;
export default function Stats() {
const [days, setDays] = useState<number>(30);
const q = useQuery({
queryKey: ["admin-quota", days],
queryFn: () => api.adminQuota(days),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">API quota usage</h2>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
days === d
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{d}d
</button>
))}
</div>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
YouTube Data API units attributed by who triggered the spend. <b className="text-fg/80">System</b> is
shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.
</p>
{q.isLoading ? (
<div className="text-muted py-8">Loading</div>
) : !data ? (
<div className="text-muted py-8">No data.</div>
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2">
Daily total ({data.range_days}d · {grandTotal.toLocaleString()} units)
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">No usage in this range.</div>
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
<div
key={d.day}
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
title={`${d.day}: ${d.total.toLocaleString()} units`}
/>
))}
</div>
)}
</div>
{/* Per-user breakdown */}
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">No usage in this range.</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
))
)}
</div>
</>
)}
</div>
);
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
return (
<div className="glass-card rounded-xl p-3">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? "System (background)" : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{row.total.toLocaleString()}
</span>
</button>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
/>
</div>
{open && actions.length > 0 && (
<div className="mt-2 space-y-1 text-xs text-muted">
{actions.map(([action, units]) => (
<div key={action} className="flex items-center justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -165,6 +165,27 @@ export interface MyStatus {
paused: boolean;
}
export interface MyUsage {
today: number;
last_7d: number;
last_30d: number;
all_time: number;
by_action: Record<string, number>;
}
export interface AdminQuotaRow {
user_id: number | null;
email: string;
total: number;
by_action: Record<string, number>;
}
export interface AdminQuota {
range_days: number;
rows: AdminQuotaRow[];
daily: { day: string; total: number }[];
}
export interface ManagedChannel {
id: string;
title: string | null;
@ -215,6 +236,10 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
// --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),

View file

@ -34,6 +34,20 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
const QUOTA_ACTION_LABELS: Record<string, string> = {
sync_subscriptions: "Sync subscriptions",
backfill_recent: "Recent backfill",
backfill_deep: "Full-history backfill",
enrich: "Enrichment",
unsubscribe: "Unsubscribe",
subscription_resync: "Auto subscription resync",
api: "Other",
};
export function quotaActionLabel(action: string): string {
return QUOTA_ACTION_LABELS[action] ?? action;
}
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
export function formatEta(seconds: number): string {
if (seconds <= 0) return "done";

View file

@ -74,12 +74,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
export type Page = "feed" | "channels";
export type Page = "feed" | "channels" | "stats";
export function readPage(): Page {
return new URLSearchParams(window.location.search).get("page") === "channels"
? "channels"
: "feed";
const p = new URLSearchParams(window.location.search).get("page");
return p === "channels" || p === "stats" ? p : "feed";
}
/** Reflect the current filters + page into the address bar without adding history entries. */