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:
@@ -62,8 +62,62 @@ CREATE TABLE IF NOT EXISTS media (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC);
|
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 = {
|
export type User = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
@@ -92,8 +146,51 @@ export type Media = {
|
|||||||
updated_at: number
|
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()
|
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 —
|
/** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich —
|
||||||
* sonst waechst die Tabelle ewig, und niemand merkt es. */
|
* sonst waechst die Tabelle ewig, und niemand merkt es. */
|
||||||
export function pruneSessions() {
|
export function pruneSessions() {
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ import { Hono } from 'hono'
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
import { config } from '../config.js'
|
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 {
|
import {
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -339,6 +349,7 @@ dashRoutes.delete('/media/:id', async (c) => {
|
|||||||
|
|
||||||
await deleteFile(row.path)
|
await deleteFile(row.path)
|
||||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
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 })
|
return c.json({ deleted: row.path })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -360,6 +371,7 @@ dashRoutes.post('/media/delete', async (c) => {
|
|||||||
if (!row) continue
|
if (!row) continue
|
||||||
await deleteFile(row.path)
|
await deleteFile(row.path)
|
||||||
remove.run(id)
|
remove.run(id)
|
||||||
|
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
|
||||||
deleted.push(row.path)
|
deleted.push(row.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +479,14 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
).run(path, data.length, digest, mime, zeit, zeit)
|
).run(path, data.length, digest, mime, zeit, zeit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
vorhanden ? 'replace' : 'upload',
|
||||||
|
path,
|
||||||
|
data.length,
|
||||||
|
'user',
|
||||||
|
c.get('user').username,
|
||||||
|
)
|
||||||
|
|
||||||
ergebnisse.push({
|
ergebnisse.push({
|
||||||
name,
|
name,
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -498,6 +518,51 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
return c.json({ hochgeladen: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
|
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) => {
|
dashRoutes.get('/stats', (c) => {
|
||||||
const media = db
|
const media = db
|
||||||
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
|
.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) => {
|
dashRoutes.post('/maintenance/prune-sessions', (c) => {
|
||||||
pruneSessions()
|
pruneSessions()
|
||||||
|
pruneEvents()
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import type { Context, Next } from 'hono'
|
import type { Context, Next } from 'hono'
|
||||||
import { config } from '../config.js'
|
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 { tokenAllows, tokenFromHeader } from '../auth.js'
|
||||||
import {
|
import {
|
||||||
PathError,
|
PathError,
|
||||||
@@ -185,6 +185,8 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
|
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logEvent(existing ? 'replace' : 'upload', path, data.length, 'token', token.name)
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
url: publicUrlFor(path),
|
url: publicUrlFor(path),
|
||||||
path,
|
path,
|
||||||
@@ -221,8 +223,16 @@ uploadRoutes.delete('/media/:pfad{.+}', async (c) => {
|
|||||||
}
|
}
|
||||||
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
|
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)
|
await deleteFile(path)
|
||||||
db.prepare('DELETE FROM media WHERE path = ?').run(path)
|
db.prepare('DELETE FROM media WHERE path = ?').run(path)
|
||||||
|
logEvent('delete', path, zeile?.size ?? null, 'token', token.name)
|
||||||
return c.json({ deleted: path })
|
return c.json({ deleted: path })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { run, useStore } from './store'
|
||||||
import { Anmelden } from './pages/Anmelden'
|
import { Anmelden } from './pages/Anmelden'
|
||||||
import { Galerie } from './pages/Galerie'
|
import { Galerie } from './pages/Galerie'
|
||||||
import { Speicher } from './pages/Speicher'
|
import { Speicher } from './pages/Speicher'
|
||||||
import { Tokens } from './pages/Tokens'
|
import { Tokens } from './pages/Tokens'
|
||||||
import { Konto } from './pages/Konto'
|
import { Konto } from './pages/Konto'
|
||||||
|
import { Verlauf } from './pages/Verlauf'
|
||||||
import { Meldungen } from './components/Meldungen'
|
import { Meldungen } from './components/Meldungen'
|
||||||
|
|
||||||
// Ein winziger Router über die History-API statt einer Bibliothek.
|
// Ein winziger Router über die History-API statt einer Bibliothek.
|
||||||
@@ -16,6 +17,7 @@ import { Meldungen } from './components/Meldungen'
|
|||||||
|
|
||||||
const seiten = [
|
const seiten = [
|
||||||
{ pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
{ pfad: '/', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
||||||
|
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
|
||||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||||
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||||
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
||||||
|
|||||||
@@ -147,6 +147,28 @@ export type Folders = {
|
|||||||
wurzel: { files: number; bytes: number }
|
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 = {
|
export type UploadErgebnis = {
|
||||||
name: string
|
name: string
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -190,6 +212,12 @@ export const api = {
|
|||||||
deleteMediaBulk: (ids: number[]) =>
|
deleteMediaBulk: (ids: number[]) =>
|
||||||
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
|
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'),
|
stats: () => get<Stats>('/api/dash/stats'),
|
||||||
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
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