feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,329 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 />
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user