feat: ein Einstellungen-Tab -- und die Sicherung meldet sich zurueck

Die Zugangsdaten der Sicherung standen in einer Textdatei auf der Platte. Jetzt
stehen sie im Panel, zusammen mit der Discord-Einrichtung, die vom Konto
dorthin umgezogen ist.

DER EIGENTLICHE GEWINN IST NICHT DIE BEQUEMLICHKEIT. Eine Textdatei sagt
niemandem, ob die Sicherung heute Nacht gelaufen ist -- und eine Sicherung, die
still aufhoert zu laufen, ist der eigentliche Schaden, nicht die eine Nacht, in
der sie fehlt. Das Skript schreibt sein Ergebnis deshalb nach jedem Lauf in die
Datenbank, und die Karte zeigt es als Erstes: wann, ob erfolgreich, wie viele
Dateien, wie gross, und ob es in der Nextcloud gelandet ist oder nur lokal.
Nachgemessen: {"dateien":228,"groesse":12063419,"hoch":false,"ok":true}.

DAS PASSWORT GEHT NICHT UEBER DIE API. Das Skript laeuft auf dem Wirt, nicht im
Dienst -- es liest die Werte per `docker exec` DIREKT aus media.db. Ueber HTTP
geht das Passwort nur beim Eintragen im Browser, und das ueber TLS. Zurueck gibt
die API nur "hatPasswort: true", wie beim Discord-Geheimnis.

nextcloud.txt neben den Sicherungen gilt weiter und hat VORRANG: wer sie
angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird.

Zwei Fussangeln beim Melden, beide beim Bauen aufgefallen:
- Der JSON-Text geht ueber die STANDARDEINGABE in den Container und nicht als
  Argument -- Anfuehrungszeichen ueberleben den Weg durch zwei Shells nicht.
- Auch dieses Hier-Dokument braucht das -replace "`r": dieselbe CRLF-Falle wie
  bei der Pruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:39:16 +02:00
