feat: Verlauf als CSV, Meldung bei Upload mit Drossel, Deckel auf dem Papierkorb

Abschnitt 3 aus docs/ideen.md, bis auf die Sicherungsziele.

VERLAUF ALS CSV. Der Export konnte nur den Bestand; wer nachrechnen wollte,
was ueber einen Zeitraum passiert ist, sass vor einer Tabelle, die nach 500
Zeilen umblaettert. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
DER GERADE OFFEN IST -- zwei Knoepfe nebeneinander waeren die Frage "welchen
von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht.

Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden.
Gemessen an der ausgelieferten Datei: 5371 Zeilen, kein einziges nacktes LF.
Ohne Anmeldung 401 -- die Route liegt hinter dem Waechter, was nach dem
Sitzungs-Fehler von vorgestern ausdruecklich nachgeprueft wurde.

MELDUNG BEI UPLOAD -- die Drossel ist der eigentliche Inhalt. Jeder Upload
schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht EINE
Nachricht raus. Ohne das waere die Funktion ein Schaden: 900 Fahrzeugbilder
ergaeben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
Meldung bekommt, sieht sie nicht mehr.

Gemessen: drei Uploads mit zehn Sekunden Abstand -> eine Meldung ueber drei
Dateien. Ein einzelner Upload -> eine Meldung ueber eine Datei.

Nur Token-Uploads, absichtlich. Was ueber das Dashboard hereinkommt, hat gerade
jemand selbst hochgeladen und bestaetigt bekommen. Zurueckholen aus dem
Papierkorb meldet gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.

Und derselbe Fehler wie bei 'platte' lauerte schon wieder: die Liste der
Anlaesse steht im Server UND in der Oberflaeche, und der Server verwirft beim
Speichern alles, was in seiner Liste fehlt -- ein Kreuzchen, das sich setzen
laesst und beim naechsten Laden weg ist. Diesmal beide angefasst, in beiden
steht jetzt ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
gespeichert.

DECKEL AUF DEM PAPIERKORB. 30 Tage UND 20 GB, was zuerst greift. Wer 200 GB
loescht, haelt sie sonst 30 Tage doppelt und merkt es erst, wenn die Platte
voll ist. Geraeumt wird das AELTESTE zuerst -- dessen Versehen waere am
ehesten schon aufgefallen.

Ueber PAPIERKORB_MAX_MB verstellbar, und das nicht aus Bequemlichkeit: ein
Deckel von 20 GB laesst sich nicht pruefen, ohne 20 GB zu loeschen. Mit
0.0002 MB nachgemessen -- vier Eintraege zu 422 Bytes, drei entfernt, uebrig
blieb der NEUESTE. Danach zurueck auf 20 GB, gemessen 21474836480.

