feat: Konten sichtbar, Kontingent je Token, Plattenwaechter, Umbenennen im Verlauf

KONTEN SICHTBAR. Es gab zwei -- "admin" und eines aus Discord -- und KEINE
Stelle, an der man sie sehen konnte. Weil jeder mit der Rolle auf dem
Discord-Server beim ersten Anmelden still ein Konto bekommt, waeren aus zwei
unbemerkt zwanzig geworden: man erfaehrt es nicht, und wegnehmen ging auch
nicht.

Jetzt eine Liste mit Weg (Discord/Passwort), angelegt, zuletzt da und offenen
Sitzungen. Die Spalte "Weg" ist dabei die wichtigere Auskunft als der Name: ein
Discord-Konto haengt an einer Rolle, die jemand anders vergibt.

Zwei Sperren, beide mit Grund: das EIGENE Konto laesst sich hier nicht
entfernen -- das ist keine Bevormundung, sondern die Vermeidung des einen
Klicks, nach dem niemand mehr hereinkommt -- und das LETZTE auch nicht. Die
Rueckfrage sagt bei einem Discord-Konto dazu, dass die naechste Anmeldung ein
neues anlegt, solange die Rolle bleibt; sonst haelt jemand das Entfernen fuer
eine Aussperrung.

"zuletzt da" wird hoechstens einmal je Stunde geschrieben. Bei jedem Aufruf
waere es ein UPDATE je Anfrage, und das Dashboard stellt beim Blaettern durch
3600 Bilder eine Menge davon.

KONTINGENT JE TOKEN. max_bytes gab es laengst, aber es galt JE DATEI: ein Token
mit "hoechstens 2 MB je Bild" konnte trotzdem die Platte fuellen, es brauchte
nur genug Bilder. quota_bytes gilt fuer alles zusammen, gezaehlt ueber
media.token_id -- also ueber das, was wirklich liegt, statt ueber einen
mitlaufenden Zaehler, der auseinanderlaufen kann.

Auf BEIDEN Wegen geprueft, eigene API und Fivemanage-Weg: eine Grenze, die nur
an einer von zwei Tueren haengt, ist keine.

Die erste Fassung meldete "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0
MB" -- formal richtig, praktisch die Meldung, die genau die Frage nicht
beantwortet, wegen der man sie liest. Die Einheit waechst jetzt mit: "haelt 12
KB von 20 KB, und diese Datei braucht 12 KB."

PLATTENWAECHTER. Meldet bei 80, 90 und 95 Prozent, je Stufe genau einmal; wird
wieder Platz frei, meldet der naechste Engpass erneut. Der Grund steht in der
Meldung: eine volle Platte ist der eine Zustand, in dem gleichzeitig nichts mehr
hereinkommt UND die Sicherung nicht mehr schreiben kann.

Gemessen, und die Abweichung zu df ist gewollt:

    gesamt         1006,9 GB
    frei (bavail)   909,5 GB  ->  9,7 % belegt   <- was der Waechter sieht
    frei (bfree)    960,7 GB  ->  4,6 % belegt
    df sagt                        5 %

bavail zieht die ext4-Reserve ab, an die der Dienst als Nicht-root ohnehin nicht
herankommt. Er warnt damit etwas frueher -- die richtige Richtung. Wer spaeter
den Unterschied zu df sucht, findet ihn als Kommentar an Ort und Stelle.

Dabei fiel ein SCHLAFENDER FEHLER auf: der Merker gegen Wiederholungen lag unter
EINEM Schluessel fuer alle Wachhunde. Mit zwei davon haetten sie sich
gegenseitig ueberschrieben -- einer meldet, der andere haelt daraufhin seinen
eigenen Zustand fuer neu und meldet auch, oder schweigt, obwohl sich etwas
geaendert hat. Der Merker haengt jetzt am Anlass.