co-authored by Claude Opus 5
parent 74cf56ce81
commit 4e613dd40d
7 changed files with 436 additions and 27 deletions
+3
View File
@@ -7,6 +7,7 @@ import {
KeyRound,
LogOut,
PieChart,
Settings,
UserCog,
} from 'lucide-react'
import { run, useStore } from './store'
@@ -16,6 +17,7 @@ import { Galerie } from './pages/Galerie'
import { Speicher } from './pages/Speicher'
import { Tokens } from './pages/Tokens'
import { Konto } from './pages/Konto'
import { Einstellungen } from './pages/Einstellungen'
import { Verlauf } from './pages/Verlauf'
import { Meldungen } from './components/Meldungen'
import { gehZu, usePfad } from './router'
@@ -28,6 +30,7 @@ const seiten = [
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
] as const
export function App() {
+22
View File
@@ -182,7 +182,27 @@ export type Verwaiste = {
vorschauOhneBild: { anzahl: number; beispiele: string[] }
}
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
export type LetzteSicherung = {
at: number
ok: boolean
dateien?: number
groesse?: number
/** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */
hoch?: boolean
fehler?: string
} | null
export type Einstellungen = {
sicherung: {
basis: string
benutzer: string
ordner: string
behalten: number
hatPasswort: boolean
bereit: boolean
letzte: LetzteSicherung
}
discord: {
clientId: string
guildId: string
@@ -301,6 +321,8 @@ export const api = {
),
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }),
einstellungenSpeichern: (discord: Record<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, CheckCircle2, CloudUpload, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
import { api, type Einstellungen as EinstellungenDaten } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
/** Alles, was sich einstellen lässt, an einer Stelle.
*
* Bisher lag die Discord-Karte beim Konto und die Zugangsdaten der Sicherung
* in einer Textdatei auf der Platte. Beides ist jetzt hier — und das ist mehr
* als Aufräumen: eine Textdatei sagt niemandem, ob die Sicherung heute Nacht
* gelaufen ist. Diese Seite tut es. */
export function Einstellungen() {
return (
<div className="flex max-w-2xl flex-col gap-4">
<SicherungKarte />
<DiscordKarte />
</div>
)
}
function SicherungKarte() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<EinstellungenDaten | null>(null)
const [laedt, setLaedt] = useState(true)
const [speichert, setSpeichert] = useState(false)
const [basis, setBasis] = useState('')
const [benutzer, setBenutzer] = useState('')
const [passwort, setPasswort] = useState('')
const [ordner, setOrdner] = useState('')
const [behalten, setBehalten] = useState('14')
async function laden() {
setLaedt(true)
try {
const e = await api.einstellungen()
setDaten(e)
setBasis(e.sicherung.basis)
setBenutzer(e.sicherung.benutzer)
setOrdner(e.sicherung.ordner)
setBehalten(String(e.sicherung.behalten))
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
setLaedt(false)
}
}
useEffect(() => {
void laden()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function speichern() {
setSpeichert(true)
const ergebnis = await run(
() =>
api.sicherungSpeichern({
basis,
benutzer,
...(passwort ? { passwort } : {}),
ordner,
behalten: Number(behalten),
}),
(r) =>
r.bereit
? 'Gespeichert. Die nächste Sicherung geht in die Nextcloud.'
: `Gespeichert. Es fehlt noch: ${r.fehlt.join(', ')}.`,
)
setSpeichert(false)
if (ergebnis) {
setPasswort('')
void laden()
}
}
if (laedt) {
return (
<Karte titel="Sicherung">
<div className="flex justify-center py-6">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
</Karte>
)
}
if (!daten) return null
const s = daten.sicherung
const l = s.letzte
return (
<Karte
titel="Sicherung in die Nextcloud"
rechts={
s.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
<ShieldCheck size={14} /> eingerichtet
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-schrift-leise">
<ShieldX size={14} /> nur lokal
</span>
)
}
>
<div className="flex flex-col gap-4">
{/* Das Wichtigste zuerst: LIEF sie? Eine Sicherung, die still
aufhoert zu laufen, ist der eigentliche Schaden. */}
<div
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${
!l
? 'border-rand bg-grund text-schrift-leise'
: l.ok
? 'border-gut/40 bg-gut/10'
: 'border-schlecht/40 bg-schlecht/10'
}`}
>
{!l ? (
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
) : l.ok ? (
<CheckCircle2 size={16} className="mt-0.5 shrink-0 text-gut" />
) : (
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
)}
<span className="text-schrift">
{!l ? (
'Noch keine Sicherung gemeldet. Sie läuft täglich um 04:30 als geplante Aufgabe.'
) : (
<>
Zuletzt {zeit(l.at)} {l.ok ? 'erfolgreich' : 'FEHLGESCHLAGEN'}
{l.dateien != null && `, ${l.dateien} Dateien`}
{l.groesse != null && `, ${bytes(l.groesse)}`}
{l.hoch === true && ', in der Nextcloud'}
{l.hoch === false && ', nur lokal'}
{l.fehler && (
<span className="block text-xs text-schlecht">{l.fehler}</span>
)}
</>
)}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Feld
label="Nextcloud-Adresse"
value={basis}
placeholder="https://cdn.d4rkst3r.de"
onChange={(e) => setBasis(e.target.value)}
/>
<Feld
label="Benutzername"
value={benutzer}
placeholder="wie beim Anmelden in der Nextcloud"
onChange={(e) => setBenutzer(e.target.value)}
/>
<Feld
label="App-Passwort"
type="password"
value={passwort}
placeholder={s.hatPasswort ? '•••••••• (hinterlegt)' : 'noch keines'}
hinweis='leer lassen heißt: unverändert. Zum Entfernen ein „-" eintragen.'
onChange={(e) => setPasswort(e.target.value)}
/>
<Feld
label="Ordner"
value={ordner}
placeholder="Sicherungen/d4rk_media"
onChange={(e) => setOrdner(e.target.value)}
/>
<Feld
label="Wie viele behalten"
type="number"
min={1}
max={365}
value={behalten}
hinweis="gilt hier wie in der Nextcloud"
onChange={(e) => setBehalten(e.target.value)}
/>
</div>
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<CloudUpload size={14} className="mt-0.5 shrink-0" />
<span>
Nimm ein <strong>App-Passwort</strong>, nicht dein echtes: Nextcloud
Einstellungen Sicherheit Neues App-Passwort erstellen". Es lässt
sich einzeln zurückziehen. Das Skript liest die Werte direkt aus der
Datenbank des Dienstes — über HTTP geht das Passwort nur hier beim
Eintragen, und das über TLS.
</span>
</p>
<div>
<Knopf art="haupt" wartet={speichert} onClick={speichern} kind="Speichern" />
</div>
</div>
</Karte>
)
}
-3
View File
@@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react'
import { api } from '../api'
import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from '../components/Bausteine'
import { DiscordKarte } from '../components/DiscordKarte'
import { Avatar } from '../components/Avatar'
export function Konto() {
@@ -105,8 +104,6 @@ export function Konto() {
</div>
</form>
</Karte>
<DiscordKarte />
</div>
)
}