merge: per-user quota attribution + admin stats page
This commit is contained in:
commit
adeb0a6160
15 changed files with 451 additions and 26 deletions
46
backend/alembic/versions/0009_quota_events.py
Normal file
46
backend/alembic/versions/0009_quota_events.py
Normal 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")
|
||||
|
|
@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||
|
||||
from app import auth
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -67,6 +67,7 @@ app.include_router(feed.router)
|
|||
app.include_router(me.router)
|
||||
app.include_router(channels.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(quota.router)
|
||||
|
||||
# The built SPA (populated by the Docker frontend build stage).
|
||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||
|
|
|
|||
|
|
@ -265,6 +265,25 @@ class ApiQuotaUsage(Base):
|
|||
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):
|
||||
"""Single-row global app state (admin-controlled)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ApiQuotaUsage
|
||||
from app.models import ApiQuotaUsage, QuotaEvent
|
||||
|
||||
_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():
|
||||
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:
|
||||
row = db.get(ApiQuotaUsage, pacific_today())
|
||||
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:
|
||||
"""Atomically add `units` to today's counter (upsert)."""
|
||||
"""Atomically add `units` to today's counter (upsert) and log an attribution event."""
|
||||
if units <= 0:
|
||||
return
|
||||
day = pacific_today()
|
||||
|
|
@ -47,4 +77,6 @@ def record_usage(db: Session, units: int) -> None:
|
|||
)
|
||||
)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota
|
||||
from app.auth import current_user, has_write_scope
|
||||
from app.db import get_db
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
||||
|
|
@ -121,7 +122,8 @@ def update_channel(
|
|||
# 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).
|
||||
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
||||
run_recent_backfill(db, [channel], max_channels=1)
|
||||
with quota.attribute(user.id, "backfill_recent"):
|
||||
run_recent_backfill(db, [channel], max_channels=1)
|
||||
|
||||
return {
|
||||
"id": channel_id,
|
||||
|
|
@ -152,7 +154,7 @@ def unsubscribe(
|
|||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
||||
)
|
||||
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)
|
||||
except YouTubeError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}")
|
||||
|
|
|
|||
106
backend/app/routes/quota.py
Normal file
106
backend/app/routes/quota.py
Normal 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}
|
||||
|
|
@ -34,7 +34,8 @@ def sync_subscriptions(
|
|||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
result = import_subscriptions(db, user)
|
||||
with quota.attribute(user.id, "sync_subscriptions"):
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||
result["quota_remaining_today"] = quota.remaining_today(db)
|
||||
return result
|
||||
|
|
@ -55,7 +56,8 @@ def sync_backfill(
|
|||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||
with quota.attribute(user.id, "backfill_recent"):
|
||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||
return result
|
||||
|
||||
|
|
@ -65,7 +67,8 @@ def sync_enrich(
|
|||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
enriched = run_enrich(db)
|
||||
with quota.attribute(user.id, "enrich"):
|
||||
enriched = run_enrich(db)
|
||||
return {
|
||||
"enriched": enriched,
|
||||
"quota_used_estimate": quota.units_used_today(db) - before,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import logging
|
|||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app import quota
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.state import is_sync_paused
|
||||
|
|
@ -42,14 +43,21 @@ def _rss_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:
|
||||
# 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):
|
||||
recent = run_recent_backfill(db, max_channels=25)
|
||||
deep = run_deep_backfill(db, max_channels=10)
|
||||
with quota.attribute(None, "backfill_recent"):
|
||||
recent = run_recent_backfill(db, max_channels=25)
|
||||
with quota.attribute(None, "backfill_deep"):
|
||||
deep = run_deep_backfill(db, max_channels=10)
|
||||
return {"recent": recent, "deep": deep}
|
||||
|
||||
_job("backfill", work)
|
||||
|
|
@ -64,7 +72,11 @@ def _shorts_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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
frontend/src/components/Stats.tsx
Normal file
123
frontend/src/components/Stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) }),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue