Files
Aether/frontend/src/pages/admin/AuditTab.tsx
T
marcandClaude Opus 5 4bd9a6ca56 fix(thumbnails): EXIF-Drehung auswerten, hochkant blieb quer
Kameras speichern die Bildpunkte fast immer quer und legen die Drehung
als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht.
Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im
Vorschaubild auf der Seite.

Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem
Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek:
Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld.
Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal
im Speicher liegt.

Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes
Foto den Zuschnitt eines Querformats.

Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu:
POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich.

Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:08:10 +02:00

385 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
'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<OcrStatus | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
{/* Der Punkt sagt den Zustand auch ohne Lesen: grün fertig, gelb läuft,
gedämpft aus. */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: !status.enabled
? 'var(--color-text-muted)'
: busy ? 'var(--color-warning)' : 'var(--color-success)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Full-text indexing
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{text}
</div>
</div>
{busy && (
<div style={{
width: 12, height: 12, flexShrink: 0,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
)
}
/** 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<string | null>(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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Storage cleanup
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Removes stored files that no longer belong to any record. Runs automatically every 12 hours.'}
</div>
</div>
<button
onClick={run}
disabled={running}
style={{
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Running…' : 'Run now'}
</button>
</div>
)
}
/** 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<string | null>(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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Rebuild image previews
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Regenerates every stored preview. Needed once: photos taken in portrait used to end up sideways, because the rotation flag was ignored.'}
</div>
</div>
<button
onClick={run}
disabled={running}
style={{
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Rebuilding…' : 'Rebuild'}
</button>
</div>
)
}
export default function AuditTab() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [actions, setActions] = useState<string[]>([])
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 (
<div>
<IndexingStatus />
<StorageCleanup />
<RebuildThumbnails />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<select
value={filter}
onChange={e => { setFilter(e.target.value); setPage(0) }}
style={{
padding: '7px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">All actions</option>
{actions.map(a => <option key={a} value={a}>{actionLabel(a)}</option>)}
</select>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{total} {total === 1 ? 'entry' : 'entries'}
</span>
</div>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Nothing recorded yet.</p>
) : (
<div style={{
border: '1px solid var(--color-border)', borderRadius: 12, overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-surface-2)' }}>
{['When', 'Who', 'Action', 'Target', 'From'].map(h => (
<th key={h} style={{
textAlign: 'left', padding: '9px 12px', fontSize: 11,
fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4,
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id} style={{ borderTop: '1px solid var(--color-border)' }}>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{new Date(e.created_at).toLocaleString()}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: 'var(--color-text)' }}>
{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' && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}> (deleted account)</span>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: actionColor(e.action), fontWeight: 500 }}>
{actionLabel(e.action)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', maxWidth: 280 }}>
{e.target_name || e.detail || '—'}
{e.target_name && e.detail && (
<div style={{ fontSize: 11, opacity: 0.8 }}>{e.detail}</div>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{e.ip ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page === 0 ? 'default' : 'pointer', opacity: page === 0 ? 0.5 : 1,
}}
>
Previous
</button>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
Page {page + 1} of {pages}
</span>
<button
onClick={() => setPage(p => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page >= pages - 1 ? 'default' : 'pointer', opacity: page >= pages - 1 ? 0.5 : 1,
}}
>
Next
</button>
</div>
)}
</div>
)
}