diff --git a/server/src/db.ts b/server/src/db.ts index f8cf9ff..8cdf117 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -62,8 +62,62 @@ CREATE TABLE IF NOT EXISTS media ( updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC); + +-- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht. +-- +-- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende, +-- und 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 bei einem 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 in der ROADMAP abgelehnt wird -- dort geht es +-- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je +-- Upload in derselben SQLite-Datei. +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + -- 'upload' | 'replace' | 'delete' + kind TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER, + -- 'token' | 'user' | 'unbekannt' + actor_kind TEXT NOT NULL, + actor TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS events_at ON events(at DESC); `) +// Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur +// wenn er leer ist. +// +// Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der +// Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen +// Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den +// urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von +// frueher weiss niemand mehr etwas. +{ + const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number } + const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number } + + if (vorhanden.n === 0 && medien.n > 0) { + const nachtragen = db.prepare( + `INSERT INTO events (at, kind, path, size, actor_kind, actor) + SELECT m.created_at, 'upload', m.path, m.size, + CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END, + COALESCE(t.name, 'vor der Aufzeichnung') + FROM media m LEFT JOIN tokens t ON t.id = m.token_id`, + ) + const info = nachtragen.run() + console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`) + } +} + export type User = { id: number username: string @@ -92,8 +146,51 @@ export type Media = { updated_at: 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 const now = () => Date.now() +const eventEinfuegen = db.prepare( + `INSERT INTO events (at, kind, path, size, actor_kind, actor) + VALUES (?, ?, ?, ?, ?, ?)`, +) + +/** Einen Eintrag in den Verlauf schreiben. + * + * Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die + * Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht, + * steht es in der Konsole und die Datei liegt trotzdem richtig. */ +export function logEvent( + kind: EventKind, + path: string, + size: number | null, + actorKind: MediaEvent['actor_kind'], + actor: string, +): void { + try { + eventEinfuegen.run(now(), kind, path, size, actorKind, actor) + } catch (err) { + console.error('[events] konnte nicht schreiben:', err) + } +} + +/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das + * 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu + * behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */ +export function pruneEvents(tage = 180) { + db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000) +} + /** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich — * sonst waechst die Tabelle ewig, und niemand merkt es. */ export function pruneSessions() { diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index b04ad35..c6282b5 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -9,7 +9,17 @@ import { Hono } from 'hono' import type { Context } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { config } from '../config.js' -import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js' +import { + db, + logEvent, + now, + pruneEvents, + pruneSessions, + type Media, + type MediaEvent, + type Token, + type User, +} from '../db.js' import { SESSION_COOKIE, createSession, @@ -339,6 +349,7 @@ dashRoutes.delete('/media/:id', async (c) => { await deleteFile(row.path) db.prepare('DELETE FROM media WHERE id = ?').run(id) + logEvent('delete', row.path, row.size, 'user', c.get('user').username) return c.json({ deleted: row.path }) }) @@ -360,6 +371,7 @@ dashRoutes.post('/media/delete', async (c) => { if (!row) continue await deleteFile(row.path) remove.run(id) + logEvent('delete', row.path, row.size, 'user', c.get('user').username) deleted.push(row.path) } @@ -467,6 +479,14 @@ dashRoutes.post('/upload', async (c) => { ).run(path, data.length, digest, mime, zeit, zeit) } + logEvent( + vorhanden ? 'replace' : 'upload', + path, + data.length, + 'user', + c.get('user').username, + ) + ergebnisse.push({ name, ok: true, @@ -498,6 +518,51 @@ dashRoutes.post('/upload', async (c) => { return c.json({ hochgeladen: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse }) }) +/** Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht. + * + * Nur lesen, nie schreiben — geschrieben wird an den Stellen, an denen es + * passiert. Ein Verlauf, den man von aussen fuellen kann, ist keiner. */ +dashRoutes.get('/events', (c) => { + const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500) + const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) + const kind = c.req.query('kind')?.trim() ?? '' + const actor = c.req.query('actor')?.trim() ?? '' + + const bedingungen: string[] = [] + const params: unknown[] = [] + if (kind && ['upload', 'replace', 'delete'].includes(kind)) { + bedingungen.push('kind = ?') + params.push(kind) + } + if (actor) { + bedingungen.push('actor = ?') + params.push(actor) + } + const where = bedingungen.length ? `WHERE ${bedingungen.join(' AND ')}` : '' + + const total = db.prepare(`SELECT COUNT(*) AS n FROM events ${where}`).get(...params) as { + n: number + } + const items = db + .prepare(`SELECT * FROM events ${where} ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`) + .all(...params, limit, offset) as MediaEvent[] + + // Wer ueberhaupt vorkommt -- damit die Oberflaeche einen Filter anbieten + // kann, ohne alle Eintraege durchzublaettern. + const akteure = db + .prepare( + `SELECT actor, actor_kind, COUNT(*) AS n, MAX(at) AS zuletzt + FROM events GROUP BY actor, actor_kind ORDER BY n DESC`, + ) + .all() as { actor: string; actor_kind: string; n: number; zuletzt: number }[] + + // Ab wann aufgezeichnet wird. Alles davor steht nur in der Galerie, und + // das gehoert dazugesagt statt verschwiegen. + const seit = db.prepare('SELECT MIN(at) AS a FROM events').get() as { a: number | null } + + return c.json({ total: total.n, limit, offset, items, akteure, seit: seit.a }) +}) + dashRoutes.get('/stats', (c) => { const media = db .prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media') @@ -578,6 +643,7 @@ dashRoutes.delete('/tokens/:id', (c) => { dashRoutes.post('/maintenance/prune-sessions', (c) => { pruneSessions() + pruneEvents() return c.json({ ok: true }) }) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index e0b11f4..de7c3fd 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -22,7 +22,7 @@ import { Hono } from 'hono' import type { Context, Next } from 'hono' import { config } from '../config.js' -import { db, now, type Media, type Token } from '../db.js' +import { db, logEvent, now, type Media, type Token } from '../db.js' import { tokenAllows, tokenFromHeader } from '../auth.js' import { PathError, @@ -185,6 +185,8 @@ uploadRoutes.post('/upload', async (c) => { ).run(path, data.length, digest, mime, token.id, timestamp, timestamp) } + logEvent(existing ? 'replace' : 'upload', path, data.length, 'token', token.name) + return c.json({ url: publicUrlFor(path), path, @@ -221,8 +223,16 @@ uploadRoutes.delete('/media/:pfad{.+}', async (c) => { } if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404) + // Die Groesse VOR dem Loeschen holen: danach steht sie nirgends mehr, und + // ein Verlaufseintrag ohne Groesse beantwortet die Frage "wie viel Platz + // wurde da frei" nicht. + const zeile = db.prepare('SELECT size FROM media WHERE path = ?').get(path) as + | { size: number } + | undefined + await deleteFile(path) db.prepare('DELETE FROM media WHERE path = ?').run(path) + logEvent('delete', path, zeile?.size ?? null, 'token', token.name) return c.json({ deleted: path }) }) diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 83fd0d2..028827e 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -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 }, diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 2d1e38f..0206991 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -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( + `/api/dash/events?kind=${encodeURIComponent(kind)}` + + `&actor=${encodeURIComponent(actor)}&limit=${limit}&offset=${offset}`, + ), + stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), diff --git a/server/ui/src/pages/Verlauf.tsx b/server/ui/src/pages/Verlauf.tsx new file mode 100644 index 0000000..bce7440 --- /dev/null +++ b/server/ui/src/pages/Verlauf.tsx @@ -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 = { + 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(null) + const [laedt, setLaedt] = useState(true) + const [seite, setSeite] = useState(0) + const [art, setArt] = useState('') + 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 ( +
+
+ { setArt(''); setSeite(0) }} text="Alles" /> + {(Object.keys(arten) as EventKind[]).map((k) => ( + { setArt(k); setSeite(0) }} + text={arten[k].text} + /> + ))} + + + + { setWer(''); setSeite(0) }} text="alle" /> + {daten?.akteure.map((a) => ( + { setWer(a.actor); setSeite(0) }} + text={`${a.actor} (${a.n})`} + kind={ + a.actor_kind === 'token' ? ( + + ) : a.actor_kind === 'user' ? ( + + ) : null + } + /> + ))} + + + {laedt ? ( + + ) : ( + `${daten?.total ?? 0} Einträge` + )} + +
+ + + {items.length === 0 && !laedt ? ( + } + text="Für diese Auswahl gibt es nichts." + /> + ) : ( + + + + + + + + + + + + {items.map((e) => { + const a = arten[e.kind] + const Kind = a.kind + return ( + + + + + + + + ) + })} + +
WannWasPfadGrößeVon wem
+ {zeit(e.at)} + + + + {a.text} + + + {e.path} + + {e.size === null ? '—' : bytes(e.size)} + + + {e.actor} + +
+ )} +
+ + {seiten > 1 && ( +
+ setSeite((s) => s - 1)} + kind={} + /> + + Seite {seite + 1} von {seiten} + + = seiten} + onClick={() => setSeite((s) => s + 1)} + kind={} + /> +
+ )} + + {/* 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. */} +

+ 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. +

+
+ ) +} + +function Filter({ + an, + onClick, + text, + kind, +}: { + an: boolean + onClick: () => void + text: string + kind?: React.ReactNode +}) { + return ( + + ) +}