NEBENBEI, DREIMAL DIESELBE SORTE FEHLER: "0.0 MB" fuer 422 Bytes, "0 KB" fuer
106 Bytes, "0.0 MB von 0.0 MB" beim Deckel. Es gab drei Byte-Formatierer, jeder
mit anderer Untergrenze. Jetzt einer in meldung.ts, von Bytes bis GB, exportiert
und von dash.ts mitbenutzt. Nachgemessen ueber alle Stufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 08:25:50 +02:00
co-authored by Claude Opus 5
parent 6a49713821
commit bbb74138c8
10 changed files with 465 additions and 28 deletions
+97 -3
View File
@@ -39,7 +39,7 @@ import { now } from './db.js'
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
* ist. Jetzt ist es eine.
*/
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte'] as const
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte', 'upload'] as const
export const MELDUNG_KEYS = {
webhook: 'discord.webhook',
@@ -152,8 +152,17 @@ export async function melde(
}
}
const groesse = (n: number) =>
n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`
// Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive,
// und die sind nie klein. Ein 200-Byte-Bildschirmfoto wurde zu "0 KB" -- eine
// Zahl, die aussieht, als waere etwas kaputt.
export const groesse = (n: number): string =>
n < 1024
? `${n} Bytes`
: n < 1024 * 1024
? `${Math.round(n / 1024)} KB`
: n < 1024 * 1024 * 1024
? `${(n / 1024 / 1024).toFixed(1)} MB`
: `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
/** Wacht ueber die Sicherung.
*
@@ -319,3 +328,88 @@ export async function pruefePlatte(): Promise<void> {
tag: 'PLATTE',
})
}
// ------------------------------------------------------------ Neues ist da
//
// WOZU. Etwas landet hier und niemand erfaehrt es. Eine Meldung darueber ist
// genau dann nuetzlich, wenn sie selten ist.
//
// UND GENAU DAS IST DAS PROBLEM: ein Serienlauf des Fotostudios legt 900
// Fahrzeugbilder ab. Neunhundert Nachrichten waeren keine Meldung mehr, sondern
// ein Ausfall des Kanals -- Discord drosselt Webhooks, und der Rest ginge
// verloren. Wer danach eine ECHTE Meldung bekommt, sieht sie nicht mehr.
//
// NUR TOKEN-UPLOADS, ABSICHTLICH. Was ueber das Dashboard hereinkommt, hat
// gerade jemand selbst hochgeladen und auf dem Bildschirm bestaetigt bekommen --
// eine Meldung darueber erzaehlt ihm, was er selbst getan hat. Und das
// Zurueckholen aus dem Papierkorb oder das Aufnehmen verwaister Dateien meldet
// gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.
//
// DESHALB WIRD GESAMMELT. Nach der Ruhe kommt EINE Nachricht: wieviel, von wem,
// wohin, mit ein paar Beispielen. Ein Serienlauf ergibt eine Meldung, ein
// einzelnes Bild ergibt eine Meldung -- beide sagen dasselbe, nur mit anderen
// Zahlen.
/** Wie lange nach dem letzten Upload gewartet wird, bevor gemeldet wird.
*
* Zwei Minuten: lang genug, dass ein Serienlauf mit Pausen zwischen den
* Bildern als EIN Vorgang durchgeht, kurz genug, dass ein einzelnes Bild
* nicht gefuehlt verlorengeht. */
const SAMMEL_MS = 120_000
type Gesammelt = { anzahl: number; bytes: number; wer: Set<string>; pfade: string[] }
let gesammelt: Gesammelt | null = null
let zeitgeber: NodeJS.Timeout | null = null
/** Einen Upload vormerken. Meldet NICHT sofort, sondern nach der Ruhe. */
export function meldeUpload(pfad: string, bytes: number, wer: string): void {
// Frueh raus, wenn niemand Upload-Meldungen will: sonst sammelte der
// Dienst 900 Pfade zusammen, um sie am Ende wegzuwerfen.
if (!meldungConfig().anlaesse.includes('upload')) return
if (!gesammelt) gesammelt = { anzahl: 0, bytes: 0, wer: new Set(), pfade: [] }
gesammelt.anzahl++
gesammelt.bytes += bytes
gesammelt.wer.add(wer)
// Nur die ersten paar Pfade behalten: bei 900 Dateien waere die Liste
// groesser als die Nachricht, die sie beschreiben soll.
if (gesammelt.pfade.length < 5) gesammelt.pfade.push(pfad)
// JEDER Upload schiebt die Frist nach hinten. So wird aus einem Lauf mit
// Pausen ein Vorgang und nicht zwanzig.
if (zeitgeber) clearTimeout(zeitgeber)
zeitgeber = setTimeout(() => void sammelMelden(), SAMMEL_MS)
// Ohne unref() haelt ein offener Zeitgeber den Prozess am Leben und das
// Herunterfahren wartet zwei Minuten auf nichts.
zeitgeber.unref()
}
async function sammelMelden(): Promise<void> {
const g = gesammelt
// ERST leeren, dann melden: waehrend des Netzaufrufs kann der naechste
// Upload kommen, und der gehoert in die naechste Runde, nicht in diese.
gesammelt = null
zeitgeber = null
if (!g) return
const einzeln = g.anzahl === 1
// Auch im Log, nicht nur im Kanal: wer spaeter fragt „warum kam da nur
// EINE Meldung fuer 900 Bilder", findet hier die Antwort mit Zahl.
console.log(
`[upload] ${g.anzahl} Datei(en), ${groesse(g.bytes)}, von ${[...g.wer].join(', ')} -- eine Meldung`,
)
await melde(einzeln ? 'Etwas Neues ist da' : `${g.anzahl} neue Dateien`, {
text:
g.pfade.map((p) => `\`${p}\``).join('\n') +
(g.anzahl > g.pfade.length ? `\n… und ${g.anzahl - g.pfade.length} weitere` : ''),
felder: [
{ name: 'Zusammen', value: groesse(g.bytes), inline: true },
{ name: 'Von', value: [...g.wer].join(', ').slice(0, 100) || '—', inline: true },
],
schwere: 'gut',
anlass: 'upload',
tag: 'UPLOAD',
})
}
+117 -7
View File
@@ -55,7 +55,7 @@ import {
type Vorlage,
} from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js'
import { ANLAESSE, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import { ANLAESSE, groesse, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
import { neuerSchluessel } from './freigabe.js'
import {
@@ -106,6 +106,25 @@ const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
const PAPIERKORB_TAGE = 30
/** Und wieviel er hoechstens halten darf.
*
* DIE ZEIT ALLEIN REICHT NICHT. Wer 200 GB loescht, haelt sie dreissig Tage
* lang doppelt -- und merkt es erst, wenn die Platte voll ist und der
* Waechter meldet. Der Deckel raeumt dann das AELTESTE zuerst weg, denn das
* ist das, dessen Versehen am ehesten schon aufgefallen waere.
*
* 20 GB bei 910 GB frei ist heute keine Grenze, sondern eine Zusicherung: der
* Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
*
* Ueber `PAPIERKORB_MAX_MB` verstellbar -- nicht aus Bequemlichkeit, sondern
* weil sich ein Deckel von 20 GB nicht nachmessen laesst, ohne 20 GB zu
* loeschen. Wer ihn pruefen will, setzt ihn klein und sieht zu. */
const PAPIERKORB_MAX_BYTES = Math.round(
// Gerundet, weil Nachkommastellen erlaubt sind: 0.0002 MB ergaeben sonst
// einen Deckel von "209.7152 Bytes", und Bruchteile von Bytes gibt es nicht.
Number(process.env.PAPIERKORB_MAX_MB || 20 * 1024) * 1024 * 1024,
)
// ------------------------------------------------------------ Die Bremse
//
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
@@ -1093,6 +1112,63 @@ dashRoutes.post('/upload-url', async (c) => {
*
* WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen
* das Discord-Geheimnis und das Nextcloud-Passwort. */
/** Den Verlauf mitnehmen -- Dateien oder Verwaltung, als CSV.
*
* WOZU: der Export konnte bisher nur den BESTAND. Wer nachweisen oder
* nachrechnen will, was ueber einen Zeitraum passiert ist, sass vor einer
* Tabelle, die nach 500 Zeilen umblaettert.
*
* Dieselben zwei Eigenheiten wie beim Bestands-Export, und aus denselben
* Gruenden: SEMIKOLON, weil Excel in deutscher Einstellung sonst alles in eine
* Spalte nimmt, und CRLF, weil sonst die halbe Datei in einer Zelle steht.
* Beides ist dort nachgemessen worden; hier waere es ein zweiter Anlauf
* gewesen.
*/
dashRoutes.get('/verlauf/export', (c) => {
const welcher = c.req.query('was') === 'verwaltung' ? 'verwaltung' : 'dateien'
const stempel = new Date(now()).toISOString().slice(0, 10)
const feld = (f: unknown) => `"${String(f ?? '').replace(/"/g, '""')}"`
let kopf: string
let zeilen: string[]
if (welcher === 'verwaltung') {
const rows = db
.prepare('SELECT * FROM verwaltung ORDER BY at DESC LIMIT 20000')
.all() as VerwaltungZeile[]
kopf = 'wann;wer;bereich;tat;ziel;was'
zeilen = rows.map((r) =>
[new Date(r.at).toISOString(), r.wer, r.bereich, r.tat, r.ziel, r.was]
.map(feld)
.join(';'),
)
} else {
const rows = db
.prepare('SELECT * FROM events ORDER BY at DESC LIMIT 20000')
.all() as MediaEvent[]
kopf = 'wann;art;pfad;von;groesse;wer;woher'
zeilen = rows.map((r) =>
[
new Date(r.at).toISOString(),
r.kind,
r.path,
r.von ?? '',
r.size ?? '',
r.actor,
r.actor_kind,
]
.map(feld)
.join(';'),
)
}
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="d4rk_media-${welcher}-${stempel}.csv"`,
'Cache-Control': 'no-store',
})
})
dashRoutes.get('/verwaltung', (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)
@@ -2305,15 +2381,45 @@ async function inPapierkorb(row: Media, wer: string): Promise<void> {
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
export async function papierkorbAusleeren(): Promise<number> {
const alt = db
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
for (const e of alt) {
const weg = async (e: PapierkorbEintrag) => {
await rm(papierkorbPfad(e.ablage), { force: true })
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
}
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`)
return alt.length
// 1. Was zu ALT ist.
const alt = db
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
for (const e of alt) await weg(e)
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege zu alt, entfernt`)
// 2. Und was zu VIEL ist.
//
// Die Zeit allein reicht nicht: wer 200 GB loescht, haelt sie dreissig Tage
// lang doppelt. Geraeumt wird von ALTEN her -- deren Versehen waere am
// ehesten schon aufgefallen.
let zuViel = 0
const summe = () =>
(db.prepare('SELECT COALESCE(SUM(size),0) AS b FROM papierkorb').get() as { b: number }).b
if (summe() > PAPIERKORB_MAX_BYTES) {
const nachAlter = db
.prepare('SELECT * FROM papierkorb ORDER BY geloescht ASC')
.all() as PapierkorbEintrag[]
let belegt = summe()
for (const e of nachAlter) {
if (belegt <= PAPIERKORB_MAX_BYTES) break
await weg(e)
belegt -= e.size
zuViel++
}
console.log(
`[papierkorb] Deckel erreicht: ${zuViel} der aeltesten Eintraege entfernt, ` +
`jetzt ${groesse(belegt)} von ${groesse(PAPIERKORB_MAX_BYTES)}`,
)
}
return alt.length + zuViel
}
dashRoutes.get('/papierkorb', async (c) => {
@@ -2349,6 +2455,10 @@ dashRoutes.get('/papierkorb', async (c) => {
items,
...summe,
haltbarTage: PAPIERKORB_TAGE,
// Der Deckel gehoert in die Antwort, damit die Oberflaeche ihn nicht
// erraet -- eine zweite Zahl fuer dieselbe Grenze waere eine, die
// irgendwann von der ersten abweicht.
maxBytes: PAPIERKORB_MAX_BYTES,
verwaist: items.filter((e) => !e.dateiDa).length,
})
})
+2
View File
@@ -28,6 +28,7 @@
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js'
@@ -305,6 +306,7 @@ async function ablegen(
}
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name)
meldeUpload(path, data.length, token.name)
return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
}
+2
View File
@@ -22,6 +22,7 @@
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { config } from '../config.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js'
import {
@@ -321,6 +322,7 @@ uploadRoutes.post('/upload', async (c) => {
}
logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name)
meldeUpload(path, daten.length, token.name)
return c.json({
url: publicUrlFor(path),
@@ -4,10 +4,17 @@ import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine'
// ACHTUNG, DIESE LISTE HAT EINE ZWILLINGSSCHWESTER: `ANLAESSE` in
// server/src/meldung.ts. Der Server verwirft beim Speichern alles, was dort
// nicht steht -- eine Zeile, die nur hier steht, ergibt ein Kreuzchen, das sich
// setzen laesst und beim naechsten Laden wieder weg ist. Genau das ist mit
// 'platte' schon einmal passiert: gesendet 3, gespeichert 2. Wer hier etwas
// ergaenzt, ergaenzt es DORT auch.
const ANLAESSE = [
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
['verwaiste', 'Verwaiste Dateien gefunden'],
['platte', 'Der Platz auf der Platte wird knapp'],
['upload', 'Neue Dateien sind angekommen'],
] as const
/** Meldungen nach Discord.
+36 -7
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import {
ChevronLeft,
ChevronRight,
Download,
History,
Loader2,
PenLine,
@@ -371,6 +372,10 @@ function Reiter({
reiter: 'dateien' | 'verwaltung'
setReiter: (r: 'dateien' | 'verwaltung') => void
}) {
// Jeder Knopf gibt Rueckmeldung, auch der erfolgreiche: ein Download, der
// nur im Browser-Balken auftaucht, sieht aus wie ein Knopf, der nichts tut.
const note = useStore((s) => s.note)
const stil = (an: boolean) =>
`rounded-md px-3 py-1.5 text-sm ${
an
@@ -378,15 +383,39 @@ function Reiter({
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}`
return (
<div className="flex gap-1 self-start rounded-lg border border-rand bg-flaeche p-1">
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
Dateien
</button>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1 rounded-lg border border-rand bg-flaeche p-1">
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
Dateien
</button>
<button
className={stil(reiter === 'verwaltung')}
onClick={() => setReiter('verwaltung')}
>
Verwaltung
</button>
</div>
{/* Der Knopf holt IMMER den Verlauf, der gerade offen ist. Zwei
Knoepfe nebeneinander waeren die Frage „welchen von beiden?" an
einer Stelle, wo die Antwort schon auf dem Bildschirm steht. */}
<button
className={stil(reiter === 'verwaltung')}
onClick={() => setReiter('verwaltung')}
onClick={() => {
// Ueber einen Link und nicht ueber fetch: so bekommt die
// Datei den Namen aus dem Content-Disposition und landet im
// Download-Ordner statt im Speicher des Browsers.
const a = document.createElement('a')
a.href = `/api/dash/verlauf/export?was=${reiter}`
a.rel = 'noreferrer'
a.click()
note('ok', `Der ${reiter === 'dateien' ? 'Datei' : 'Verwaltungs'}-Verlauf wird heruntergeladen.`)
}}
title="Als CSV herunterladen — Semikolon und CRLF, damit Excel es direkt aufmacht"
className="flex items-center gap-1.5 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
>
Verwaltung
<Download size={15} />
CSV
</button>
</div>
)