Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
154 lines
6.0 KiB
TypeScript
154 lines
6.0 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
|
|
import { api, type AdminQuotaRow } from "../lib/api";
|
|
import { sinceOf } from "../lib/activityUrlState";
|
|
import { formatNumber, quotaActionLabel } from "../lib/format";
|
|
import { qk } from "../lib/queryKeys";
|
|
import type { Shared } from "./ActivityRuns";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// Activity → Quota: the shared YouTube API budget's spend over the page's time range — per day
|
|
// (Pacific days, as the budget resets), then per account, each opening to its actions. The
|
|
// account filter narrows both to one account; the text filter has nothing to match here.
|
|
|
|
export default function ActivityQuota({ shared }: { shared: Shared }) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const q = useQuery({
|
|
queryKey: qk.adminQuota({ window: shared.window, user: shared.user }),
|
|
queryFn: () => api.adminQuota(sinceOf(shared.window), shared.user),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
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={q.isPlaceholderData ? "opacity-60" : ""} aria-busy={q.isPlaceholderData}>
|
|
<p className="text-xs text-muted mb-3 leading-relaxed">
|
|
{t("stats.introBefore")}
|
|
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
|
{t("stats.introAfter")}
|
|
</p>
|
|
{q.isError && !data ? (
|
|
<StateMessage tone="error" onRetry={() => void q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading || !data ? (
|
|
<LoadingState label={t("stats.loading")} />
|
|
) : (
|
|
<>
|
|
{q.isError && (
|
|
<StateMessage tone="error" compact onRetry={() => void q.refetch()} className="mb-3">
|
|
{t("activity.stale")}
|
|
</StateMessage>
|
|
)}
|
|
<div className={`${SURFACE.card} p-3 mb-4`}>
|
|
<div className="text-xs text-muted mb-2">
|
|
{t(shared.window === "all" ? "activity.quota.totalAll" : "activity.quota.total", {
|
|
count: grandTotal,
|
|
window: t(`activity.windows.${shared.window}`),
|
|
units: formatNumber(grandTotal, lang),
|
|
})}
|
|
</div>
|
|
{data.daily.length === 0 ? (
|
|
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
|
) : (
|
|
// Each day's figure is in its bar's text too (read out, not shown): the tooltip only
|
|
// reaches a pointer.
|
|
<div
|
|
className="flex items-end gap-0.5 h-24"
|
|
role="list"
|
|
aria-label={t("activity.quota.daily")}
|
|
>
|
|
{data.daily.map((d) => (
|
|
<div
|
|
key={d.day}
|
|
role="listitem"
|
|
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
|
// dimming the bar: an opacity dim reads as "disabled".
|
|
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
|
title={t("stats.dailyTooltip", {
|
|
count: d.total,
|
|
day: d.day,
|
|
units: formatNumber(d.total, lang),
|
|
})}
|
|
>
|
|
<span className="sr-only">
|
|
{t("stats.dailyTooltip", {
|
|
count: d.total,
|
|
day: d.day,
|
|
units: formatNumber(d.total, lang),
|
|
})}
|
|
</span>
|
|
<div
|
|
className="w-full bg-accent rounded-t"
|
|
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
{data.rows.length === 0 ? (
|
|
<div className="text-muted py-4">{t("stats.noUsageInRange")}</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 { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
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={`${SURFACE.card} p-3`}>
|
|
<Pressable
|
|
selectable
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-expanded={open}
|
|
className="w-full flex items-center gap-3"
|
|
>
|
|
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
|
{isSystem ? t("stats.system") : row.email}
|
|
</span>
|
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
|
{formatNumber(row.total, lang)}
|
|
</span>
|
|
</Pressable>
|
|
{/* 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(t, action)}</span>
|
|
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|