siftlode/frontend/src/lib/api.ts
npeter83 f255728f75 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.
2026-06-12 02:47:55 +02:00

262 lines
7.9 KiB
TypeScript

import { notify } from "./notifications";
export interface Me {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
role: string;
can_write: boolean;
pending_invites: number;
preferences: Record<string, any>;
}
export interface Invite {
id: number;
email: string;
status: "pending" | "approved" | "denied";
note: string | null;
requested_at: string | null;
decided_at: string | null;
decided_by: string | null;
}
export interface Tag {
id: number;
name: string;
color: string | null;
category: string;
system: boolean;
channel_count: number;
}
export interface Video {
id: string;
title: string | null;
channel_id: string;
channel_title: string | null;
channel_thumbnail: string | null;
channel_url: string;
published_at: string | null;
thumbnail_url: string | null;
duration_seconds: number | null;
view_count: number | null;
is_short: boolean;
live_status: string;
status: string;
watch_url: string;
}
export interface FeedResponse {
items: Video[];
has_more: boolean;
offset: number;
limit: number;
}
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
q: string;
sort: string;
includeNormal: boolean;
includeShorts: boolean;
includeLive: boolean;
show: string;
channelId?: string;
channelName?: string;
maxAgeDays?: number;
minDuration?: number;
maxDuration?: number;
dateFrom?: string;
dateTo?: string;
}
class HttpError extends Error {
status: number;
constructor(status: number) {
super(`HTTP ${status}`);
this.status = status;
}
}
// Collapse bursts of connection failures (e.g. while the server restarts) into a
// single notification rather than one per failed request.
let lastErrorNotifiedAt = 0;
function notifyErrorThrottled(title: string, message: string): void {
const now = Date.now();
if (now - lastErrorNotifiedAt < 30_000) return;
lastErrorNotifiedAt = now;
notify({ level: "error", title, message });
}
async function req(url: string, opts: RequestInit = {}): Promise<any> {
const method = opts.method ?? "GET";
let r: Response;
try {
r = await fetch(url, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
} catch (e) {
notifyErrorThrottled(
"Connection lost",
"Couldn't reach the server — it may be restarting. This will clear once it's back."
);
throw e;
}
if (!r.ok) {
// Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
if (r.status >= 500) {
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
}
throw new HttpError(r.status);
}
return r.status === 204 ? null : r.json();
}
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
if (f.q) p.set("q", f.q);
p.set("sort", f.sort);
p.set("show_normal", String(f.includeNormal));
p.set("include_shorts", String(f.includeShorts));
p.set("include_live", String(f.includeLive));
p.set("show", f.show);
if (f.channelId) p.set("channel_id", f.channelId);
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.dateFrom) p.set("published_after", f.dateFrom);
if (f.dateTo) p.set("published_before", f.dateTo);
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
p.set("offset", String(offset));
p.set("limit", String(limit));
return p.toString();
}
export interface SyncStatus {
subscriptions: number;
channels_total: number;
channels_backfilling: number;
videos_total: number;
pending_enrich: number;
quota_used_today: number;
quota_remaining_today: number;
paused: boolean;
is_admin: boolean;
}
export interface MyStatus {
channels_total: number;
channels_details_synced: number;
channels_recent_synced: number;
channels_deep_done: number;
channels_recent_pending: number;
channels_deep_pending: number;
channels_deep_requested: number;
deep_pending_count: number;
deep_eta_seconds: number;
my_videos: number;
quota_used_today: number;
quota_remaining_today: number;
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;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
stored_videos: number;
priority: number;
hidden: boolean;
deep_requested: boolean;
deep_in_queue: boolean;
tag_ids: number[];
details_synced: boolean;
recent_synced: boolean;
backfill_done: boolean;
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
// --- channel manager ---
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
updateChannel: (
id: string,
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deepAll: (on = true) =>
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
unsubscribeChannel: (id: string) =>
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 }) }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) =>
req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
// --- user tags ---
createTag: (t: { name: string; color?: string; category?: string }) =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
};
export { HttpError };