import { useState, useEffect } from 'react' import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client' import { formatBytes } from './ui' // Audit-Log, Speicher-Bereinigung und der Stand der Texterkennung – // alles, was im Hintergrund läuft und sonst unbeobachtbar wäre. // ── Audit Tab ───────────────────────────────────────────────────────────────── const PAGE_SIZE = 50 /** Aktionsnamen sind maschinenlesbar (`admin.user_delete`). Hier stehen die * lesbaren Entsprechungen – was nicht drinsteht, wird aus dem Schlüssel * hergeleitet, damit neue Aktionen nicht als leere Zeile erscheinen. */ const ACTION_LABELS: Record = { 'auth.login': 'Signed in', 'auth.login_failed': 'Sign-in failed', 'auth.logout': 'Signed out', 'auth.password_change': 'Changed password', 'app_password.create': 'Created app password', 'app_password.revoke': 'Revoked app password', 'admin.user_create': 'Created user', 'admin.user_update': 'Updated user', 'admin.user_delete': 'Deleted user', 'admin.user_password_reset': 'Reset a password', 'admin.group_create': 'Created group', 'admin.group_update': 'Updated group', 'admin.group_delete': 'Deleted group', 'admin.group_member_add': 'Added group member', 'admin.group_member_remove': 'Removed group member', 'admin.settings_update': 'Changed settings', 'share.create': 'Created share link', 'share.delete': 'Deleted share link', 'file.delete': 'Moved file to trash', 'file.restore': 'Restored file', 'file.trash_empty': 'Emptied trash', 'file.hard_delete': 'Permanently deleted file', 'system.gc': 'Cleaned up orphaned data', } function actionLabel(action: string): string { return ACTION_LABELS[action] ?? action.split('.').pop()?.replace(/_/g, ' ') ?? action } /** Fehlgeschlagene Anmeldungen und endgültige Löschungen sind das, wonach man * im Protokoll sucht – die bekommen Farbe, der Rest bleibt ruhig. */ function actionColor(action: string): string { if (action === 'auth.login_failed') return 'var(--color-danger)' if (action.includes('delete')) return 'var(--color-warning)' return 'var(--color-text-muted)' } /** * Stand der Texterkennung. * * Ohne diese Anzeige ist nicht zu unterscheiden, ob die Volltextsuche nichts * findet oder den Bestand noch gar nicht gelesen hat. Nach dem ersten Start * läuft die Erkennung je nach Menge stundenlang – bei einem Scan rund eine * Minute je Seite –, und eine Suche ohne Treffer sieht in dieser Zeit aus wie * ein Fehler. */ function IndexingStatus() { const [status, setStatus] = useState(null) const [failed, setFailed] = useState(false) useEffect(() => { let alive = true let timer: ReturnType async function tick() { try { const s = await adminApi.ocrStatus() if (!alive) return setStatus(s) setFailed(false) // Nur nachfragen, solange sich etwas bewegt. Steht die Warteschlange // still, ändert sich die Zahl erst beim nächsten Upload – dann wäre // jede weitere Anfrage verschenkt. const busy = (s.pending ?? 0) + (s.running ?? 0) > 0 if (busy) timer = setTimeout(tick, 5000) } catch { if (alive) setFailed(true) } } tick() return () => { alive = false; clearTimeout(timer) } }, []) if (failed || !status) return null const pending = status.pending ?? 0 const running = status.running ?? 0 const indexed = status.indexed ?? 0 const bad = status.failed ?? 0 const busy = pending + running > 0 let text: string if (!status.enabled) { text = 'Off — file contents are not searchable. Set OCR_ENABLED and make sure ocrmypdf and tesseract are in the image.' } else if (busy) { text = `${indexed} indexed · ${pending + running} to go — a scanned page takes about a minute.` } else if (indexed === 0) { text = 'No documents indexed yet. PDFs and images are read after upload.' } else { text = `${indexed} ${indexed === 1 ? 'document' : 'documents'} indexed — their contents are searchable.` } if (bad > 0) { text += ` ${bad} could not be read.` } return (
{/* Der Punkt sagt den Zustand auch ohne Lesen: grün fertig, gelb läuft, gedämpft aus. */}
Full-text indexing
{text}
{busy && (
)}
) } /** Der GC läuft von selbst alle 12 Stunden. Der Knopf ist da, damit man sieht, * was er tut – ein Aufräumjob, der unbeobachtbar Dateien löscht, ist schwer zu * vertrauen. */ function StorageCleanup() { const [running, setRunning] = useState(false) const [result, setResult] = useState(null) async function run() { setRunning(true) setResult(null) try { const r = await adminApi.runCleanup() setResult( r.deleted > 0 ? `${r.deleted} orphaned ${r.deleted === 1 ? 'file' : 'files'} removed, ${formatBytes(r.freed_bytes)} freed (${r.scanned} scanned)` : `Nothing to clean up — all ${r.scanned} stored files are accounted for`, ) } catch { setResult('Cleanup failed') } finally { setRunning(false) } } return (
Storage cleanup
{result ?? 'Removes stored files that no longer belong to any record. Runs automatically every 12 hours.'}
) } /** Vorschaubilder entstehen beim Hochladen von selbst. Dieser Knopf ist für * den Bestand: Bis heute wurde die EXIF-Drehung nicht ausgewertet, hochkant * aufgenommene Fotos liegen als Vorschaubild deshalb quer. */ function RebuildThumbnails() { const [running, setRunning] = useState(false) const [result, setResult] = useState(null) async function run() { setRunning(true) setResult(null) try { const r = await adminApi.rebuildThumbnails() setResult( r.images === 0 ? 'No images stored yet' : `${r.rebuilt} of ${r.images} previews rebuilt` + (r.failed ? `, ${r.failed} failed` : ''), ) } catch { setResult('Rebuild failed') } finally { setRunning(false) } } return (
Rebuild image previews
{result ?? 'Regenerates every stored preview. Needed once: photos taken in portrait used to end up sideways, because the rotation flag was ignored.'}
) } export default function AuditTab() { const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) const [actions, setActions] = useState([]) const [filter, setFilter] = useState('') const [page, setPage] = useState(0) const [loading, setLoading] = useState(true) useEffect(() => { adminApi.listAuditActions().then(a => setActions(a ?? [])).catch(() => {}) }, []) useEffect(() => { setLoading(true) adminApi.listAudit({ action: filter || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE }) .then(res => { setEntries(res.entries ?? []); setTotal(res.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [filter, page]) const pages = Math.ceil(total / PAGE_SIZE) return (
{total} {total === 1 ? 'entry' : 'entries'}
{loading ? (

Loading…

) : entries.length === 0 ? (

Nothing recorded yet.

) : (
{['When', 'Who', 'Action', 'Target', 'From'].map(h => ( ))} {entries.map(e => ( ))}
{h}
{new Date(e.created_at).toLocaleString()} {e.actor_name} {/* Fehlt die actor_id, ist das Konto gelöscht – der Name bleibt stehen, damit der Eintrag lesbar bleibt. Bei einem Fehlversuch auf einen unbekannten Namen gab es dagegen nie ein Konto, „gelöscht" wäre dort schlicht falsch. */} {e.actor_id === null && e.actor_name !== 'system' && e.action !== 'auth.login_failed' && ( (deleted account) )} {actionLabel(e.action)} {e.target_name || e.detail || '—'} {e.target_name && e.detail && (
{e.detail}
)}
{e.ip ?? '—'}
)} {pages > 1 && (
Page {page + 1} of {pages}
)}
) }