Files
siftlode/frontend/src/components/downloadAdmin.tsx
T

238 lines
8.2 KiB
TypeScript

import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";
import Modal from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api";
import { formatBytes } from "../lib/format";
import { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
const GiB = 1024 ** 3;
function QuotaModal({
userId,
email,
onClose,
}: {
userId: number;
email: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({
queryKey: qk.dlQuota(userId),
queryFn: () => api.adminGetDownloadQuota(userId),
});
const [form, setForm] = useState<{
gb: number;
jobs: number;
conc: number;
unlimited: boolean;
} | null>(null);
const cur = q.data;
const state =
form ??
(cur
? {
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
jobs: cur.max_jobs,
conc: cur.max_concurrent,
unlimited: cur.unlimited,
}
: null);
const done = () => {
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: qk.adminStorage() });
onClose();
};
const save = useMutation({
mutationFn: () =>
api.adminSetDownloadQuota(userId, {
max_bytes: Math.round(state!.gb * GiB),
max_jobs: state!.jobs,
max_concurrent: state!.conc,
unlimited: state!.unlimited,
}),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuota" },
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
return (
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<Input
className="w-full"
type="number"
value={state.gb}
onChange={(e) => upd({ gb: Number(e.target.value) })}
disabled={state.unlimited}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<Input
className="w-full"
type="number"
value={state.jobs}
onChange={(e) => upd({ jobs: Number(e.target.value) })}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<Input
className="w-full"
type="number"
value={state.conc}
onChange={(e) => upd({ conc: Number(e.target.value) })}
/>
</label>
</div>
<CheckRow
checked={state.unlimited}
onChange={(checked) => upd({ unlimited: checked })}
className="text-sm"
>
{t("downloads.admin.unlimited")}
</CheckRow>
<div className="flex justify-between pt-1">
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
{t("downloads.admin.reset")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={save.isPending}
>
{t("downloads.admin.save")}
</Button>
</div>
</div>
</Modal>
);
}
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
// footprint list below only shows users who already have files).
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = useMemo(() => (users.data ?? []).filter((u) => !u.is_demo), [users.data]);
return (
<div className="w-full sm:w-72">
<UserPicker
users={list}
loading={users.isLoading}
onPick={(u) => onPick({ id: u.id, email: u.email })}
label={t("downloads.admin.quotaPickPlaceholder")}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
/>
</div>
);
}
export function AdminSystem() {
const { t } = useTranslation();
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data;
return (
<div>
{s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[t("downloads.admin.readyFiles"), String(s.ready_files)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
[
t("downloads.admin.cap"),
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
],
].map(([label, val]) => (
<div key={label} className={`${SURFACE.card} p-3`}>
<div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div>
</div>
))}
</div>
)}
{s && (
<div className="mb-6">
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<QuotaUserPicker onPick={setQuotaFor} />
</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
label={t("downloads.admin.editQuota")}
>
<Settings2 />
</IconButton>
</div>
))}
{s.per_user.length === 0 && (
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
)}
</div>
</div>
)}
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
<div className="space-y-1.5">
{(jobs.data ?? []).map((j) => (
<div
key={j.id}
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{jobTitle(j)}</span>
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
</span>
</div>
))}
{jobs.data && jobs.data.length === 0 && (
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
)}
</div>
{quotaFor && (
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
)}
</div>
);
}