UMBENENNEN IM VERLAUF. Stand dort als "verschoben". Technisch stimmt das, fuer
den Leser nicht: verschoben heisst "liegt woanders", umbenannt heisst "heisst
anders, liegt noch da" -- und genau diesen Unterschied schlaegt man im Verlauf
nach. Eigene Art mit eigenem Zeichen. Die Filterleiste kennt jetzt auch move und
rename, die vorher gar nicht filterbar waren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:26:22 +02:00
co-authored by Claude Opus 5
parent 06e924daf5
commit 458775f9d9
15 changed files with 580 additions and 18 deletions
+22 -2
View File
@@ -174,8 +174,10 @@ export type TokenRow = {
can_delete: number
created_at: number
last_used_at: number | null
/** Groesste erlaubte Datei. Null = die Grenze des Dienstes. */
/** Groesste erlaubte EINZELNE Datei. Null = die Grenze des Dienstes. */
max_bytes: number | null
/** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */
quota_bytes: number | null
/** Erlaubte Arten, mit Komma. Leer = alle. */
arten: string
expires_at: number | null
@@ -228,6 +230,20 @@ export type Uebersicht = {
export type Vorlage = { name: string; muster: string }
/** Ein Konto. `weg` sagt, worueber jemand hereinkommt — die wichtigere Auskunft
* als der Name, denn ein Discord-Konto haengt an einer Rolle, die jemand
* anders vergibt. */
export type BenutzerRow = {
id: number
username: string
created_at: number
zuletzt_da: number | null
weg: 'discord' | 'passwort'
discordName: string | null
avatar: string | null
sitzungen: number
}
/** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann.
*
* Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin
@@ -324,7 +340,7 @@ export type Einstellungen = {
}
}
export type EventKind = 'upload' | 'replace' | 'delete' | 'move'
export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename'
export type MediaEvent = {
id: number
@@ -478,6 +494,9 @@ export const api = {
{ aktion },
),
benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'),
benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`),
freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'),
freigabeAnlegen: (ordner: string, titel: string) =>
post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>(
@@ -505,6 +524,7 @@ export const api = {
prefix: string
canDelete: boolean
maxMb?: string
quotaMb?: string
arten?: string[]
gueltigTage?: string
}) => post<NewToken>('/api/dash/tokens', daten),
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { KeyRound, Trash2, UserRound } from 'lucide-react'
import { api, type BenutzerRow } from '../api'
import { run, useStore } from '../store'
import { zeit } from '../format'
import { Avatar } from './Avatar'
import { Karte, Nachfrage } from './Bausteine'
/** Wer ein Konto hat.
*
* WOZU ÜBERHAUPT. Es gab zwei Konten und keine Stelle, an der man sie sehen
* konnte. Und weil jeder mit der Rolle auf dem Discord-Server beim ersten
* Anmelden **still** ein Konto bekommt, wären aus zwei unbemerkt zwanzig
* geworden — man erfährt es nicht, und wegnehmen ging auch nicht.
*
* Die Spalte „Weg" ist dabei die wichtigere Auskunft als der Name: ein
* Discord-Konto hängt an einer Rolle, die jemand anders vergibt. Wer die Rolle
* verliert, behält trotzdem sein Konto hier — geprüft wird die Rolle nur beim
* Anmelden. */
export function BenutzerKarte() {
const note = useStore((s) => s.note)
const [items, setItems] = useState<BenutzerRow[] | null>(null)
const [ich, setIch] = useState(0)
const [weg, setWeg] = useState<BenutzerRow | null>(null)
async function laden() {
try {
const r = await api.benutzer()
setItems(r.items)
setIch(r.ich)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!items) return null
return (
<Karte titel="Konten">
<p className="mb-3 text-xs text-schrift-leise">
Wer sich hier anmelden kann. Ein Konto über Discord entsteht{' '}
<strong className="text-schrift">von selbst</strong>, sobald sich jemand mit der
passenden Rolle zum ersten Mal anmeldet deshalb steht diese Liste hier.
Entfernen wirft auch die offenen Sitzungen weg, der Zugang ist also sofort zu.
</p>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Weg</th>
<th className="pb-2 font-medium">Angelegt</th>
<th className="pb-2 font-medium">Zuletzt da</th>
<th className="pb-2 text-right font-medium">Sitzungen</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id} className="border-t border-rand">
<td className="py-2">
<span className="flex items-center gap-2">
<Avatar url={u.avatar} name={u.username} groesse={22} />
<span className="font-medium">{u.username}</span>
{u.id === ich && (
<span className="rounded bg-akzent/15 px-1.5 py-0.5 text-[11px] text-akzent">
das bist du
</span>
)}
</span>
</td>
<td className="py-2">
{u.weg === 'discord' ? (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<UserRound size={12} /> Discord
</span>
) : (
<span className="flex items-center gap-1 text-xs text-schrift-leise">
<KeyRound size={12} /> Passwort
</span>
)}
</td>
<td className="py-2 text-schrift-leise">{zeit(u.created_at)}</td>
<td className="py-2 text-schrift-leise">
{u.zuletzt_da ? (
zeit(u.zuletzt_da)
) : (
// Nicht „nie": gezählt wird erst, seit es
// die Spalte gibt. Eine Null zu behaupten,
// wo nichts gemessen wurde, wäre gelogen.
<span title="Gezählt wird erst seit dem Einbau dieser Spalte.">
</span>
)}
</td>
<td className="py-2 text-right tabular-nums text-schrift-leise">
{u.sitzungen}
</td>
<td className="py-2 text-right">
<button
onClick={() => setWeg(u)}
disabled={u.id === ich || items.length <= 1}
title={
u.id === ich
? 'Das eigene Konto lässt sich hier nicht entfernen'
: items.length <= 1
? 'Das letzte Konto bleibt'
: 'Konto entfernen'
}
className="rounded border border-rand px-2 py-1 text-schrift-leise
hover:bg-schlecht/15 hover:text-schlecht
disabled:cursor-not-allowed disabled:opacity-30
disabled:hover:bg-transparent disabled:hover:text-schrift-leise"
aria-label={`${u.username} entfernen`}
>
<Trash2 size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Nachfrage
offen={weg !== null}
titel="Konto entfernen?"
bestaetigung="Entfernen"
onNein={() => setWeg(null)}
onJa={async () => {
const u = weg
setWeg(null)
if (!u) return
await run(() => api.benutzerWeg(u.id), (r) => `${r.username} entfernt.`)
void laden()
}}
text={
<p>
<code className="font-mono">{weg?.username}</code> kommt danach nicht mehr
herein, und die {weg?.sitzungen ?? 0} offenen Sitzungen enden sofort.
{weg?.weg === 'discord' && (
<>
{' '}
<strong className="text-schrift">Aber:</strong> hat diese Person
weiterhin die Rolle auf deinem Discord-Server, legt die nächste
Anmeldung ein neues Konto an. Wer dauerhaft draußen bleiben soll,
verliert die Rolle.
</>
)}
</p>
}
/>
</Karte>
)
}
@@ -7,6 +7,7 @@ import { Feld, Karte, Knopf } from './Bausteine'
const ANLAESSE = [
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
['verwaiste', 'Verwaiste Dateien gefunden'],
['platte', 'Der Platz auf der Platte wird knapp'],
] as const
/** Meldungen nach Discord.
+3
View File
@@ -7,6 +7,7 @@ import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
import { VorlagenKarte } from '../components/VorlagenKarte'
import { FreigabenKarte } from '../components/FreigabenKarte'
import { BenutzerKarte } from '../components/BenutzerKarte'
import { MeldungenKarte } from '../components/MeldungenKarte'
/** Alles, was sich einstellen lässt, an einer Stelle.
@@ -31,6 +32,8 @@ export function Einstellungen() {
<VorlagenKarte publicUrl={urls.publicUrl} filePrefix={urls.filePrefix} />
<FreigabenKarte />
<BenutzerKarte />
<SicherungKarte />
<MeldungenKarte />
<DiscordKarte />
+2
View File
@@ -13,6 +13,7 @@ import {
Trash2,
TrendingUp,
Upload,
PenLine,
} from 'lucide-react'
import { api, vorschauUrl, type EventKind, type Uebersicht } from '../api'
import { useStore } from '../store'
@@ -27,6 +28,7 @@ const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Uploa
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 },
rename: { text: 'umbenannt', farbe: 'text-akzent', kind: PenLine },
}
/** Die Startseite: was ist da, was ist zuletzt passiert, wohin muss ich.
+16 -1
View File
@@ -14,6 +14,7 @@ export function Tokens() {
const [prefix, setPrefix] = useState('')
const [darfLoeschen, setDarfLoeschen] = useState(false)
const [maxMb, setMaxMb] = useState('')
const [quotaMb, setQuotaMb] = useState('')
const [gueltigTage, setGueltigTage] = useState('')
const [arten, setArten] = useState<string[]>([])
const [legtAn, setLegtAn] = useState(false)
@@ -52,6 +53,7 @@ export function Tokens() {
prefix: prefix.trim(),
canDelete: darfLoeschen,
maxMb: maxMb.trim(),
quotaMb: quotaMb.trim(),
arten,
gueltigTage: gueltigTage.trim(),
}),
@@ -111,6 +113,16 @@ export function Tokens() {
hinweis="Ein Token für Fahrzeugbilder braucht keine 64 MB."
onChange={(e) => setMaxMb(e.target.value)}
/>
<Feld
label="Kontingent insgesamt (MB, optional)"
type="number"
min={0}
step="1"
value={quotaMb}
placeholder="leer = unbegrenzt"
hinweis="Die Grenze links gilt je Datei. Diese hier gilt für alles zusammen — ohne sie kann ein Token die Platte füllen, es braucht nur genug kleine Dateien."
onChange={(e) => setQuotaMb(e.target.value)}
/>
<Feld
label="Gültig für (Tage, optional)"
type="number"
@@ -255,7 +267,10 @@ export function Tokens() {
</td>
<td className="py-2 text-xs text-schrift-leise">
{[
t.max_bytes ? `max ${bytes(t.max_bytes)}` : null,
t.max_bytes ? `max ${bytes(t.max_bytes)}/Datei` : null,
t.quota_bytes
? `${bytes(t.bytes)} von ${bytes(t.quota_bytes)}`
: null,
t.arten ? t.arten.replaceAll(',', ', ') : null,
t.expires_at
? `bis ${zeit(t.expires_at).slice(0, 10)}`
+5
View File
@@ -4,6 +4,7 @@ import {
ChevronRight,
History,
Loader2,
PenLine,
RefreshCw,
Search,
FolderInput,
@@ -26,6 +27,10 @@ const arten: Record<EventKind, { text: string; farbe: string; kind: typeof Uploa
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() {