Files
siftlode/frontend/src/components/ActivityQuota.tsx
T
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
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.
2026-10-18 15:38:32 +02:00

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>
);
}