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>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|