feat: Verlauf -- wer hat wann was abgelegt, ersetzt oder geloescht
Neuer Reiter, und dahinter eine eigene Tabelle. WARUM NICHT EINFACH
media.created_at -- drei Gruende, jeder allein wuerde reichen:
1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg, samt
der Frage, wer es geloescht hat.
2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die
Auskunft, wann die Datei urspruenglich kam.
3. media.token_id ist beim Upload aus dem Dashboard NULL -- dort hat niemand
einen Token, sondern eine Sitzung. Das "von wem" stand also nirgends.
Das ist NICHT das Logging, das die ROADMAP ablehnt: dort geht es um
Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je Upload in
derselben SQLite-Datei, und nach einem halben Jahr raeumt pruneEvents auf.
DER VERLAUF STARTET NICHT LEER. Beim ersten Start wird er aus dem Bestand
nachgetragen (68 Eintraege) -- ein Tab, der am ersten Tag leer ist, obwohl 68
Bilder in der Ablage liegen, sieht aus wie ein kaputter Tab. Die nachgetragenen
Eintraege sind als solche gekennzeichnet: Zeitpunkt stimmt, Urheber steht als
"vor der Aufzeichnung", weil ihn niemand mehr kennt. Die Oberflaeche sagt das
unter der Tabelle, statt es zu verschweigen.
logEvent wirft nie: ein Verlauf, der einen Upload scheitern laesst, waere die
Buchhaltung, die das Geschaeft verhindert.
Nachgemessen: Upload, Ersetzen und Loeschen aus dem Dashboard erscheinen als
user:admin, die Uploads des Fotostudios als token:d4rk_photostudio; beim
Loeschen wird die Groesse VOR dem Loeschen geholt, sonst stuende dort nichts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HardDrive, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react'
|
||||
import { HardDrive, History, Images, KeyRound, LogOut, PieChart, UserCog } from 'lucide-react'
|
||||
import { run, useStore } from './store'
|
||||
import { Anmelden } from './pages/Anmelden'
|
||||
import { Galerie } from './pages/Galerie'
|
||||
import { Speicher } from './pages/Speicher'
|
||||
import { Tokens } from './pages/Tokens'
|
||||
import { Konto } from './pages/Konto'
|
||||
import { Verlauf } from './pages/Verlauf'
|
||||
import { Meldungen } from './components/Meldungen'
|
||||
|
||||
// Ein winziger Router über die History-API statt einer Bibliothek.
|
||||
@@ -16,6 +17,7 @@ import { Meldungen } from './components/Meldungen'
|
||||
|
||||
const seiten = [
|
||||
{ pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
||||
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
|
||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
||||
|
||||
@@ -147,6 +147,28 @@ export type Folders = {
|
||||
wurzel: { files: number; bytes: number }
|
||||
}
|
||||
|
||||
export type EventKind = 'upload' | 'replace' | 'delete'
|
||||
|
||||
export type MediaEvent = {
|
||||
id: number
|
||||
at: number
|
||||
kind: EventKind
|
||||
path: string
|
||||
size: number | null
|
||||
actor_kind: 'token' | 'user' | 'unbekannt'
|
||||
actor: string
|
||||
}
|
||||
|
||||
export type EventsSeite = {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: MediaEvent[]
|
||||
akteure: { actor: string; actor_kind: string; n: number; zuletzt: number }[]
|
||||
/** Ab wann aufgezeichnet wird — null, solange nichts drinsteht. */
|
||||
seit: number | null
|
||||
}
|
||||
|
||||
export type UploadErgebnis = {
|
||||
name: string
|
||||
ok: boolean
|
||||
@@ -190,6 +212,12 @@ export const api = {
|
||||
deleteMediaBulk: (ids: number[]) =>
|
||||
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
|
||||
|
||||
events: (kind: string, actor: string, limit: number, offset: number) =>
|
||||
get<EventsSeite>(
|
||||
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
|
||||
`&actor=${encodeURIComponent(actor)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
|
||||
stats: () => get<Stats>('/api/dash/stats'),
|
||||
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { api, type EventKind, type EventsSeite } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||
|
||||
const PRO_SEITE = 100
|
||||
|
||||
const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Upload }> = {
|
||||
upload: { text: 'hochgeladen', farbe: 'text-gut', kind: Upload },
|
||||
replace: { text: 'ersetzt', farbe: 'text-warn', kind: RefreshCw },
|
||||
delete: { text: 'gelöscht', farbe: 'text-schlecht', kind: Trash2 },
|
||||
}
|
||||
|
||||
export function Verlauf() {
|
||||
const note = useStore((s) => s.note)
|
||||
const [daten, setDaten] = useState<EventsSeite | null>(null)
|
||||
const [laedt, setLaedt] = useState(true)
|
||||
const [seite, setSeite] = useState(0)
|
||||
const [art, setArt] = useState<EventKind | ''>('')
|
||||
const [wer, setWer] = useState('')
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
setLaedt(true)
|
||||
try {
|
||||
setDaten(await api.events(art, wer, PRO_SEITE, seite * PRO_SEITE))
|
||||
} catch (err) {
|
||||
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}, [art, wer, seite, note])
|
||||
|
||||
useEffect(() => {
|
||||
void laden()
|
||||
}, [laden])
|
||||
|
||||
const items = daten?.items ?? []
|
||||
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / PRO_SEITE))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter an={art === ''} onClick={() => { setArt(''); setSeite(0) }} text="Alles" />
|
||||
{(Object.keys(arten) as EventKind[]).map((k) => (
|
||||
<Filter
|
||||
key={k}
|
||||
an={art === k}
|
||||
onClick={() => { setArt(k); setSeite(0) }}
|
||||
text={arten[k].text}
|
||||
/>
|
||||
))}
|
||||
|
||||
<span className="mx-1 h-4 w-px bg-rand" />
|
||||
|
||||
<Filter an={wer === ''} onClick={() => { setWer(''); setSeite(0) }} text="alle" />
|
||||
{daten?.akteure.map((a) => (
|
||||
<Filter
|
||||
key={a.actor}
|
||||
an={wer === a.actor}
|
||||
onClick={() => { setWer(a.actor); setSeite(0) }}
|
||||
text={`${a.actor} (${a.n})`}
|
||||
kind={
|
||||
a.actor_kind === 'token' ? (
|
||||
<KeyRound size={12} />
|
||||
) : a.actor_kind === 'user' ? (
|
||||
<UserCog size={12} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<span className="ml-auto text-sm text-schrift-leise">
|
||||
{laedt ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
`${daten?.total ?? 0} Einträge`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Karte>
|
||||
{items.length === 0 && !laedt ? (
|
||||
<Leer
|
||||
kind={<History size={26} className="text-schrift-leise/50" />}
|
||||
text="Für diese Auswahl gibt es nichts."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-schrift-leise">
|
||||
<th className="pb-2 font-medium">Wann</th>
|
||||
<th className="pb-2 font-medium">Was</th>
|
||||
<th className="pb-2 font-medium">Pfad</th>
|
||||
<th className="pb-2 text-right font-medium">Größe</th>
|
||||
<th className="pb-2 font-medium">Von wem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((e) => {
|
||||
const a = arten[e.kind]
|
||||
const Kind = a.kind
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-rand">
|
||||
<td className="py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
||||
{zeit(e.at)}
|
||||
</td>
|
||||
<td className={`py-1.5 whitespace-nowrap ${a.farbe}`}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Kind size={13} />
|
||||
{a.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-0 truncate py-1.5 font-mono text-xs" title={e.path}>
|
||||
{e.path}
|
||||
</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
|
||||
{e.size === null ? '—' : bytes(e.size)}
|
||||
</td>
|
||||
<td className="py-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className={
|
||||
e.actor_kind === 'unbekannt'
|
||||
? 'text-schrift-leise italic'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{e.actor}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{seiten > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Knopf
|
||||
disabled={seite === 0}
|
||||
onClick={() => setSeite((s) => s - 1)}
|
||||
kind={<ChevronLeft size={15} />}
|
||||
/>
|
||||
<span className="text-sm text-schrift-leise">
|
||||
Seite {seite + 1} von {seiten}
|
||||
</span>
|
||||
<Knopf
|
||||
disabled={seite + 1 >= seiten}
|
||||
onClick={() => setSeite((s) => s + 1)}
|
||||
kind={<ChevronRight size={15} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Was der Verlauf NICHT weiß, gehört dazugesagt. Ein Tab, der so
|
||||
tut, als wüsste er alles, ist schlimmer als einer, der seine
|
||||
Grenze nennt. */}
|
||||
<p className="text-xs text-schrift-leise">
|
||||
Aufgezeichnet wird seit {daten?.seit ? zeit(daten.seit) : '—'}. Einträge mit
|
||||
„vor der Aufzeichnung" wurden nachträglich aus dem Bestand erschlossen — ihr
|
||||
Zeitpunkt stimmt, aber wer sie abgelegt hat, weiß niemand mehr. Der Verlauf
|
||||
behält ein halbes Jahr.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Filter({
|
||||
an,
|
||||
onClick,
|
||||
text,
|
||||
kind,
|
||||
}: {
|
||||
an: boolean
|
||||
onClick: () => void
|
||||
text: string
|
||||
kind?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs
|
||||
transition-colors ${
|
||||
an
|
||||
? 'border-akzent bg-akzent/15 text-schrift'
|
||||
: 'border-rand text-schrift-leise hover:text-schrift'
|
||||
}`}
|
||||
>
|
||||
{kind}
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user