Files
youtube-studio-flow/frontend/src/app/(dashboard)/quota-history/page.tsx
T

437 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, AlertCircle, BarChart2, ChevronDown, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { fetchQuotaHistory, type QuotaLogEntry } from '@/lib/api';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const OPERATION_COST: Record<string, string> = {
'videos.update': '50',
'playlistItems.insert': '50',
'playlistItems.delete': '50',
'videos.list': '1',
'playlistItems.list': '1',
'playlists.list': '1',
'channels.list': '1',
};
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString();
}
function operationLabel(op: string) {
const labels: Record<string, string> = {
'videos.update': 'Push to YouTube',
'videos.list': 'Fetch video metadata',
'playlistItems.list': 'List playlist items',
'playlistItems.insert': 'Add to playlist',
'playlistItems.delete': 'Remove from playlist',
'playlists.list': 'List playlists',
'channels.list': 'Fetch channel info',
};
return labels[op] ?? op;
}
const DAYS_OPTIONS = [
{ value: 1, label: 'Today' },
{ value: 7, label: 'Last 7 days' },
{ value: 30, label: 'Last 30 days' },
{ value: 90, label: 'Last 90 days' },
];
function groupByOperation(items: QuotaLogEntry[]) {
const map = new Map<string, { count: number; units: number }>();
for (const item of items) {
const existing = map.get(item.operation) ?? { count: 0, units: 0 };
map.set(item.operation, { count: existing.count + 1, units: existing.units + item.units });
}
return Array.from(map.entries())
.map(([op, stats]) => ({ op, ...stats }))
.sort((a, b) => b.units - a.units);
}
// ── Action group clustering ──────────────────────────────────────────────────
const ACTION_TYPE_LABELS: Record<string, string> = {
channel_import: 'Channel Import',
video_sync: 'Push to YouTube',
video_refresh: 'Refresh from YouTube',
playlist_sync: 'Playlist Sync',
playlist_add: 'Add to Playlist',
playlist_remove: 'Remove from Playlist',
};
interface ActionGroup {
actionId: string | null;
channelId: string | null;
channelName: string | null;
startTs: number;
totalUnits: number;
entries: QuotaLogEntry[];
label: string;
detail: string | null;
videoId: string | null;
youtubeVideoId: string | null;
}
function buildLabel(
actionType: string | null,
entries: QuotaLogEntry[],
): { label: string; detail: string | null; videoId: string | null; youtubeVideoId: string | null } {
const label = (actionType && ACTION_TYPE_LABELS[actionType]) ?? operationLabel(entries[0]?.operation ?? '');
if (actionType === 'video_sync' || actionType === 'video_refresh') {
const e = entries.find((e) => e.videoTitle ?? e.videoId);
return {
label,
detail: e?.videoTitle ?? e?.videoId ?? null,
videoId: e?.videoId ?? null,
youtubeVideoId: e?.youtubeVideoId ?? null,
};
}
if (actionType === 'playlist_add' || actionType === 'playlist_remove') {
const e = entries.find((e) => e.entityLabel);
return { label, detail: e?.entityLabel ?? null, videoId: null, youtubeVideoId: null };
}
return { label, detail: null, videoId: null, youtubeVideoId: null };
}
function groupByActionId(items: QuotaLogEntry[]): ActionGroup[] {
if (!items.length) return [];
const map = new Map<string, ActionGroup>();
for (const entry of items) {
const key = entry.actionId ?? `ungrouped:${entry.id}`;
const ts = new Date(entry.createdAt).getTime();
if (!map.has(key)) {
map.set(key, {
actionId: entry.actionId,
channelId: entry.channelId,
channelName: entry.channelName,
startTs: ts,
totalUnits: 0,
entries: [],
label: '',
detail: null,
videoId: null,
youtubeVideoId: null,
});
}
const group = map.get(key)!;
group.totalUnits += entry.units;
group.entries.push(entry);
if (ts < group.startTs) group.startTs = ts;
}
const groups = Array.from(map.values());
for (const group of groups) {
const actionType = group.entries[0]?.actionType ?? null;
const { label, detail, videoId, youtubeVideoId } = buildLabel(actionType, group.entries);
group.label = label;
group.detail = detail;
group.videoId = videoId;
group.youtubeVideoId = youtubeVideoId;
group.entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
const sorted = groups.sort((a, b) => b.startTs - a.startTs);
return clusterVideoGroups(sorted);
}
// Merge action groups that share the same videoId and are within 60 seconds of each other.
// This handles the common case where a video push and playlist changes are logged as separate
// actionIds but belong to the same user-initiated save action.
function clusterVideoGroups(groups: ActionGroup[]): ActionGroup[] {
const result: ActionGroup[] = [];
const used = new Set<number>();
for (let i = 0; i < groups.length; i++) {
if (used.has(i)) continue;
const g = groups[i];
if (!g.videoId) {
result.push(g);
continue;
}
const toMerge: ActionGroup[] = [g];
for (let j = i + 1; j < groups.length; j++) {
if (used.has(j)) continue;
const other = groups[j];
if (other.videoId !== g.videoId) continue;
// groups are sorted newest-first so g.startTs >= other.startTs
if (g.startTs - other.startTs > 60_000) continue;
toMerge.push(other);
used.add(j);
}
if (toMerge.length === 1) {
result.push(g);
} else {
const allEntries = toMerge
.flatMap((m) => m.entries)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const totalUnits = toMerge.reduce((s, m) => s + m.totalUnits, 0);
const startTs = Math.min(...toMerge.map((m) => m.startTs));
// Prefer the video_sync group's label if present; otherwise keep the first
const primary = toMerge.find((m) => m.entries.some((e) => e.actionType === 'video_sync')) ?? toMerge[0];
result.push({ ...primary, entries: allEntries, totalUnits, startTs });
}
}
return result;
}
// ── Components ───────────────────────────────────────────────────────────────
function ActionGroupRow({ group }: { group: ActionGroup }) {
const [open, setOpen] = useState(false);
const startIso = new Date(group.startTs).toISOString();
return (
<div className={styles.groupBlock}>
<button
className={styles.groupHeader}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.groupChevron}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className={styles.groupTime} title={formatDateTime(startIso)}>
{relativeTime(startIso)}
</span>
<span className={styles.groupLabel}>{group.label}</span>
{group.detail && (
group.videoId ? (
<Link
href={`/videos/${group.videoId}`}
className={styles.groupDetail}
onClick={(e) => e.stopPropagation()}
>
{group.detail}
{group.youtubeVideoId && (
<span className={styles.groupVideoId}>{group.youtubeVideoId}</span>
)}
</Link>
) : (
<span className={styles.groupDetail}>{group.detail}</span>
)
)}
<span className={styles.groupMeta}>
{group.channelName && <span className={styles.groupChannel}>{group.channelName}</span>}
<span className={styles.groupCalls}>{group.entries.length} call{group.entries.length !== 1 ? 's' : ''}</span>
<span className={`${styles.groupUnits} ${group.totalUnits >= 50 ? styles.groupUnitsHigh : ''}`}>
{group.totalUnits} units
</span>
</span>
</button>
{open && (
<div className={styles.groupEntries}>
<table className={styles.subTable}>
<thead>
<tr>
<th>Time</th>
<th>Operation</th>
<th>Detail</th>
<th className={styles.numCol}>Units</th>
</tr>
</thead>
<tbody>
{group.entries.map((entry) => (
<tr key={entry.id} className={styles.subRow}>
<td className={styles.timeCell} title={formatDateTime(entry.createdAt)}>
{relativeTime(entry.createdAt)}
</td>
<td>
<span className={styles.opLabel}>{operationLabel(entry.operation)}</span>
<span className={styles.opCode}>{entry.operation}</span>
</td>
<td className={styles.detailCell}>
{entry.videoId ? (
<Link href={`/videos/${entry.videoId}`} className={styles.videoLink}>
{entry.videoTitle ?? entry.videoId}
{entry.youtubeVideoId && (
<span className={styles.groupVideoId}>{entry.youtubeVideoId}</span>
)}
</Link>
) : entry.entityLabel ? (
<span>{entry.entityLabel}</span>
) : (
<span className={styles.none}></span>
)}
</td>
<td className={`${styles.numCol} ${styles.unitsBadge} ${entry.units >= 50 ? styles.unitsHigh : ''}`}>
{entry.units}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function QuotaHistoryPage() {
const [days, setDays] = useState(7);
const { data, isLoading, isError } = useQuery({
queryKey: ['quotaHistory', days],
queryFn: () => fetchQuotaHistory(days),
refetchInterval: 60_000,
});
const summary = data ? groupByOperation(data.items) : [];
const groups = data ? groupByActionId(data.items) : [];
const limit = 9_000;
const pct = data ? Math.round((data.totalUnits / limit) * 100) : 0;
return (
<div className={styles.container}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}>Logging</span>
<h1>Quota History</h1>
</div>
<div className={styles.headerRight}>
<select
className={`${f.input} ${styles.daySelect}`}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{DAYS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</header>
{/* Summary cards */}
{data && (
<div className={styles.summaryRow}>
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{data.totalUnits.toLocaleString()}</div>
<div className={styles.summaryLabel}>Total units used</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaFill} ${pct > 70 ? styles.quotaFillWarn : ''} ${pct > 90 ? styles.quotaFillError : ''}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className={styles.summaryMeta}>{pct}% of 9,000 daily limit</div>
</div>
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{groups.length.toLocaleString()}</div>
<div className={styles.summaryLabel}>Actions</div>
<div className={styles.summaryMeta}>{data.items.length} API calls total</div>
</div>
{summary[0] && (
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{summary[0].units.toLocaleString()}</div>
<div className={styles.summaryLabel}>Highest cost: {operationLabel(summary[0].op)}</div>
<div className={styles.summaryMeta}>{summary[0].count} calls × {OPERATION_COST[summary[0].op] ?? '?'} units</div>
</div>
)}
</div>
)}
{/* Breakdown by operation */}
{summary.length > 0 && (
<section className="panel">
<h2 className={styles.sectionTitle}>
<BarChart2 size={15} />
Usage by operation
</h2>
<table className={styles.breakdownTable}>
<thead>
<tr>
<th>Operation</th>
<th className={styles.numCol}>Calls</th>
<th className={styles.numCol}>Units each</th>
<th className={styles.numCol}>Total units</th>
<th className={styles.barCol}></th>
</tr>
</thead>
<tbody>
{summary.map(({ op, count, units }) => (
<tr key={op} className={styles.breakdownRow}>
<td>
<span className={styles.opLabel}>{operationLabel(op)}</span>
<span className={styles.opCode}>{op}</span>
</td>
<td className={styles.numCol}>{count.toLocaleString()}</td>
<td className={styles.numCol}>{OPERATION_COST[op] ?? '?'}</td>
<td className={styles.numCol}><strong>{units.toLocaleString()}</strong></td>
<td className={styles.barCol}>
<div className={styles.miniBarTrack}>
<div
className={styles.miniBarFill}
style={{ width: `${Math.round((units / data!.totalUnits) * 100)}%` }}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{/* Action groups */}
<section>
<h2 className={styles.sectionTitle} style={{ marginBottom: 'var(--space-3)' }}>
Actions
{data && <span className={styles.count}>{groups.length}</span>}
</h2>
{isLoading && (
<div className={styles.state}>
<Loader2 size={20} className={styles.spin} />
<span>Loading quota history</span>
</div>
)}
{isError && (
<div className={styles.state}>
<AlertCircle size={18} />
<span>Failed to load quota history.</span>
</div>
)}
{data?.items.length === 0 && (
<div className={styles.empty}>No quota usage recorded for this period.</div>
)}
{groups.length > 0 && (
<div className={styles.groupList}>
{groups.map((group, i) => (
<ActionGroupRow key={i} group={group} />
))}
</div>
)}
</section>
</div>
);
}