feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries, generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never per-row (a scheduler run touching thousands of videos is ONE row). Backend: - migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action, target_type/id, summary, before/after JSON, created_at). - app/audit.py: AuditAction constants (single source of truth, i18n'd on the client) + record() (adds to the caller's txn) + gc(retention_days). - producers wired: role change / suspend / delete / invite approve-deny-add / demo add-remove-reset / discovery purge (admin.py); config set/reset — secret VALUES never logged, key only (config.py); scheduler interval + maintenance tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job() choke point writes ONE run-summary row per run — manual runs + errors always, automatic runs only when they changed something (no no-op-poll spam). - retention: audit_gc scheduler job prunes rows older than audit_retention_days (sysconfig, default 180; 0 = keep forever). - admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails resolved, System for background) + clear (leaves one tamper-evidence row). Frontend: - new admin-only "Audit log" nav page (ScrollText) using the shared DataTable (first admin page to reuse it) — sort/filter/paginate/persist, action select filter, actor text filter, before→after detail, Clear-all with confirm. - AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type. - trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group + scheduler job labels; Audit config group (retention days).
This commit is contained in:
parent
c1d38eea21
commit
318fdc4812
35 changed files with 720 additions and 29 deletions
|
|
@ -73,6 +73,7 @@ const Stats = lazy(() => import("./components/Stats"));
|
|||
const Scheduler = lazy(() => import("./components/Scheduler"));
|
||||
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
|
||||
const AdminUsers = lazy(() => import("./components/AdminUsers"));
|
||||
const AuditLog = lazy(() => import("./components/AuditLog"));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
|
||||
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
|
||||
const Messages = lazy(() => import("./components/Messages"));
|
||||
|
|
@ -828,6 +829,8 @@ export default function App() {
|
|||
<ConfigPanel />
|
||||
) : page === "users" && meQuery.data!.role === "admin" ? (
|
||||
<AdminUsers me={meQuery.data!} />
|
||||
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||
<AuditLog />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : page === "notifications" ? (
|
||||
|
|
|
|||
90
frontend/src/components/AuditLog.tsx
Normal file
90
frontend/src/components/AuditLog.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./DataTable";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
||||
export default function AuditLog() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() });
|
||||
const data = q.data;
|
||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
const present = Array.from(new Set(rows.map((r) => r.action))).sort();
|
||||
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }));
|
||||
}, [rows, t]);
|
||||
const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("audit.clearTitle"),
|
||||
message: t("audit.clearConfirm"),
|
||||
confirmLabel: t("audit.clear"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
clear.mutate();
|
||||
};
|
||||
|
||||
const truncated = !!data && data.total > data.returned;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-5xl w-full mx-auto">
|
||||
<Section card title={t("audit.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
||||
{q.isLoading ? (
|
||||
<div className="text-muted py-8">{t("common.loading")}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => String(r.id)}
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsLeading={
|
||||
<button
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("audit.clear")}
|
||||
</button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
{truncated && (
|
||||
<p className="text-[11px] text-muted mt-2">
|
||||
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { LS } from "../lib/storage";
|
|||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
||||
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||
// Email/SMTP) stay together.
|
||||
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
|
||||
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "audit", "plex"];
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ListVideo,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
|
|
@ -193,6 +194,7 @@ export default function NavSidebar({
|
|||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
||||
{ page: "users", icon: Users, label: t("header.account.users") },
|
||||
{ page: "audit", icon: ScrollText, label: t("header.account.audit") },
|
||||
]
|
||||
: [];
|
||||
|
||||
|
|
|
|||
91
frontend/src/components/auditColumns.tsx
Normal file
91
frontend/src/components/auditColumns.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { relativeTime, formatDate } from "../lib/format";
|
||||
import i18n from "../i18n";
|
||||
import type { AuditRow } from "../lib/api";
|
||||
import type { Column } from "./DataTable";
|
||||
|
||||
function actionLabel(t: TFunction, action: string): string {
|
||||
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||
return t(`auditActions.${action}`, action);
|
||||
}
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
return v == null || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||
function changeText(row: AuditRow): string | null {
|
||||
const { before, after } = row;
|
||||
if (before && after) {
|
||||
return Object.keys(after)
|
||||
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
|
||||
.join(", ");
|
||||
}
|
||||
if (after) {
|
||||
return Object.entries(after)
|
||||
.map(([k, v]) => `${k}: ${fmt(v)}`)
|
||||
.join(", ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
|
||||
* the data (built by the page) so the Action column's select filter only offers real values. */
|
||||
export function auditColumns(
|
||||
t: TFunction,
|
||||
actionOptions: { value: string; label: string }[],
|
||||
): Column<AuditRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "when",
|
||||
header: t("audit.cols.when"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => r.created_at ?? "",
|
||||
cardPrimary: true,
|
||||
render: (r) => (
|
||||
<span
|
||||
className="text-muted tabular-nums"
|
||||
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
|
||||
>
|
||||
{r.created_at ? relativeTime(r.created_at) : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actor",
|
||||
header: t("audit.cols.actor"),
|
||||
sortable: true,
|
||||
sortValue: (r) => r.actor ?? "",
|
||||
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
|
||||
render: (r) =>
|
||||
r.actor ? (
|
||||
<span className="truncate">{r.actor}</span>
|
||||
) : (
|
||||
<span className="text-muted italic">{t("audit.system")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: t("audit.cols.action"),
|
||||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (r) => actionLabel(t, r.action),
|
||||
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
|
||||
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
header: t("audit.cols.details"),
|
||||
render: (r) => {
|
||||
const change = changeText(r);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{r.summary && <div className="truncate">{r.summary}</div>}
|
||||
{change && <div className="text-[11px] text-muted truncate">{change}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
18
frontend/src/i18n/locales/de/audit.json
Normal file
18
frontend/src/i18n/locales/de/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"title": "Audit-Protokoll",
|
||||
"intro": "Ein reines Anhänge-Protokoll der Admin-Aktionen und Scheduler-Laufzusammenfassungen — wer was wann getan hat. Auf Aktionsebene protokolliert, nie pro Element.",
|
||||
"empty": "Noch keine Protokolleinträge.",
|
||||
"system": "System",
|
||||
"clear": "Protokoll leeren",
|
||||
"clearTitle": "Audit-Protokoll leeren?",
|
||||
"clearConfirm": "Alle Protokolleinträge dauerhaft löschen? Das kann nicht rückgängig gemacht werden. (Ein Eintrag, der festhält, dass du es geleert hast, bleibt erhalten.)",
|
||||
"cleared": "{{count}} Protokolleinträge gelöscht",
|
||||
"clearFailed": "Das Protokoll konnte nicht geleert werden.",
|
||||
"truncated": "Die {{returned}} neuesten von {{total}} Einträgen werden angezeigt.",
|
||||
"cols": {
|
||||
"when": "Wann",
|
||||
"actor": "Wer",
|
||||
"action": "Aktion",
|
||||
"details": "Details"
|
||||
}
|
||||
}
|
||||
21
frontend/src/i18n/locales/de/auditActions.json
Normal file
21
frontend/src/i18n/locales/de/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"user_role_change": "Rolle geändert",
|
||||
"user_suspend": "Benutzer gesperrt",
|
||||
"user_unsuspend": "Benutzer reaktiviert",
|
||||
"user_delete": "Benutzer gelöscht",
|
||||
"invite_approve": "Zugriffsanfrage genehmigt",
|
||||
"invite_deny": "Zugriffsanfrage abgelehnt",
|
||||
"invite_add": "E-Mail auf Whitelist",
|
||||
"demo_add": "Demo-E-Mail hinzugefügt",
|
||||
"demo_remove": "Demo-E-Mail entfernt",
|
||||
"demo_reset": "Demo zurückgesetzt",
|
||||
"discovery_purge": "Entdeckung bereinigt",
|
||||
"config_set": "Einstellung geändert",
|
||||
"config_reset": "Einstellung zurückgesetzt",
|
||||
"scheduler_interval": "Job-Intervall geändert",
|
||||
"maintenance_tune": "Wartung angepasst",
|
||||
"sync_pause": "Sync pausiert",
|
||||
"sync_resume": "Sync fortgesetzt",
|
||||
"job_run": "Scheduler-Job ausgeführt",
|
||||
"audit_clear": "Audit-Protokoll geleert"
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"batch": "Stapelgrößen",
|
||||
"explore": "Kanal erkunden",
|
||||
"downloads": "Downloads",
|
||||
"plex": "Plex"
|
||||
"plex": "Plex",
|
||||
"audit": "Audit-Protokoll"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"about": "Über",
|
||||
"signOut": "Abmelden",
|
||||
"addAccount": "Weiteres Konto hinzufügen",
|
||||
"switchTo": "Zu {{name}} wechseln"
|
||||
"switchTo": "Zu {{name}} wechseln",
|
||||
"audit": "Audit-Protokoll"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "deine",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
|
||||
"plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.",
|
||||
"plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.",
|
||||
"plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt."
|
||||
"plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt.",
|
||||
"audit_gc": "Entfernt Audit-Protokolleinträge, die älter als das eingestellte Aufbewahrungsfenster sind (Konfiguration → Audit-Protokoll). Wenn er stoppt, wächst das Protokoll unbegrenzt — harmlos, aber unbeschränkt."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||
|
|
@ -87,7 +88,8 @@
|
|||
"download_gc": "Download-Bereinigung",
|
||||
"plex_sync": "Plex-Bibliothek-Sync",
|
||||
"plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)",
|
||||
"plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)"
|
||||
"plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)",
|
||||
"audit_gc": "Audit-Protokoll-Bereinigung"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Zu synchronisierende Kanäle",
|
||||
|
|
|
|||
18
frontend/src/i18n/locales/en/audit.json
Normal file
18
frontend/src/i18n/locales/en/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"title": "Audit log",
|
||||
"intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.",
|
||||
"empty": "No audit entries yet.",
|
||||
"system": "System",
|
||||
"clear": "Clear log",
|
||||
"clearTitle": "Clear the audit log?",
|
||||
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
|
||||
"cleared": "Cleared {{count}} audit entries",
|
||||
"clearFailed": "Couldn't clear the audit log.",
|
||||
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
||||
"cols": {
|
||||
"when": "When",
|
||||
"actor": "Who",
|
||||
"action": "Action",
|
||||
"details": "Details"
|
||||
}
|
||||
}
|
||||
21
frontend/src/i18n/locales/en/auditActions.json
Normal file
21
frontend/src/i18n/locales/en/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"user_role_change": "Role changed",
|
||||
"user_suspend": "User suspended",
|
||||
"user_unsuspend": "User reinstated",
|
||||
"user_delete": "User deleted",
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
"demo_add": "Demo email added",
|
||||
"demo_remove": "Demo email removed",
|
||||
"demo_reset": "Demo reset",
|
||||
"discovery_purge": "Discovery purged",
|
||||
"config_set": "Setting changed",
|
||||
"config_reset": "Setting reset",
|
||||
"scheduler_interval": "Job interval changed",
|
||||
"maintenance_tune": "Maintenance tuned",
|
||||
"sync_pause": "Sync paused",
|
||||
"sync_resume": "Sync resumed",
|
||||
"job_run": "Scheduler job run",
|
||||
"audit_clear": "Audit log cleared"
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"batch": "Batch sizes",
|
||||
"explore": "Channel explore",
|
||||
"downloads": "Downloads",
|
||||
"plex": "Plex"
|
||||
"plex": "Plex",
|
||||
"audit": "Audit log"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"about": "About",
|
||||
"signOut": "Sign out",
|
||||
"addAccount": "Add another account",
|
||||
"switchTo": "Switch to {{name}}"
|
||||
"switchTo": "Switch to {{name}}",
|
||||
"audit": "Audit log"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "yours",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
||||
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
|
||||
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
|
||||
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back."
|
||||
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
|
||||
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS poll (new uploads)",
|
||||
|
|
@ -87,7 +88,8 @@
|
|||
"download_gc": "Download cleanup",
|
||||
"plex_sync": "Plex library sync",
|
||||
"plex_watch_sync": "Plex watch sync (incremental)",
|
||||
"plex_watch_reconcile": "Plex watch reconcile (full)"
|
||||
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
||||
"audit_gc": "Audit-log cleanup"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Channels to sync",
|
||||
|
|
|
|||
18
frontend/src/i18n/locales/hu/audit.json
Normal file
18
frontend/src/i18n/locales/hu/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"title": "Naplózás",
|
||||
"intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
|
||||
"empty": "Még nincs naplóbejegyzés.",
|
||||
"system": "Rendszer",
|
||||
"clear": "Napló ürítése",
|
||||
"clearTitle": "Üríted a naplót?",
|
||||
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||
"cleared": "{{count}} naplóbejegyzés törölve",
|
||||
"clearFailed": "A naplót nem sikerült üríteni.",
|
||||
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
|
||||
"cols": {
|
||||
"when": "Mikor",
|
||||
"actor": "Ki",
|
||||
"action": "Művelet",
|
||||
"details": "Részletek"
|
||||
}
|
||||
}
|
||||
21
frontend/src/i18n/locales/hu/auditActions.json
Normal file
21
frontend/src/i18n/locales/hu/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"user_role_change": "Szerepkör módosítva",
|
||||
"user_suspend": "Felhasználó felfüggesztve",
|
||||
"user_unsuspend": "Felhasználó visszaállítva",
|
||||
"user_delete": "Felhasználó törölve",
|
||||
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||
"invite_add": "Email fehérlistázva",
|
||||
"demo_add": "Demo email hozzáadva",
|
||||
"demo_remove": "Demo email eltávolítva",
|
||||
"demo_reset": "Demo visszaállítva",
|
||||
"discovery_purge": "Felfedezés ürítve",
|
||||
"config_set": "Beállítás módosítva",
|
||||
"config_reset": "Beállítás visszaállítva",
|
||||
"scheduler_interval": "Job-intervallum módosítva",
|
||||
"maintenance_tune": "Karbantartás hangolva",
|
||||
"sync_pause": "Szinkron szüneteltetve",
|
||||
"sync_resume": "Szinkron folytatva",
|
||||
"job_run": "Ütemező job lefutott",
|
||||
"audit_clear": "Napló ürítve"
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"batch": "Kötegméretek",
|
||||
"explore": "Csatorna-felfedezés",
|
||||
"downloads": "Letöltések",
|
||||
"plex": "Plex"
|
||||
"plex": "Plex",
|
||||
"audit": "Naplózás"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"about": "Névjegy",
|
||||
"signOut": "Kijelentkezés",
|
||||
"addAccount": "Másik fiók hozzáadása",
|
||||
"switchTo": "Váltás erre: {{name}}"
|
||||
"switchTo": "Váltás erre: {{name}}",
|
||||
"audit": "Naplózás"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "tiéd",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
||||
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
|
||||
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
||||
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza."
|
||||
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.",
|
||||
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||
|
|
@ -87,7 +88,8 @@
|
|||
"download_gc": "Letöltések takarítása",
|
||||
"plex_sync": "Plex-könyvtár szinkron",
|
||||
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
|
||||
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)"
|
||||
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
|
||||
"audit_gc": "Napló-karbantartás"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Szinkronra váró csatorna",
|
||||
|
|
|
|||
|
|
@ -926,6 +926,23 @@ export interface AdminUserRow {
|
|||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface AuditRow {
|
||||
id: number;
|
||||
created_at: string | null;
|
||||
actor: string | null; // email of the acting admin; null = System (scheduler/background)
|
||||
action: string; // canonical key, localized on the client via auditActions.<action>
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
summary: string | null;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
}
|
||||
export interface AuditListResult {
|
||||
items: AuditRow[];
|
||||
total: number; // total rows in the log (may exceed `items` if capped)
|
||||
returned: number;
|
||||
}
|
||||
|
||||
// --- download center ---
|
||||
export interface DownloadSpec {
|
||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||
|
|
@ -1386,6 +1403,8 @@ export const api = {
|
|||
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
||||
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
||||
// --- admin: users & roles ---
|
||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const LS = {
|
|||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
configTab: "siftlode.configTab",
|
||||
auditTable: "siftlode.auditTable",
|
||||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
navCollapsed: "siftlode.navCollapsed",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ const PAGES = [
|
|||
"scheduler",
|
||||
"config",
|
||||
"users",
|
||||
"audit",
|
||||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue