DER FEHLER ZUERST, denn er ist meiner von gestern: die Liste der
Meldungsanlaesse stand ZWEIMAL da -- als Standard in meldung.ts und noch einmal
als Weissliste beim Speichern in dash.ts. Beim Einbau des Plattenwaechters habe
ich nur die erste angefasst.
Ergebnis: das Kaestchen "Der Platz auf der Platte wird knapp" liess sich
ankreuzen, der Server warf den Wert beim Speichern weg, und beim naechsten
Laden war es wieder aus. Ohne ein Wort dazu. Nachgemessen am laufenden Dienst:
geschickt ["sicherung","verwaiste","platte"]
gespeichert ["sicherung","verwaiste"]
Die Liste steht jetzt an EINER Stelle (ANLAESSE in meldung.ts) und wird von
beiden benutzt. Gegengeprueft: alle drei kommen an.
SITZUNGEN BEENDEN. Wer sich an einem fremden Rechner angemeldet hat und es
spaeter merkt, hatte keine Moeglichkeit das zurueckzunehmen -- ein geaendertes
Passwort half nicht, die Sitzungen haengen an einer eigenen Tabelle und
ueberleben es. Der Knopf steht im Kontomenue und nur dann, wenn es ueberhaupt
eine zweite Sitzung gibt. Gemessen: 87 offen, 86 beendet, meine lebt.
Dabei in die eigene Falle getreten: der Weg lag zuerst bei den anderen
/auth-Wegen -- und die stehen mit Absicht VOR der Wache, weil man sich anmelden
koennen muss, ohne angemeldet zu sein. Dort ist c.get('user') leer, und der Weg
antwortete mit "Cannot read properties of undefined". Hono setzt Middleware und
Handler in der Reihenfolge ihrer Anmeldung zusammen; der Pfad sagt darueber
nichts.
VERWALTUNGS-VERLAUF. Der Datei-Verlauf beantwortet "was ist mit den Dateien
passiert". Wer den Discord-Webhook geaendert, einen Token angelegt oder ein
Konto entfernt hat, stand nirgends. Bei zwei Konten verschmerzbar -- sobald ein
drittes ueber die Discord-Rolle von selbst entsteht, ist es die erste Frage.
Eigene Tabelle und nicht events: dort traegt jede Zeile path und size, und eine
Einstellungsaenderung muesste path mit etwas fuellen, das kein Pfad ist.
WERTE STEHEN DORT NIE, nur Schluesselnamen. Unter den Einstellungen liegen das
Discord-Geheimnis und das Nextcloud-Passwort; ein Verlauf, der sie mitschreibt,
macht aus einer Tabelle mit einem Passwort eine Tabelle mit allen, die es je
gab.
Beim Einbau derselbe Fehlertyp wie gestern beim SVG: im SQL-Kommentar standen
Backticks, und das Schema liegt in einem Template-Literal -- ein Backtick
beendet es. Der Uebersetzer hat es gefangen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
History,
|
|
Loader2,
|
|
PenLine,
|
|
RefreshCw,
|
|
Search,
|
|
FolderInput,
|
|
Trash2,
|
|
Upload,
|
|
X,
|
|
} 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'
|
|
import { useAuffrischen } from '../auffrischen'
|
|
import { useMerker } from '../merker'
|
|
import { Avatar } from '../components/Avatar'
|
|
import { VerwaltungListe } from '../components/VerwaltungListe'
|
|
|
|
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
|
|
|
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 },
|
|
move: { text: 'verschoben', farbe: 'text-akzent', kind: FolderInput },
|
|
// Eigene Art, obwohl es technisch ein Verschieben ist: verschoben heisst
|
|
// „liegt woanders", umbenannt heisst „heisst anders, liegt noch da". Wer
|
|
// den Verlauf aufschlägt, will genau diesen Unterschied wissen.
|
|
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
|
|
}
|
|
|
|
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 [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
|
|
const [suche, setSuche] = useState('')
|
|
// Zwei Verläufe, eine Seite. Der eine beantwortet „was ist mit den Dateien
|
|
// passiert", der andere „wer hat daran gedreht" — dieselbe Frage aus zwei
|
|
// Richtungen, und beide gehören dorthin, wo man Verlauf sucht.
|
|
const [reiter, setReiter] = useState<'dateien' | 'verwaltung'>('dateien')
|
|
|
|
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
|
// "vehicles" eine Anfrage je Buchstabe raus.
|
|
const [sucheAktiv, setSucheAktiv] = useState('')
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setSucheAktiv(suche)
|
|
setSeite(0)
|
|
}, 250)
|
|
return () => clearTimeout(t)
|
|
}, [suche])
|
|
|
|
const laden = useCallback(async () => {
|
|
setLaedt(true)
|
|
try {
|
|
setDaten(await api.events(art, wer, sucheAktiv, proSeite, seite * proSeite))
|
|
} catch (err) {
|
|
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setLaedt(false)
|
|
}
|
|
}, [art, wer, sucheAktiv, proSeite, seite, note])
|
|
|
|
useEffect(() => {
|
|
void laden()
|
|
}, [laden])
|
|
|
|
// Im Verlauf ist das Mitlesen der eigentliche Zweck: waehrend ein
|
|
// Serienlauf laeuft, sieht man hier zu. Blockiert wird nur, wenn man
|
|
// geblaettert hat -- eine Seite drei, die unter den Haenden zu Seite eins
|
|
// wird, ist aergerlich.
|
|
const frisch = useAuffrischen({ laden: () => void laden(), blockiert: seite > 0 })
|
|
|
|
const items = daten?.items ?? []
|
|
// Die Bilder stehen bei den Akteuren und nicht an jedem Eintrag — sonst
|
|
// schleppte jede der 200 Zeilen dieselbe Adresse mit.
|
|
const bilder = new Map((daten?.akteure ?? []).map((a) => [a.actor, a.avatar]))
|
|
const seiten = Math.max(1, Math.ceil((daten?.total ?? 0) / proSeite))
|
|
|
|
if (reiter === 'verwaltung') {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Reiter reiter={reiter} setReiter={setReiter} />
|
|
<Karte>
|
|
<VerwaltungListe />
|
|
</Karte>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Reiter reiter={reiter} setReiter={setReiter} />
|
|
<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 === 'unbekannt' ? null : (
|
|
<Avatar
|
|
name={a.actor}
|
|
url={a.avatar}
|
|
token={a.actor_kind === 'token'}
|
|
groesse={16}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{frisch.wartet && (
|
|
<Knopf
|
|
art="haupt"
|
|
className="ml-auto"
|
|
onClick={frisch.jetztLaden}
|
|
kind={
|
|
<>
|
|
<RefreshCw size={14} />
|
|
Neues da — laden
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<label
|
|
className={`flex cursor-pointer items-center gap-1.5 text-xs text-schrift-leise ${
|
|
frisch.wartet ? '' : 'ml-auto'
|
|
}`}
|
|
title="Alle fünf Sekunden nachsehen, ob etwas dazugekommen ist"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={frisch.an}
|
|
onChange={(e) => frisch.setAn(e.target.checked)}
|
|
className="accent-akzent"
|
|
/>
|
|
automatisch
|
|
</label>
|
|
|
|
<span className="text-sm text-schrift-leise">
|
|
{laedt ? (
|
|
<Loader2 size={15} className="animate-spin" />
|
|
) : (
|
|
`${daten?.total ?? 0} Einträge`
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative min-w-56 flex-1">
|
|
<Search
|
|
size={15}
|
|
className="absolute top-1/2 left-3 -translate-y-1/2 text-schrift-leise"
|
|
/>
|
|
<input
|
|
value={suche}
|
|
onChange={(e) => setSuche(e.target.value)}
|
|
placeholder="Pfad durchsuchen, z. B. vehicles/adder"
|
|
className="w-full rounded-md border border-rand bg-flaeche py-1.5 pr-8 pl-9
|
|
text-sm placeholder:text-schrift-leise/60 focus:border-akzent
|
|
focus:outline-none"
|
|
/>
|
|
{suche && (
|
|
<button
|
|
onClick={() => setSuche('')}
|
|
className="absolute top-1/2 right-2 -translate-y-1/2 text-schrift-leise hover:text-schrift"
|
|
aria-label="Suche leeren"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 text-xs text-schrift-leise">
|
|
pro Seite
|
|
<select
|
|
value={proSeite}
|
|
onChange={(e) => {
|
|
setProSeite(Number(e.target.value))
|
|
setSeite(0)
|
|
}}
|
|
className="rounded-md border border-rand bg-flaeche px-2 py-1.5 text-sm
|
|
text-schrift focus:border-akzent focus:outline-none"
|
|
>
|
|
{SEITENGROESSEN.map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</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 table-fixed text-sm">
|
|
{/* Die Spaltenbreiten stehen HIER und nicht verteilt an
|
|
den Zellen: vorher hatten Kopf und Koerper
|
|
verschiedene Abstaende, und der Pfad wurde
|
|
abgeschnitten, obwohl daneben Platz frei war. */}
|
|
<colgroup>
|
|
<col className="w-40" />
|
|
<col className="w-32" />
|
|
<col />
|
|
<col className="w-24" />
|
|
<col className="w-44" />
|
|
</colgroup>
|
|
<thead>
|
|
<tr className="text-left text-xs text-schrift-leise">
|
|
<th className="px-3 pb-2 font-medium">Wann</th>
|
|
<th className="px-3 pb-2 font-medium">Was</th>
|
|
<th className="px-3 pb-2 font-medium">Pfad</th>
|
|
<th className="px-3 pb-2 text-right font-medium">Größe</th>
|
|
<th className="px-3 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="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
|
|
{zeit(e.at)}
|
|
</td>
|
|
<td className={`px-3 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 px-3 py-1.5 font-mono text-xs"
|
|
title={e.von ? `${e.von} → ${e.path}` : e.path}
|
|
>
|
|
{e.von && (
|
|
<span className="text-schrift-leise">
|
|
{e.von} →{' '}
|
|
</span>
|
|
)}
|
|
{e.path}
|
|
</td>
|
|
<td className="px-3 py-1.5 text-right whitespace-nowrap text-schrift-leise tabular-nums">
|
|
{e.size === null ? '—' : bytes(e.size)}
|
|
</td>
|
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
|
<span className="flex items-center gap-2">
|
|
{e.actor_kind !== 'unbekannt' && (
|
|
<Avatar
|
|
name={e.actor}
|
|
url={bilder.get(e.actor)}
|
|
token={e.actor_kind === 'token'}
|
|
groesse={18}
|
|
/>
|
|
)}
|
|
<span
|
|
className={
|
|
e.actor_kind === 'unbekannt'
|
|
? 'text-schrift-leise italic'
|
|
: ''
|
|
}
|
|
>
|
|
{e.actor}
|
|
</span>
|
|
</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>
|
|
)
|
|
}
|
|
|
|
/** Die zwei Verläufe nebeneinander.
|
|
*
|
|
* Als Reiter und nicht als zweite Seite im Menü: es ist beides ein Verlauf,
|
|
* und wer einen sucht, sucht nicht zwei Menüeinträge ab. */
|
|
function Reiter({
|
|
reiter,
|
|
setReiter,
|
|
}: {
|
|
reiter: 'dateien' | 'verwaltung'
|
|
setReiter: (r: 'dateien' | 'verwaltung') => void
|
|
}) {
|
|
const stil = (an: boolean) =>
|
|
`rounded-md px-3 py-1.5 text-sm ${
|
|
an
|
|
? 'bg-flaeche-hoch text-schrift'
|
|
: '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>
|
|
<button
|
|
className={stil(reiter === 'verwaltung')}
|
|
onClick={() => setReiter('verwaltung')}
|
|
>
|
|
Verwaltung
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|