feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.
Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:
Datei da, kein Eintrag Eintrag anlegen, samt Ordnerkette,
Pruefsumme und Dateityp
Eintrag da, Datei fort Eintrag entfernen, dazu Vorschaubild und
fruehere Fassungen
von aussen ersetzt Groesse und Pruefsumme uebernehmen
Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.
Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.
Zwei Fallen, die erst der Praxistest zeigte:
- Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
/etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
jede Textdatei waere als application/octet-stream gelandet.
- COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
Text und bricht mit "operator does not exist: uuid = text" ab. Die
Ordnerkette wurde nie angelegt.
Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.
scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fce8053d09
commit
55dcf517f2
12 files changed
+1029
-4
No files matched your search
@@ -103,6 +103,32 @@ export interface PublicShareInfo {
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||
export interface RescanEntry {
|
||||
kind: 'new' | 'orphaned' | 'changed'
|
||||
storage_key: string
|
||||
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||
path: string
|
||||
owner: string
|
||||
size: number
|
||||
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RescanReport {
|
||||
new: number
|
||||
orphaned: number
|
||||
changed: number
|
||||
scanned: number
|
||||
samples: RescanEntry[]
|
||||
aborted?: boolean
|
||||
/** Nur nach dem Anwenden gefüllt. */
|
||||
adopted?: number
|
||||
removed?: number
|
||||
corrected?: number
|
||||
errors?: number
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
@@ -851,6 +877,10 @@ export const adminApi = {
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||
rescan: (apply = false) =>
|
||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import RescanCard from './RescanCard'
|
||||
import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client'
|
||||
import { formatBytes } from './ui'
|
||||
|
||||
@@ -176,6 +177,7 @@ function StorageCleanup() {
|
||||
return (
|
||||
<>
|
||||
<DerivedStorage items={derived} />
|
||||
<RescanCard />
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { adminApi, type RescanReport, type RescanEntry } from '../../api/client'
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const kindLabel: Record<RescanEntry['kind'], string> = {
|
||||
new: 'New on disk',
|
||||
orphaned: 'Record without file',
|
||||
changed: 'Replaced on disk',
|
||||
}
|
||||
|
||||
const kindColor: Record<RescanEntry['kind'], string> = {
|
||||
new: 'var(--color-primary)',
|
||||
orphaned: 'var(--color-danger)',
|
||||
changed: 'var(--color-accent)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgleich zwischen Platte und Datenbank – die Gegenrichtung zur
|
||||
* Speicherbereinigung daneben.
|
||||
*
|
||||
* Bewusst in zwei Schritten: „Check" liest nur, „Apply" ändert. Ein einzelner
|
||||
* Knopf, der sofort loslegt, gäbe niemandem die Gelegenheit, vorher
|
||||
* hinzusehen – und hier werden Einträge angelegt und gelöscht.
|
||||
*/
|
||||
export default function RescanCard() {
|
||||
const [busy, setBusy] = useState<'check' | 'apply' | null>(null)
|
||||
const [report, setReport] = useState<RescanReport | null>(null)
|
||||
const [applied, setApplied] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pending = report ? report.new + report.orphaned + report.changed : 0
|
||||
|
||||
async function run(apply: boolean) {
|
||||
setBusy(apply ? 'apply' : 'check')
|
||||
setError('')
|
||||
try {
|
||||
const r = await adminApi.rescan(apply)
|
||||
setReport(r)
|
||||
setApplied(apply)
|
||||
} catch {
|
||||
setError(apply ? 'Applying failed' : 'Check failed')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Rescan storage
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2, lineHeight: 1.45 }}>
|
||||
{error ? <span style={{ color: 'var(--color-danger)' }}>{error}</span>
|
||||
: report ? summary(report, applied)
|
||||
: 'Finds files placed on disk from outside — restored from a backup or copied in over SFTP — '
|
||||
+ 'and records whose file is gone. Checking changes nothing.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => run(false)}
|
||||
disabled={busy !== null}
|
||||
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: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy === 'check' ? 'Checking…' : 'Check'}
|
||||
</button>
|
||||
{report && !applied && pending > 0 && (
|
||||
<button
|
||||
onClick={() => run(true)}
|
||||
disabled={busy !== null}
|
||||
style={{
|
||||
padding: '7px 16px', borderRadius: 8, flexShrink: 0, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 12, fontWeight: 600,
|
||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy === 'apply' ? 'Applying…' : `Apply (${pending})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{report && report.samples.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 10, maxHeight: 260, overflowY: 'auto',
|
||||
border: '1px solid var(--color-border)', borderRadius: 8,
|
||||
background: 'var(--color-surface)',
|
||||
}}>
|
||||
{report.samples.map(e => (
|
||||
<div key={e.storage_key} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px',
|
||||
borderBottom: '1px solid var(--color-border)', fontSize: 11.5,
|
||||
}}>
|
||||
<span style={{
|
||||
flexShrink: 0, width: 128, color: kindColor[e.kind], fontWeight: 600,
|
||||
}}>
|
||||
{kindLabel[e.kind]}
|
||||
</span>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={e.storage_key}>
|
||||
{e.path}
|
||||
</span>
|
||||
{e.reason
|
||||
? <span style={{ color: 'var(--color-danger)', flexShrink: 0 }}>{e.reason}</span>
|
||||
: <span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{e.kind === 'orphaned' ? '' : formatBytes(e.size)}
|
||||
</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function summary(r: RescanReport, applied: boolean): string {
|
||||
const pending = r.new + r.orphaned + r.changed
|
||||
if (applied) {
|
||||
const teile = [
|
||||
r.adopted ? `${r.adopted} adopted` : '',
|
||||
r.corrected ? `${r.corrected} corrected` : '',
|
||||
r.removed ? `${r.removed} records removed` : '',
|
||||
r.errors ? `${r.errors} failed` : '',
|
||||
].filter(Boolean)
|
||||
return teile.length ? teile.join(', ') : 'Nothing changed'
|
||||
}
|
||||
if (pending === 0) {
|
||||
return `Everything matches — ${r.scanned} stored files checked`
|
||||
}
|
||||
const teile = [
|
||||
r.new ? `${r.new} new on disk` : '',
|
||||
r.changed ? `${r.changed} replaced` : '',
|
||||
r.orphaned ? `${r.orphaned} records without a file` : '',
|
||||
].filter(Boolean)
|
||||
return `${teile.join(', ')} (${r.scanned} checked)`
|
||||
+ (r.aborted ? ' — stopped early, run again' : '')
|
||||
}
|
||||
Reference in new issue
Block a user