feat: die Sicherung liegt an drei Orten statt an zweien

Der letzte offene Punkt aus docs/ideen.md. Ein ZWEITES LAUFWERK, einstellbar im
Panel unter Einstellungen -> Sicherung; sichern.ps1 legt das Archiv dort ab,
vergleicht die Pruefsumme und duennt auch dort aus.

WARUM DAS NICHT DASSELBE IST WIE DIE NEXTCLOUD. Die hilft, wenn der ganze
Rechner weg ist -- aber wer 335 MB, spaeter 100 GB, zurueckholen muss, laedt sie
ueber die Leitung. Das zweite Laufwerk ist in Minuten zurueckgespielt. Zwei
Fragen, zwei Antworten, deshalb zwei Ziele.

UND ES WARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche Inhalt.
Ein zweiter Ordner auf C: sieht im Panel genauso gruen aus wie eine echte zweite
Platte und hilft gegen gar nichts. Verglichen wird die PHYSISCHE Platte und
nicht der Laufwerksbuchstabe: zwei Partitionen derselben NVMe sterben zusammen.
Nachgemessen in beide Richtungen:

    C:\backup gegen D:\backup  ->  Nr. 0 gegen Nr. 1  ->  "andere Platte"
    C:\backup gegen C:\Users   ->  Nr. 0 gegen Nr. 0  ->  Warnung

Auf diesem Rechner sind das zwei getrennte NVMe zu je 954 GB.

GEMESSEN AN EINEM ECHTEN LAUF, keinem Trockentest: 4630 Eintraege, 335,9 MB,
auf beiden lokalen Zielen und in der Nextcloud dieselbe Pruefsumme
FFCE4D0A7310... -- identisch.

Geprueft wird mit der Pruefsumme und nicht mit der Dateigroesse: eine
abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
trotzdem Nullen am Ende. Schlaegt das Kopieren oder der Vergleich fehl, ist der
ganze Lauf gescheitert (exit 1, ok:false) -- ein Ziel, das still ausfaellt, ist
genau das, wogegen das hier gebaut ist.

Der Bericht traegt jetzt zweit:true. Fehlt das Feld, ist KEIN zweites Ziel
eingerichtet -- es steht absichtlich nicht als false da, denn das laese sich wie
"hat nicht geklappt".

Die Statusseite zaehlt die Orte mit: "sicherung ok an 3 Orten" statt nur "ok".
Eine Sicherung, die es nur einmal gibt, ist gruen und trotzdem eine, die ein
Plattenausfall mitnimmt -- das gehoert dorthin, wo jemand hinsieht.

Der Pfad wird beim Speichern auf seine FORM geprueft (Laufwerksbuchstabe oder
UNC-Freigabe): der Dienst laeuft im Container und kann ihn nicht nachschlagen,
aber ein relativer Pfad ist mit Sicherheit ein Tippfehler -- und ein Tippfehler
in einem Sicherungsziel faellt sonst erst auf, wenn man die Sicherung braucht.
Nachgemessen: "backup/woauchimmer" -> 400 mit Text.

