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 80cae63811
commit f6375a097e
15 changed files with 451 additions and 26 deletions

View file

@ -0,0 +1,46 @@
"""quota_events: per-spend audit log for per-user quota attribution
Revision ID: 0009_quota_events
Revises: 0008_invites
Create Date: 2026-06-12
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0009_quota_events"
down_revision: Union[str, None] = "0008_invites"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"quota_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("units", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_quota_events_user_id", "quota_events", ["user_id"])
op.create_index("ix_quota_events_action", "quota_events", ["action"])
op.create_index("ix_quota_events_created_at", "quota_events", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_quota_events_created_at", table_name="quota_events")
op.drop_index("ix_quota_events_action", table_name="quota_events")
op.drop_index("ix_quota_events_user_id", table_name="quota_events")
op.drop_table("quota_events")

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings from app.config import settings
from app.routes import admin, channels, feed, health, me, sync, tags from app.routes import admin, channels, feed, health, me, quota, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler
@ -67,6 +67,7 @@ app.include_router(feed.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(quota.router)
# The built SPA (populated by the Docker frontend build stage). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -265,6 +265,25 @@ class ApiQuotaUsage(Base):
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0") units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
class QuotaEvent(Base):
"""Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user
whose action caused the spend; NULL means background/system work (scheduler backfill,
enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail."""
__tablename__ = "quota_events"
id: Mapped[int] = mapped_column(primary_key=True)
# SET NULL on user delete: keep the audit trail (it just becomes system-attributed).
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
action: Mapped[str] = mapped_column(String(40), index=True)
units: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class AppState(Base): class AppState(Base):
"""Single-row global app state (admin-controlled).""" """Single-row global app state (admin-controlled)."""

View file

@ -4,22 +4,52 @@ The whole app shares one daily budget (the API resets at midnight US Pacific tim
Steady-state work (RSS detection is free; enrichment of new videos is cheap) should Steady-state work (RSS detection is free; enrichment of new videos is cheap) should
always be allowed; expensive backfill must yield to the remaining budget. always be allowed; expensive backfill must yield to the remaining budget.
""" """
from datetime import datetime import contextlib
import contextvars
from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings from app.config import settings
from app.models import ApiQuotaUsage from app.models import ApiQuotaUsage, QuotaEvent
_PACIFIC = ZoneInfo("America/Los_Angeles") _PACIFIC = ZoneInfo("America/Los_Angeles")
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
# record_usage. Default = background/system, generic action.
_actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"quota_actor_id", default=None
)
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
"quota_action", default="api"
)
@contextlib.contextmanager
def attribute(actor_id: int | None, action: str):
"""Attribute any quota spent in this block to `actor_id` (None = system) under `action`."""
t1 = _actor_id.set(actor_id)
t2 = _action.set(action)
try:
yield
finally:
_actor_id.reset(t1)
_action.reset(t2)
def pacific_today(): def pacific_today():
return datetime.now(_PACIFIC).date() return datetime.now(_PACIFIC).date()
def pacific_day_start_utc() -> datetime:
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
return start.astimezone(timezone.utc)
def units_used_today(db: Session) -> int: def units_used_today(db: Session) -> int:
row = db.get(ApiQuotaUsage, pacific_today()) row = db.get(ApiQuotaUsage, pacific_today())
return row.units_used if row else 0 return row.units_used if row else 0
@ -34,7 +64,7 @@ def can_spend(db: Session, units: int) -> bool:
def record_usage(db: Session, units: int) -> None: def record_usage(db: Session, units: int) -> None:
"""Atomically add `units` to today's counter (upsert).""" """Atomically add `units` to today's counter (upsert) and log an attribution event."""
if units <= 0: if units <= 0:
return return
day = pacific_today() day = pacific_today()
@ -47,4 +77,6 @@ def record_usage(db: Session, units: int) -> None:
) )
) )
db.execute(stmt) db.execute(stmt)
# Audit detail: who/what spent it (per-user attribution; NULL actor = system).
db.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units))
db.commit() db.commit()

View file

@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota
from app.auth import current_user, has_write_scope from app.auth import current_user, has_write_scope
from app.db import get_db from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
@ -121,6 +122,7 @@ def update_channel(
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep). # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
if deep_turned_on and channel is not None and channel.recent_synced_at is None: if deep_turned_on and channel is not None and channel.recent_synced_at is None:
with quota.attribute(user.id, "backfill_recent"):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
return { return {
@ -152,7 +154,7 @@ def unsubscribe(
detail="No YouTube subscription id on record — sync subscriptions first.", detail="No YouTube subscription id on record — sync subscriptions first.",
) )
try: try:
with YouTubeClient(db, user) as yt: with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt:
yt.delete_subscription(sub.yt_subscription_id) yt.delete_subscription(sub.yt_subscription_id)
except YouTubeError as exc: except YouTubeError as exc:
raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}") raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}")

106
backend/app/routes/quota.py Normal file
View file

@ -0,0 +1,106 @@
"""Quota usage attribution: a user's own consumption + an admin breakdown across users.
The aggregate daily budget lives in ApiQuotaUsage; here we read the per-spend audit log
(QuotaEvent) to attribute units to the user who triggered them (NULL actor = system).
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import quota
from app.auth import current_user
from app.db import get_db
from app.models import QuotaEvent, User
from app.routes.admin import admin_user
router = APIRouter(prefix="/api/quota", tags=["quota"])
def _sum(db: Session, *conds) -> int:
return int(
db.scalar(
select(func.coalesce(func.sum(QuotaEvent.units), 0)).where(*conds)
)
or 0
)
@router.get("/my-usage")
def my_usage(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
now = datetime.now(timezone.utc)
mine = QuotaEvent.user_id == user.id
by_action = {
action: int(units)
for action, units in db.execute(
select(QuotaEvent.action, func.sum(QuotaEvent.units))
.where(mine, QuotaEvent.created_at >= now - timedelta(days=30))
.group_by(QuotaEvent.action)
).all()
}
return {
"today": _sum(db, mine, QuotaEvent.created_at >= quota.pacific_day_start_utc()),
"last_7d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=7)),
"last_30d": _sum(db, mine, QuotaEvent.created_at >= now - timedelta(days=30)),
"all_time": _sum(db, mine),
"by_action": by_action,
}
@router.get("/admin")
def admin_usage(
days: int = 30,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
days = max(1, min(days, 365))
since = datetime.now(timezone.utc) - timedelta(days=days)
totals = db.execute(
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(QuotaEvent.user_id)
).all()
per_action: dict[int | None, dict[str, int]] = {}
for uid, action, units in db.execute(
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(QuotaEvent.user_id, QuotaEvent.action)
).all():
per_action.setdefault(uid, {})[action] = int(units)
uids = [uid for uid, _ in totals if uid is not None]
emails = (
{u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
if uids
else {}
)
rows = [
{
"user_id": uid,
"email": emails.get(uid, "(deleted user)") if uid is not None else "System",
"total": int(total),
"by_action": per_action.get(uid, {}),
}
for uid, total in totals
]
rows.sort(key=lambda r: r["total"], reverse=True)
# Instance-wide daily series in Pacific days (matches the budget reset).
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
daily = [
{"day": str(day), "total": int(units)}
for day, units in db.execute(
select(day_expr, func.sum(QuotaEvent.units))
.where(QuotaEvent.created_at >= since)
.group_by(day_expr)
.order_by(day_expr)
).all()
]
return {"range_days": days, "rows": rows, "daily": daily}

View file

@ -34,6 +34,7 @@ def sync_subscriptions(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "sync_subscriptions"):
result = import_subscriptions(db, user) result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_remaining_today"] = quota.remaining_today(db) result["quota_remaining_today"] = quota.remaining_today(db)
@ -55,6 +56,7 @@ def sync_backfill(
max_channels: int = 25, max_channels: int = 25,
) -> dict: ) -> dict:
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "backfill_recent"):
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_used_estimate"] = quota.units_used_today(db) - before
return result return result
@ -65,6 +67,7 @@ def sync_enrich(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
before = quota.units_used_today(db) before = quota.units_used_today(db)
with quota.attribute(user.id, "enrich"):
enriched = run_enrich(db) enriched = run_enrich(db)
return { return {
"enriched": enriched, "enriched": enriched,

View file

@ -4,6 +4,7 @@ import logging
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from app import quota
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.state import is_sync_paused from app.state import is_sync_paused
@ -42,13 +43,20 @@ def _rss_job() -> None:
def _enrich_job() -> None: def _enrich_job() -> None:
_job("enrich", lambda db: run_enrich(db)) def work(db):
with quota.attribute(None, "enrich"):
return run_enrich(db)
_job("enrich", work)
def _backfill_job() -> None: def _backfill_job() -> None:
# Recent-first for not-yet-synced channels, then deep backfill for the rest. # Recent-first for not-yet-synced channels, then deep backfill for the rest. All
# background spend is attributed to the system (no actor), split by action.
def work(db): def work(db):
with quota.attribute(None, "backfill_recent"):
recent = run_recent_backfill(db, max_channels=25) recent = run_recent_backfill(db, max_channels=25)
with quota.attribute(None, "backfill_deep"):
deep = run_deep_backfill(db, max_channels=10) deep = run_deep_backfill(db, max_channels=10)
return {"recent": recent, "deep": deep} return {"recent": recent, "deep": deep}
@ -64,7 +72,11 @@ def _shorts_job() -> None:
def _subscriptions_job() -> None: def _subscriptions_job() -> None:
_job("subscriptions", run_subscription_resync) def work(db):
with quota.attribute(None, "subscription_resync"):
return run_subscription_resync(db)
_job("subscriptions", work)
def start_scheduler() -> None: def start_scheduler() -> None:

View file

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

View file

@ -1,5 +1,5 @@
import { useRef, useState } from "react"; 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 { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
@ -48,7 +48,9 @@ export default function Header({
/> />
</div> </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"> <div className="flex items-center gap-1">
@ -134,17 +136,24 @@ function AccountMenu({
)} )}
<div className="mt-2 flex flex-col"> <div className="mt-2 flex flex-col">
{page === "channels" ? ( {page !== "feed" && (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}> <button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" /> <Home className="w-4 h-4" />
Feed Feed
</button> </button>
) : ( )}
{page !== "channels" && (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}> <button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" /> <Tv className="w-4 h-4" />
Channels Channels
</button> </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}> <button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Settings 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 { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Invite, type Me } from "../lib/api"; import { api, type Invite, type Me } from "../lib/api";
import { formatEta } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
import { import {
configureNotifications, configureNotifications,
getNotifSettings, getNotifSettings,
@ -322,6 +322,7 @@ function Notifications() {
function Sync({ me }: { me: Me }) { function Sync({ me }: { me: Me }) {
const qc = useQueryClient(); const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data; const s = status.data;
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
@ -392,6 +393,36 @@ function Sync({ me }: { me: Me }) {
)} )}
</Section> </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"> <Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels."> <Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<button <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; 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 { export interface ManagedChannel {
id: string; id: string;
title: string | null; title: string | null;
@ -215,6 +236,10 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }), req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), 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 --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), 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)}`; 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"). */ /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
export function formatEta(seconds: number): string { export function formatEta(seconds: number): string {
if (seconds <= 0) return "done"; if (seconds <= 0) return "done";

View file

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