Und zurueckspielen.ps1 sagt im Kopf, was zu tippen ist, wenn genau diese Platte
das Problem ist. Das ist der Fall, fuer den das Ganze da ist, und niemand soll
ihn um vier Uhr nachts erst herleiten muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 08:50:44 +02:00
co-authored by Claude Opus 5
parent bbb74138c8
commit 74e2128afb
10 changed files with 289 additions and 15 deletions
+26
View File
@@ -1311,6 +1311,7 @@ dashRoutes.post('/settings', async (c) => {
passwort?: string
ordner?: string
behalten?: number | string
zweitziel?: string
}
}>(c)
if (!body) return c.json(KEIN_JSON, 400)
@@ -1418,6 +1419,31 @@ dashRoutes.post('/settings', async (c) => {
}
setSetting(SICHERUNG_KEYS.behalten, String(Math.round(n)))
}
if (sic.zweitziel !== undefined) {
// Der Pfad gilt auf dem HOST. Der Dienst sieht ihn nie und kann
// ihn deshalb nicht nachschlagen -- er nimmt ihn entgegen und
// tools/sichern.ps1 stolpert notfalls darueber. Was hier trotzdem
// geht, ist die Form: ein Pfad ohne Laufwerk oder Freigabe ist mit
// Sicherheit ein Tippfehler, und ein Tippfehler in einem
// Sicherungsziel faellt sonst erst auf, wenn man die Sicherung
// braucht.
const v = sic.zweitziel.trim().replace(/[\\/]+$/, '')
// Entweder ein Laufwerksbuchstabe (D:\...) oder eine UNC-Freigabe
// (\\nas\sicherungen). Alles andere ist ein relativer Pfad, und
// relativ WOZU waere hier gar nicht beantwortbar.
if (v && !/^([A-Za-z]:[\\/]|\\\\[^\\]+\\)/.test(v)) {
return c.json(
{
error:
'Das zweite Ziel braucht ein Laufwerk oder eine Freigabe, ' +
'z. B. D:\\backup\\d4rk_media oder \\\\nas\\sicherungen.',
},
400,
)
}
setSetting(SICHERUNG_KEYS.zweitziel, v)
}
// Wie beim Discord-Geheimnis: leer heisst unveraendert, "-" entfernt.
if (sic.passwort !== undefined) {
const v = sic.passwort.trim()
+16 -2
View File
@@ -189,7 +189,12 @@ async function pruefen(): Promise<Antwort> {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'noch keine gemeldet' })
} else {
try {
const l = JSON.parse(roh) as { at: number; ok: boolean }
const l = JSON.parse(roh) as {
at: number
ok: boolean
hoch?: boolean
zweit?: boolean
}
const stunden = (now() - l.at) / 3_600_000
if (!l.ok) {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'letzte fehlgeschlagen' })
@@ -200,7 +205,16 @@ async function pruefen(): Promise<Antwort> {
hinweis: `letzte vor ${Math.round(stunden)} Stunden`,
})
} else {
pruefungen.push({ was: 'sicherung', ok: true })
// WIEVIELE ORTE, nicht nur "gelaufen". Eine Sicherung, die es
// nur einmal gibt, ist gruen und trotzdem eine, die ein
// Plattenausfall mitnimmt -- das gehoert auf die Statusseite,
// wo jemand hinschaut, und nicht nur ins Panel.
const orte = 1 + (l.zweit ? 1 : 0) + (l.hoch ? 1 : 0)
pruefungen.push({
was: 'sicherung',
ok: true,
hinweis: orte === 1 ? 'nur an einem Ort' : `an ${orte} Orten`,
})
}
} catch {
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'Eintrag unlesbar' })
+13
View File
@@ -109,6 +109,17 @@ export const SICHERUNG_KEYS = {
passwort: 'sicherung.passwort',
ordner: 'sicherung.ordner',
behalten: 'sicherung.behalten',
/** Ein ZWEITER lokaler Ablageort, moeglichst auf einer anderen Platte.
*
* WOZU, wenn es doch schon die Nextcloud gibt: die ist weit weg. Wer
* 100 GB zurueckholen muss, laedt sie ueber die Leitung -- das ist die
* Sicherung fuer den Fall, dass das Haus abbrennt, nicht fuer den Fall,
* dass eine Platte stirbt. Fuer den zweiten will man die Kopie IM Rechner
* haben, nur eben nicht auf derselben Platte.
*
* Der Pfad gilt auf dem HOST, nicht im Container -- ihn prueft deshalb
* nicht der Dienst, sondern tools/sichern.ps1. */
zweitziel: 'sicherung.zweitziel',
/** Schreibt das Skript nach jedem Lauf. JSON, damit das Panel es anzeigen
* kann, ohne dass hier eine weitere Tabelle noetig waere. */
letzte: 'sicherung.letzte',
@@ -119,6 +130,7 @@ export type SicherungConfig = {
benutzer: string
ordner: string
behalten: number
zweitziel: string
hatPasswort: boolean
bereit: boolean
}
@@ -135,6 +147,7 @@ export function sicherungConfig(): SicherungConfig {
benutzer,
ordner,
behalten,
zweitziel: getSetting(SICHERUNG_KEYS.zweitziel),
hatPasswort: Boolean(passwort),
bereit: Boolean(basis && benutzer && passwort),
}
+5
View File
@@ -309,6 +309,10 @@ export type LetzteSicherung = {
groesse?: number
/** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */
hoch?: boolean
/** true = auch auf der zweiten Platte. undefined heisst „kein zweites Ziel
* eingerichtet" und nicht „hat nicht geklappt" -- schiefgegangen waere ein
* Grund fuer ok:false, nicht fuer ein stilles false. */
zweit?: boolean
fehler?: string
} | null
@@ -320,6 +324,7 @@ export type Einstellungen = {
benutzer: string
ordner: string
behalten: number
zweitziel: string
hatPasswort: boolean
bereit: boolean
letzte: LetzteSicherung
+38 -3
View File
@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, CheckCircle2, CloudUpload, Loader2, ShieldCheck, ShieldX } from 'lucide-react'
import {
AlertTriangle,
CheckCircle2,
CloudUpload,
HardDrive,
Loader2,
ShieldCheck,
ShieldX,
} from 'lucide-react'
import { api, type Einstellungen as EinstellungenDaten } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
@@ -55,6 +63,7 @@ function SicherungKarte() {
const [passwort, setPasswort] = useState('')
const [ordner, setOrdner] = useState('')
const [behalten, setBehalten] = useState('14')
const [zweitziel, setZweitziel] = useState('')
async function laden() {
setLaedt(true)
@@ -65,6 +74,7 @@ function SicherungKarte() {
setBenutzer(e.sicherung.benutzer)
setOrdner(e.sicherung.ordner)
setBehalten(String(e.sicherung.behalten))
setZweitziel(e.sicherung.zweitziel)
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
} finally {
@@ -87,6 +97,7 @@ function SicherungKarte() {
...(passwort ? { passwort } : {}),
ordner,
behalten: Number(behalten),
zweitziel,
}),
(r) =>
r.bereit
@@ -116,7 +127,7 @@ function SicherungKarte() {
return (
<Karte
titel="Sicherung in die Nextcloud"
titel="Sicherung"
rechts={
s.bereit ? (
<span className="flex items-center gap-1.5 text-xs text-gut">
@@ -158,6 +169,7 @@ function SicherungKarte() {
{l.groesse != null && `, ${bytes(l.groesse)}`}
{l.hoch === true && ', in der Nextcloud'}
{l.hoch === false && ', nur lokal'}
{l.zweit === true && ', auf der zweiten Platte'}
{l.fehler && (
<span className="block text-xs text-schlecht">{l.fehler}</span>
)}
@@ -199,11 +211,34 @@ function SicherungKarte() {
min={1}
max={365}
value={behalten}
hinweis="gilt hier wie in der Nextcloud"
hinweis="gilt überall gleich"
onChange={(e) => setBehalten(e.target.value)}
/>
<Feld
label="Zweites Ziel (andere Platte)"
value={zweitziel}
placeholder={'D:\\backup\\d4rk_media'}
className="font-mono"
hinweis="Pfad auf dem Server, leer lassen heißt: nur ein lokales Ziel."
onChange={(e) => setZweitziel(e.target.value)}
/>
</div>
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<HardDrive size={14} className="mt-0.5 shrink-0" />
<span>
<strong className="text-schrift">
Die zwei Ziele beantworten zwei verschiedene Fragen.
</strong>{' '}
Die Nextcloud hilft, wenn der ganze Rechner weg ist aber wer 100 GB
zurückholen muss, lädt sie über die Leitung. Das zweite Ziel liegt im
Rechner und ist in Minuten zurückgespielt; es hilft nur, wenn es auf einer{' '}
<strong className="text-schrift">anderen Platte</strong> liegt als die
Daten. Ein zweiter Ordner auf derselben Platte sieht aus wie eine
Sicherung und ist keine.
</span>
</p>
<p className="flex items-start gap-2 text-xs text-schrift-leise">
<CloudUpload size={14} className="mt-0.5 shrink-0" />
<span>