changed UI layout
new Navigation Dashboard
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
# Fixes & Hardening TODO
|
||||||
|
|
||||||
|
Kritisches Review-Backlog vor Produktiveinsatz und OSS-Release.
|
||||||
|
Sortiert nach Risikoklasse. Datei-/Zeilenreferenzen beziehen sich auf den Stand bei Review-Erstellung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Kritisch — vor Produktion blockierend
|
||||||
|
|
||||||
|
- [ ] **1. Default-Admin `admin/admin` entfernen**
|
||||||
|
- Datei: `management/src/db.js:84`, `management/src/config.js:10`
|
||||||
|
- Aktuell: leeres `AUTH_PASSWORD` → Passwort wird still auf `"admin"` gesetzt.
|
||||||
|
- Fix: Bei leerem Passwort + leerer User-Tabelle Start mit klarer Fehlermeldung abbrechen. Keinen Default mehr in den Code.
|
||||||
|
- README/`docs/deployment.md` entsprechend anpassen.
|
||||||
|
|
||||||
|
- [ ] **2. `API_TOKEN` im Node-Agent zur Pflicht machen**
|
||||||
|
- Datei: `backend/src/index.js:24`, `backend/src/config.js:19`
|
||||||
|
- Aktuell: `if (!config.apiToken) next();` → ohne Token offener Root-RCE-Endpunkt.
|
||||||
|
- Fix: Beim Start verweigern wenn leer. Minimal-Länge erzwingen (z.B. 32 Zeichen).
|
||||||
|
|
||||||
|
- [ ] **3. HTTPS zwischen Management und Agent erzwingen**
|
||||||
|
- Datei: `management/src/agentClient.js:2`, `management/src/routes/nodes.js:91`
|
||||||
|
- Aktuell: `node.baseUrl` darf beliebiges Schema haben, Bearer-Token läuft potenziell im Klartext.
|
||||||
|
- Fix: `baseUrl` muss `https://` sein (Validator). Optional: eigene CA + Cert-Pinning pro Node, mindestens dokumentieren.
|
||||||
|
|
||||||
|
- [ ] **4. CSRF-Risiko durch reflektierendes CORS schließen**
|
||||||
|
- Datei: `management/src/index.js:18`
|
||||||
|
- Aktuell: `cors({ origin: true, credentials: true })` reflektiert jede Origin.
|
||||||
|
- Fix: Whitelist konkreter Frontend-Origins via Env. Cookie auf `SameSite=Strict` setzen oder CSRF-Token-Pattern (Double-Submit) ergänzen.
|
||||||
|
|
||||||
|
- [ ] **5. Brute-Force-Schutz beim Login**
|
||||||
|
- Datei: `management/src/routes/auth.js:12`
|
||||||
|
- Aktuell: kein Rate-Limit, kein Lockout.
|
||||||
|
- Fix: `express-rate-limit` o.ä. — z.B. 5 Versuche / 15 min / IP + User. Audit-Event für gesperrte Versuche.
|
||||||
|
|
||||||
|
- [ ] **6. Restore atomarisieren — kein direktes `dd` auf Produktiv-Volume**
|
||||||
|
- Datei: `backend/src/executor.js:92` (`streamResticDumpToDd`), `backend/src/routes/restore.js`
|
||||||
|
- Aktuell: Wenn `restic dump` abbricht, sind GB bereits auf der VM-Disk → Disk irreversibel kaputt.
|
||||||
|
- Fix-Optionen:
|
||||||
|
- In temporäres ZFS-Volume schreiben, danach Größen-/Hash-Verifikation, dann atomarer `zfs rename`/clone-Swap.
|
||||||
|
- Oder: vor Restore automatisch Disk-Snapshot anlegen; bei Fehler rollback.
|
||||||
|
- Größe vorab prüfen (`restic stats` vs. `zfs get volsize`).
|
||||||
|
|
||||||
|
- [ ] **7. Backup-Verifikation einbauen**
|
||||||
|
- Datei: `backend/src/routes/backup.js` (VM-Pfad ~Z.71, Container-Pfad ~Z.137)
|
||||||
|
- Aktuell: Bei Pipe-Fehler (`zfs send` !=0, `createReadStream`-Abbruch) committed restic ggf. einen unvollständigen Snapshot mit Status „success".
|
||||||
|
- Fix:
|
||||||
|
- Vor Start erwartete Größe ermitteln (`zfs get volsize` / `used`).
|
||||||
|
- Nach Restic-Commit `restic stats <snapshotId>` vergleichen.
|
||||||
|
- Bei Mismatch oder Pipe-Fehler: Restic-Snapshot per ID `forget`+`prune`en, Job auf `failed`.
|
||||||
|
|
||||||
|
- [ ] **8. Bearer-Token aus Frontend-`localStorage` entfernen**
|
||||||
|
- Datei: `frontend/src/api.js:9`
|
||||||
|
- Aktuell: Agent-API-Token im Browser-Storage; bei XSS sofort kompromittiert.
|
||||||
|
- Fix: Frontend nutzt ausschließlich Cookie-Auth zum Management. `VITE_API_TOKEN`/`setApiToken` ersatzlos streichen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 Hoch
|
||||||
|
|
||||||
|
- [ ] **9. Session-Cookie `Secure`-Flag**
|
||||||
|
- Datei: `management/src/auth.js:37`
|
||||||
|
- Fix: `Secure` immer setzen (in Production hart erzwingen), Cookie-Attribute über Config konfigurierbar machen.
|
||||||
|
|
||||||
|
- [ ] **10. Race beim Sichtbarmachen des Snapshot-Devices**
|
||||||
|
- Datei: `backend/src/routes/backup.js:57`
|
||||||
|
- Aktuell: 2 s `sleep` reicht nicht garantiert.
|
||||||
|
- Fix: Polling-Schleife auf `fs.access(snapshotDevice)` mit Timeout; zusätzlich `udevadm trigger && udevadm settle`.
|
||||||
|
|
||||||
|
- [ ] **11. Persistenter Job- und Lock-Store im Agent**
|
||||||
|
- Datei: `backend/src/jobs.js`
|
||||||
|
- Aktuell: in-memory; bei Crash gehen laufende Jobs verloren, Locks bleiben hängen oder verschwinden inkonsistent (z.B. ZFS `volmode=dev`).
|
||||||
|
- Fix: SQLite-Tabelle für Jobs/Locks. Beim Start: alle `running`/`queued` Jobs zu `failed` markieren, Cleanup-Pfad ausführen.
|
||||||
|
|
||||||
|
- [ ] **12. Toten `SESSION_SECRET` aufräumen**
|
||||||
|
- Datei: `management/src/config.js:8`
|
||||||
|
- Aktuell: gelesen, aber nirgendwo genutzt — sieht aus wie ein Loch.
|
||||||
|
- Fix: Entweder Session-IDs HMAC-signieren oder Variable entfernen + `.env.example` säubern.
|
||||||
|
|
||||||
|
- [ ] **13. Snapshot-ID-Prefix-Matching eindeutig machen**
|
||||||
|
- Datei: `backend/src/validators.js:66`
|
||||||
|
- Aktuell: `startsWith` — bei Prefix-Kollision wird stillschweigend der erste Treffer genommen.
|
||||||
|
- Fix: Bei >1 Treffer 409 zurückgeben; UI auf 12-Hex-Prefix umstellen.
|
||||||
|
|
||||||
|
- [ ] **14. RBAC einführen**
|
||||||
|
- Aktuell: ein User, alle Rechte, kein Read-only.
|
||||||
|
- Fix: Mindestens Rollen `admin` / `operator` / `viewer`. Restore nur für `admin`.
|
||||||
|
|
||||||
|
- [ ] **15. ENV-Escaping in `writeEnvSettings` verbessern**
|
||||||
|
- Datei: `backend/src/config.js:105`
|
||||||
|
- Aktuell: Nur `\` und `"` escaped; `$`, Backticks, Newlines nicht.
|
||||||
|
- Fix: Eigener Serializer mit korrektem Escaping aller Sonderzeichen.
|
||||||
|
|
||||||
|
- [ ] **16. Settings-Endpunkt sperren bis `API_TOKEN` initial gesetzt ist**
|
||||||
|
- Datei: `backend/src/routes/settings.js`
|
||||||
|
- Hängt mit Fix #2 zusammen — nach #2 automatisch erfüllt, hier zur Sicherheit dokumentieren/testen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Mittel
|
||||||
|
|
||||||
|
- [ ] **17. `Math.random()` durch `crypto.randomBytes` ersetzen**
|
||||||
|
- Datei: `management/src/db.js:90` (`cryptoId`).
|
||||||
|
|
||||||
|
- [ ] **18. Systemd-Hardening für den Agent**
|
||||||
|
- Datei: `deploy/systemd/incus-backup-agent.service`
|
||||||
|
- Hinzufügen: `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`, `ReadWritePaths=/dev/zvol /var/lib/incus /opt/incus-backup-ui/backend`, `CapabilityBoundingSet=...`, eingeschränkte `AmbientCapabilities`.
|
||||||
|
|
||||||
|
- [ ] **19. `npm start` durch direkten `node`-Aufruf ersetzen**
|
||||||
|
- Datei: beide `deploy/systemd/*.service`
|
||||||
|
- `ExecStart=/usr/bin/node src/index.js` — kein npm-Wrapper-Prozess, kein PATH-Risiko.
|
||||||
|
|
||||||
|
- [ ] **20. CORS am Agent entfernen**
|
||||||
|
- Datei: `backend/src/index.js:16`
|
||||||
|
- Agent wird nie aus dem Browser angesprochen → Angriffsfläche raus.
|
||||||
|
|
||||||
|
- [ ] **21. `schedules.json`-Pfad explizit konfigurierbar**
|
||||||
|
- Datei: `backend/src/scheduler.js:6`
|
||||||
|
- Aktuell: `process.cwd()`-abhängig.
|
||||||
|
- Fix: über Env (`SCHEDULES_PATH`) absolut konfigurieren, Default unter `/var/lib/incus-backup-agent/`.
|
||||||
|
|
||||||
|
- [ ] **22. Audit-Log-Integrität**
|
||||||
|
- Datei: `management/src/store.js:71`
|
||||||
|
- Append-only Constraint + Hash-Chain (jeder Event hat `prev_hash`). Optional Off-Site-Versand (Webhook).
|
||||||
|
|
||||||
|
- [ ] **23. Passwort-Hashing auf argon2id umstellen**
|
||||||
|
- Datei: `management/src/crypto.js`
|
||||||
|
- Aktuell: `scryptSync` mit Node-Defaults, ohne explizite Parameter.
|
||||||
|
- Fix: `@node-rs/argon2` oder explizite scrypt-Parameter dokumentieren + Migration-Pfad.
|
||||||
|
|
||||||
|
- [ ] **24. Rate-Limit / Quoten für Backups**
|
||||||
|
- Pro VM und global (z.B. max N parallele Streams nach S3). Disk-Space-/Quota-Check vor Start.
|
||||||
|
|
||||||
|
- [ ] **25. `incus snapshot delete` Retry verallgemeinern**
|
||||||
|
- Datei: `backend/src/routes/backup.js:303`
|
||||||
|
- Aktuell: Substring-Match auf englische Stderr — bricht bei lokalisierten Builds.
|
||||||
|
- Fix: Generischer Retry (n Versuche, Backoff) bei nicht-0 Exit-Code.
|
||||||
|
|
||||||
|
- [ ] **26. Restic-`ls` streamen statt vollständig in RAM laden**
|
||||||
|
- Datei: `backend/src/routes/snapshots.js:19`
|
||||||
|
- Für Container-Backups mit vielen Files relevant.
|
||||||
|
|
||||||
|
- [ ] **27. Container-Restore implementieren oder Container-Backup deaktivieren**
|
||||||
|
- Datei: `backend/src/routes/restore.js:21`
|
||||||
|
- Aktuell: 501. Backups laufen, aber nicht wiederherstellbar = Backup-Theater.
|
||||||
|
- Fix: Sicheren `zfs receive`-Workflow umsetzen oder Container-Backup im UI/API ausschalten bis fertig.
|
||||||
|
|
||||||
|
- [ ] **28. Pre-Backup VM-Zustand prüfen**
|
||||||
|
- Live-Migration, laufende interne Snapshots, fehlende Berechtigungen → klare Fehler statt halb durchgeführter Pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Niedrig / Aufräumen
|
||||||
|
|
||||||
|
- [ ] **29. `.DS_Store` aus Repo entfernen und in `.gitignore` aufnehmen**
|
||||||
|
- [ ] **30. `frontend/dist/` ist eingecheckt** — ignorieren und löschen.
|
||||||
|
- [ ] **31. `incus-backup-ui-plan.md` auf Secrets/Bucket-Namen prüfen** bevor OSS.
|
||||||
|
- [ ] **32. Container-Limit (Restore fehlt) prominent in README dokumentieren.**
|
||||||
|
- [ ] **33. Scheduler-Jitter einbauen** (`management/src/scheduler.js`, `backend/src/scheduler.js`) — sonst belasten viele Nodes synchron S3.
|
||||||
|
- [ ] **34. `formatBytes` deduplizieren** (`backend/src/routes/backup.js`, `restore.js`) → `executor.js` oder `utils.js`.
|
||||||
|
- [ ] **35. `.env`-Dateipermissions dokumentieren** — `chmod 600` im Deployment-Doc verlangen.
|
||||||
|
- [ ] **36. Schema-Versionierung statt `addColumnIfMissing`** — z.B. `schema_version`-Tabelle + nummerierte Migrationen.
|
||||||
|
- [ ] **37. `audit_events.details` Größenlimit** oder JSON-Spalte (SQLite hat JSON1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 OSS-Release-Voraussetzungen
|
||||||
|
|
||||||
|
- [ ] **38. Tests einführen** — aktuell keinerlei Tests im Repo. Mindestens:
|
||||||
|
- `validators.js` (vmName-/snapshot-Regex, Pfad-Validation).
|
||||||
|
- `crypto.js` (hash/verify Roundtrip, Timing-Safe).
|
||||||
|
- `jobs.js` (Locking, Trimming).
|
||||||
|
- Pipeline-Smoke-Tests mit gemockten Befehlen.
|
||||||
|
- [ ] **39. CI-Pipeline** (GitHub Actions): Lint, Tests, `npm audit`, Lockfile-Check.
|
||||||
|
- [ ] **40. `SECURITY.md`** mit Disclosure-Adresse + GPG-Key.
|
||||||
|
- [ ] **41. Threat-Model dokumentieren** — explizit machen, was außerhalb des Schutzbereichs liegt (root-Agent, vertrauenswürdiges Netz, etc.).
|
||||||
|
- [ ] **42. `LICENSE`-Datei ergänzen** (z.B. MIT/Apache-2.0).
|
||||||
|
- [ ] **43. `CONTRIBUTING.md`** + Code-of-Conduct.
|
||||||
|
- [ ] **44. Beispiel-`compose.yaml` oder Ansible-Rolle** für reproduzierbares Deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-5 Sofort-Patches (Vorschlag Reihenfolge)
|
||||||
|
|
||||||
|
1. #1 Default-Passwort entfernen + #2 `API_TOKEN` Pflicht.
|
||||||
|
2. #3 HTTPS-Pflicht für Agent-URL.
|
||||||
|
3. #4 CORS-Whitelist + #9 `Secure`-Cookie.
|
||||||
|
4. #5 Login-Rate-Limit.
|
||||||
|
5. #6/#7 Restore- und Backup-Verifikation (größter Aufwand, größter Impact auf Datenintegrität).
|
||||||
+127
-100
@@ -1,10 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, CalendarClock, ClipboardList, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CalendarClock,
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
DatabaseBackup,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Network,
|
||||||
|
RefreshCw,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react';
|
||||||
import { api, errorMessage } from './api.js';
|
import { api, errorMessage } from './api.js';
|
||||||
import { Dashboard } from './components/Dashboard.jsx';
|
import { Dashboard } from './components/Dashboard.jsx';
|
||||||
import { Login } from './components/Login.jsx';
|
import { Login } from './components/Login.jsx';
|
||||||
import { Nodes } from './components/Nodes.jsx';
|
import { Nodes } from './components/Nodes.jsx';
|
||||||
import { Operations } from './components/Operations.jsx';
|
import { Operations } from './components/Operations.jsx';
|
||||||
|
import { Repositories } from './components/Repositories.jsx';
|
||||||
import { Scheduler } from './components/Scheduler.jsx';
|
import { Scheduler } from './components/Scheduler.jsx';
|
||||||
import { Settings } from './components/Settings.jsx';
|
import { Settings } from './components/Settings.jsx';
|
||||||
import { VMDetail } from './components/VMDetail.jsx';
|
import { VMDetail } from './components/VMDetail.jsx';
|
||||||
@@ -97,122 +110,136 @@ export default function App() {
|
|||||||
return <Login onLogin={loadSession} />;
|
return <Login onLogin={loadSession} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageTitle = currentVm
|
||||||
|
? currentVm.name
|
||||||
|
: {
|
||||||
|
dashboard: 'Backups',
|
||||||
|
nodes: 'Nodes',
|
||||||
|
repositories: 'Repositories',
|
||||||
|
operations: 'Operations',
|
||||||
|
scheduler: 'Scheduler',
|
||||||
|
settings: 'Settings',
|
||||||
|
}[view] || 'Backups';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||||
<header className="border-b border-zinc-800 bg-zinc-950/95">
|
<div className="grid min-h-screen lg:grid-cols-[260px_1fr]">
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
<aside className="flex flex-col border-r border-zinc-800 bg-zinc-900/80">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-3 text-left"
|
className="flex h-20 w-full items-center gap-3 border-b border-zinc-800 px-6 text-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedVm(null);
|
setSelectedVm(null);
|
||||||
setView('dashboard');
|
setView('dashboard');
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900">
|
<span className="flex h-9 w-9 items-center justify-center rounded-md text-red-400">
|
||||||
<DatabaseBackup className="h-5 w-5 text-cyan-300" />
|
<DatabaseBackup className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-sm font-semibold tracking-wide text-zinc-100">Incus Backup Control</span>
|
<span className="block text-base font-semibold tracking-wide text-zinc-100">Incus Backup</span>
|
||||||
<span className="block text-xs text-zinc-500">ZFS block-level Restic operations</span>
|
<span className="block text-xs text-zinc-500">Management UI</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<nav className="space-y-1 px-4 py-6">
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
<NavButton active={view === 'nodes' && !currentVm} icon={Network} label="Nodes" onClick={() => switchView('nodes')} />
|
||||||
onClick={() => {
|
<NavButton active={view === 'repositories' && !currentVm} icon={Database} label="Repositories" onClick={() => switchView('repositories')} />
|
||||||
setSelectedVm(null);
|
<NavButton active={view === 'dashboard' || Boolean(currentVm)} icon={CalendarClock} label="Backups" onClick={() => switchView('dashboard')} />
|
||||||
setView('nodes');
|
<NavButton active={view === 'operations' && !currentVm} icon={ClipboardList} label="Operations" onClick={() => switchView('operations')} />
|
||||||
}}
|
<NavButton active={view === 'scheduler' && !currentVm} icon={Shield} label="Scheduler" onClick={() => switchView('scheduler')} />
|
||||||
type="button"
|
<NavButton active={view === 'settings' && !currentVm} icon={SettingsIcon} label="Settings" onClick={() => switchView('settings')} />
|
||||||
>
|
</nav>
|
||||||
<Network className="h-4 w-4" />
|
|
||||||
Nodes
|
<div className="mt-auto hidden border-t border-zinc-800 px-6 py-4 text-xs text-zinc-500 lg:block">
|
||||||
</button>
|
<div>v0.1.0</div>
|
||||||
<button
|
<div className="mt-2 text-red-400">Management</div>
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedVm(null);
|
|
||||||
setView('scheduler');
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<CalendarClock className="h-4 w-4" />
|
|
||||||
Scheduler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedVm(null);
|
|
||||||
setView('operations');
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ClipboardList className="h-4 w-4" />
|
|
||||||
Operations
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedVm(null);
|
|
||||||
setView('settings');
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<SettingsIcon className="h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
|
||||||
onClick={refresh}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
|
|
||||||
onClick={logout}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</header>
|
|
||||||
|
|
||||||
{error ? (
|
<section className="min-w-0">
|
||||||
<div className="mx-auto mt-4 flex max-w-7xl items-center gap-2 px-4 text-sm text-amber-300 sm:px-6">
|
<header className="flex h-20 items-center justify-between border-b border-zinc-800 bg-zinc-900/80 px-6">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<div className="flex items-center gap-3">
|
||||||
{error}
|
<button className="hidden h-9 w-9 items-center justify-center rounded-md border border-zinc-800 text-zinc-400 lg:inline-flex" type="button">
|
||||||
</div>
|
<DatabaseBackup className="h-4 w-4" />
|
||||||
) : null}
|
</button>
|
||||||
|
<h1 className="text-lg font-semibold tracking-wide text-zinc-100">{pageTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-zinc-800 bg-zinc-950 px-3 py-2">
|
||||||
|
<span className="px-2 text-sm font-medium text-zinc-200">{session.user?.username || 'admin'}</span>
|
||||||
|
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" onClick={refresh} title="Refresh" type="button">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" title="Theme" type="button">
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" onClick={logout} title="Logout" type="button">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
|
<div className="app-grid-bg min-h-[calc(100vh-5rem)] px-5 py-7 sm:px-8">
|
||||||
{view === 'nodes' ? (
|
{error ? (
|
||||||
<Nodes nodes={nodes} onChanged={refresh} />
|
<div className="mb-5 flex items-center gap-2 text-sm text-amber-300">
|
||||||
) : view === 'operations' ? (
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<Operations />
|
{error}
|
||||||
) : view === 'settings' ? (
|
</div>
|
||||||
<Settings nodes={nodes} onChanged={refresh} />
|
) : null}
|
||||||
) : view === 'scheduler' ? (
|
|
||||||
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
|
<div className="mx-auto max-w-[1500px]">
|
||||||
) : currentVm ? (
|
{view === 'nodes' ? (
|
||||||
<VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} />
|
<Nodes nodes={nodes} onChanged={refresh} />
|
||||||
) : (
|
) : view === 'repositories' ? (
|
||||||
<Dashboard
|
<Repositories nodes={nodes} onChanged={refresh} />
|
||||||
health={health}
|
) : view === 'operations' ? (
|
||||||
jobs={jobs}
|
<Operations />
|
||||||
loading={loading}
|
) : view === 'settings' ? (
|
||||||
onManage={(vmName) => {
|
<Settings nodes={nodes} onChanged={refresh} />
|
||||||
setSelectedVm(vmName);
|
) : view === 'scheduler' ? (
|
||||||
setView('dashboard');
|
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
|
||||||
}}
|
) : currentVm ? (
|
||||||
vms={vms}
|
<VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} />
|
||||||
/>
|
) : (
|
||||||
)}
|
<Dashboard
|
||||||
|
health={health}
|
||||||
|
jobs={jobs}
|
||||||
|
loading={loading}
|
||||||
|
onCreateSchedule={() => switchView('scheduler')}
|
||||||
|
onManage={(vmName) => {
|
||||||
|
setSelectedVm(vmName);
|
||||||
|
setView('dashboard');
|
||||||
|
}}
|
||||||
|
schedules={schedules}
|
||||||
|
vms={vms}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function switchView(nextView) {
|
||||||
|
setSelectedVm(null);
|
||||||
|
setView(nextView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavButton({ active, icon: Icon, label, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm font-semibold transition ${
|
||||||
|
active
|
||||||
|
? 'border-l-4 border-red-500 bg-red-950/40 text-zinc-100'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-100'
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${active ? 'text-red-400' : 'text-zinc-500'}`} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,129 @@
|
|||||||
import { ChevronRight, Circle } from 'lucide-react';
|
import { Box, Circle, Container, Plus, Server, Timer } from 'lucide-react';
|
||||||
import { StatusCard } from './StatusCard.jsx';
|
|
||||||
|
|
||||||
export function Dashboard({ health, jobs, loading, onManage, vms }) {
|
export function Dashboard({ health, jobs, loading, onCreateSchedule, onManage, schedules, vms }) {
|
||||||
const activeJobs = jobs.filter((job) => ['queued', 'running'].includes(job.status));
|
const scheduleByVm = new Map((schedules || []).map((schedule) => [`${schedule.nodeId}:${schedule.vmName}`, schedule]));
|
||||||
const runningVms = vms.filter((vm) => vm.status === 'Running');
|
|
||||||
const lastSuccess = jobs.find((job) => job.status === 'success');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-5 xl:grid-cols-3">
|
||||||
<StatusCard label="Total VMs" value={loading ? '...' : vms.length} />
|
{vms.map((vm) => {
|
||||||
<StatusCard label="Running VMs" value={runningVms.length} tone="good" />
|
const activeJob = vm.activeJob || jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && ['queued', 'running'].includes(job.status));
|
||||||
<StatusCard label="Active Jobs" value={activeJobs.length} tone={activeJobs.length ? 'active' : 'neutral'} />
|
const lastJob = jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && job.status === 'success');
|
||||||
<StatusCard label="Last Success" value={lastSuccess ? formatTime(lastSuccess.finishedAt) : 'None'} tone="good" />
|
const schedule = scheduleByVm.get(`${vm.nodeId}:${vm.name}`);
|
||||||
<StatusCard label="Repository" value={health?.ok ? 'Ready' : 'Check'} tone={health?.ok ? 'good' : 'warn'} />
|
return (
|
||||||
|
<button
|
||||||
|
className="group relative min-h-[178px] overflow-hidden border border-zinc-700 bg-zinc-950/80 p-7 text-left outline-none transition hover:border-zinc-500 focus:border-red-400"
|
||||||
|
key={vm.id || `${vm.nodeId}:${vm.name}`}
|
||||||
|
onClick={() => onManage(vm.id || vm.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CornerFrame />
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{vm.type === 'container' ? <Container className="h-5 w-5 text-zinc-400" /> : <Box className="h-5 w-5 text-zinc-400" />}
|
||||||
|
<h2 className="truncate text-xl font-bold tracking-wide text-zinc-100">{vm.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="truncate">{vm.nodeName || 'Unknown node'}</span>
|
||||||
|
<span className="text-red-400">→</span>
|
||||||
|
<span>{vm.type === 'container' ? 'Container' : 'VM'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Circle className={`mt-1 h-4 w-4 shrink-0 fill-current ${vm.status === 'Running' ? 'text-emerald-400' : 'text-zinc-600'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-10 space-y-3 text-sm">
|
||||||
|
<Metric label="Schedule" value={scheduleLabel(schedule)} />
|
||||||
|
<Metric label="Last backup" value={lastJob ? relativeTime(lastJob.finishedAt || lastJob.createdAt) : vm.lastJobStatus || 'None'} />
|
||||||
|
<Metric label="Next backup" value={schedule?.enabled && schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '-'} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{activeJob ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-zinc-400">
|
||||||
|
<span className="truncate">{activeJob.currentStep || activeJob.status}</span>
|
||||||
|
<span className="font-mono">{Math.round(activeJob.progress?.percent || 0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-sm bg-zinc-800">
|
||||||
|
<div className="h-full bg-cyan-400" style={{ width: `${Math.round(activeJob.progress?.percent || 0)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex min-h-[178px] flex-col items-center justify-center gap-5 border border-dashed border-zinc-700 bg-zinc-950/50 text-zinc-100 transition hover:border-zinc-500"
|
||||||
|
onClick={onCreateSchedule}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-900 text-zinc-200">
|
||||||
|
<Plus className="h-7 w-7" />
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tracking-wide">Create a backup schedule</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
|
{!vms.length ? (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
<div className="border border-zinc-800 bg-zinc-950/80 px-5 py-10 text-center text-sm text-zinc-500">
|
||||||
<h1 className="text-sm font-semibold text-zinc-100">VM Backup Status</h1>
|
{loading ? 'Loading instances...' : 'No VMs or containers loaded.'}
|
||||||
<span className="text-xs text-zinc-500">{healthSummary(health)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[760px] text-left text-sm">
|
|
||||||
<thead className="border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium">VM</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Node</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Type</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Incus</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Last Job</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Active Job</th>
|
|
||||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-800">
|
|
||||||
{vms.map((vm) => (
|
|
||||||
<tr className="hover:bg-zinc-900" key={vm.id || vm.name}>
|
|
||||||
<td className="px-4 py-3 font-medium text-zinc-100">{vm.name}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-300">{vm.nodeName || '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-300">{vm.type === 'container' ? 'Container' : 'VM'}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="inline-flex items-center gap-2 text-zinc-300">
|
|
||||||
<Circle className={`h-2.5 w-2.5 fill-current ${vm.status === 'Running' ? 'text-emerald-400' : 'text-red-400'}`} />
|
|
||||||
{vm.status || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-300">{vm.lastJobStatus || 'None'}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-400">
|
|
||||||
{vm.activeJob ? (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate">{vm.activeJob.currentStep}</span>
|
|
||||||
<span className="font-mono text-xs text-zinc-500">{Math.round(vm.activeJob.progress?.percent || 0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-1.5 overflow-hidden rounded-md bg-zinc-800">
|
|
||||||
<div className="h-full bg-cyan-400" style={{ width: `${Math.round(vm.activeJob.progress?.percent || 0)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : 'Idle'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<button
|
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-zinc-700 px-2.5 text-xs text-zinc-100 hover:border-cyan-500 hover:text-cyan-200"
|
|
||||||
onClick={() => onManage(vm.id || vm.name)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Manage <ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!vms.length ? (
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-8 text-center text-zinc-500" colSpan="7">
|
|
||||||
No VMs loaded.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
<span>{healthSummary(health)}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value) {
|
function Metric({ label, value }) {
|
||||||
if (!value) return 'None';
|
return (
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-3 text-zinc-400">
|
||||||
|
<dt className="font-medium">{label}</dt>
|
||||||
|
<dd className="border-b border-dashed border-zinc-800" />
|
||||||
|
<dd className="max-w-[13rem] truncate text-right font-semibold text-zinc-200">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CornerFrame() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="pointer-events-none absolute left-0 top-0 h-5 w-5 border-l-2 border-t-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||||
|
<span className="pointer-events-none absolute right-0 top-0 h-5 w-5 border-r-2 border-t-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||||
|
<span className="pointer-events-none absolute bottom-0 left-0 h-5 w-5 border-b-2 border-l-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||||
|
<span className="pointer-events-none absolute bottom-0 right-0 h-5 w-5 border-b-2 border-r-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLabel(schedule) {
|
||||||
|
if (!schedule?.enabled) return '-';
|
||||||
|
if (schedule.timeOfDay) return schedule.timeOfDay;
|
||||||
|
return schedule.cron || 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '-';
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value));
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relativeTime(value) {
|
||||||
|
if (!value) return 'None';
|
||||||
|
const diffMs = Date.now() - new Date(value).getTime();
|
||||||
|
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||||||
|
if (diffMinutes < 1) return 'just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||||
|
const diffHours = Math.round(diffMinutes / 60);
|
||||||
|
if (diffHours < 48) return `${diffHours} hours ago`;
|
||||||
|
return `${Math.round(diffHours / 24)} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
function healthSummary(health) {
|
function healthSummary(health) {
|
||||||
if (!health) return 'Health loading';
|
if (!health) return 'Health loading';
|
||||||
if (health.ok) return 'All checks ok';
|
if (health.ok) return 'All checks ok';
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Cloud, Database, Eye, EyeOff, Save } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api, errorMessage } from '../api.js';
|
||||||
|
|
||||||
|
const repositoryKeys = [
|
||||||
|
'RESTIC_REPOSITORY',
|
||||||
|
'AWS_ACCESS_KEY_ID',
|
||||||
|
'AWS_SECRET_ACCESS_KEY',
|
||||||
|
'RESTIC_PASSWORD',
|
||||||
|
'RESTIC_KEEP_HOURLY',
|
||||||
|
'RESTIC_KEEP_DAILY',
|
||||||
|
'RESTIC_KEEP_WEEKLY',
|
||||||
|
'RESTIC_KEEP_MONTHLY',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Repositories({ nodes, onChanged }) {
|
||||||
|
const [settingsByNode, setSettingsByNode] = useState({});
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState('');
|
||||||
|
const [values, setValues] = useState({});
|
||||||
|
const [visibleSecrets, setVisibleSecrets] = useState({});
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const rows = useMemo(() => nodes.map((node) => {
|
||||||
|
const settings = settingsByNode[node.id];
|
||||||
|
const repository = settings?.values.RESTIC_REPOSITORY || '';
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
backend: backendName(repository),
|
||||||
|
repository,
|
||||||
|
retention: retentionLabel(settings?.values || {}),
|
||||||
|
settingsError: settings?.error,
|
||||||
|
};
|
||||||
|
}), [nodes, settingsByNode]);
|
||||||
|
|
||||||
|
const selectedSettings = settingsByNode[selectedNodeId];
|
||||||
|
const editableFields = (selectedSettings?.fields || []).filter((field) => repositoryKeys.includes(field.key));
|
||||||
|
const nodeSignature = nodes.map((node) => node.id).join('|');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRepositorySettings();
|
||||||
|
}, [nodeSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNodeId && rows[0]?.id) setSelectedNodeId(rows[0].id);
|
||||||
|
}, [rows, selectedNodeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSettings?.values) setValues(selectedSettings.values);
|
||||||
|
}, [selectedSettings]);
|
||||||
|
|
||||||
|
async function loadRepositorySettings() {
|
||||||
|
if (!nodes.length) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const results = await Promise.allSettled(nodes.map(async (node) => {
|
||||||
|
const result = await api.get(`/settings/${encodeURIComponent(node.id)}`);
|
||||||
|
return {
|
||||||
|
nodeId: node.id,
|
||||||
|
fields: result.data.fields || [],
|
||||||
|
values: Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const next = {};
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const node = nodes[index];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
next[node.id] = result.value;
|
||||||
|
} else {
|
||||||
|
next[node.id] = { fields: [], values: {}, error: errorMessage(result.reason) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSettingsByNode(next);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRepository(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage('');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.put(`/settings/${encodeURIComponent(selectedNodeId)}`, { values });
|
||||||
|
const fields = result.data.fields || [];
|
||||||
|
const nextValues = Object.fromEntries(fields.map((field) => [field.key, field.value || '']));
|
||||||
|
setSettingsByNode((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedNodeId]: { fields, values: nextValues },
|
||||||
|
}));
|
||||||
|
setValues(nextValues);
|
||||||
|
setMessage('Repository settings saved.');
|
||||||
|
await onChanged?.();
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(errorMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-5">
|
||||||
|
{error ? <Notice tone="bad" text={error} /> : null}
|
||||||
|
{message ? <Notice tone="good" text={message} /> : null}
|
||||||
|
|
||||||
|
<div className="overflow-hidden border border-zinc-700 bg-zinc-950/80">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
className="h-10 w-64 max-w-full border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none"
|
||||||
|
disabled
|
||||||
|
placeholder="Search..."
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<select className="h-10 border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-300 outline-none" disabled>
|
||||||
|
<option>All status</option>
|
||||||
|
</select>
|
||||||
|
<select className="h-10 border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-300 outline-none" disabled>
|
||||||
|
<option>All backends</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-md border border-zinc-700 px-3 text-sm font-semibold text-zinc-100 hover:border-zinc-500"
|
||||||
|
onClick={loadRepositorySettings}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[880px] text-left text-sm">
|
||||||
|
<thead className="border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-semibold">Name</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Backend</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Repository</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Retention</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
className={`cursor-pointer hover:bg-zinc-900 ${selectedNodeId === row.id ? 'bg-red-950/20' : ''}`}
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => setSelectedNodeId(row.id)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-4 font-semibold text-red-400">{row.name}</td>
|
||||||
|
<td className="px-4 py-4 text-zinc-300">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Cloud className="h-4 w-4 text-zinc-500" />
|
||||||
|
{row.backend}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[30rem] truncate px-4 py-4 font-mono text-xs text-zinc-300">{row.repository || '-'}</td>
|
||||||
|
<td className="px-4 py-4 text-zinc-300">{row.retention}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className={`inline-flex h-3 w-3 rounded-full ${statusClass(row, loading)}`} title={row.settingsError || row.lastHealthStatus || 'unknown'} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!rows.length ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-10 text-center text-zinc-500" colSpan="5">No nodes configured.</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-zinc-800 px-4 py-3 text-right text-sm text-zinc-400">
|
||||||
|
<span className="font-semibold text-red-400">{rows.length}</span> repositories
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNodeId ? (
|
||||||
|
<form className="border border-zinc-700 bg-zinc-950/80" onSubmit={saveRepository}>
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-zinc-100">Repository Settings</h2>
|
||||||
|
<p className="text-sm text-zinc-500">{nodes.find((node) => node.id === selectedNodeId)?.name || selectedNodeId}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-red-900 bg-red-950/40 px-3 text-sm text-red-100 hover:border-red-700"
|
||||||
|
disabled={saving || Boolean(selectedSettings?.error)}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSettings?.error ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-red-300">{selectedSettings.error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
||||||
|
{editableFields.map((field) => {
|
||||||
|
const secretVisible = visibleSecrets[field.key];
|
||||||
|
return (
|
||||||
|
<label className="block text-sm text-zinc-300" key={field.key}>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-500">{field.key}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 flex border border-zinc-700 bg-zinc-900 focus-within:border-red-700">
|
||||||
|
<input
|
||||||
|
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
||||||
|
min={field.key.startsWith('RESTIC_KEEP_') ? '0' : undefined}
|
||||||
|
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||||||
|
type={field.secret && !secretVisible ? 'password' : field.key.startsWith('RESTIC_KEEP_') ? 'number' : 'text'}
|
||||||
|
value={values[field.key] || ''}
|
||||||
|
/>
|
||||||
|
{field.secret ? (
|
||||||
|
<button
|
||||||
|
className="flex h-10 w-10 items-center justify-center border-l border-zinc-800 text-zinc-400 hover:text-zinc-100"
|
||||||
|
onClick={() => setVisibleSecrets((current) => ({ ...current, [field.key]: !current[field.key] }))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendName(repository) {
|
||||||
|
if (!repository) return '-';
|
||||||
|
if (repository.startsWith('s3:')) return 's3';
|
||||||
|
if (repository.startsWith('sftp:')) return 'sftp';
|
||||||
|
if (repository.startsWith('rest:')) return 'rest';
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionLabel(values) {
|
||||||
|
const parts = [
|
||||||
|
['h', values.RESTIC_KEEP_HOURLY],
|
||||||
|
['d', values.RESTIC_KEEP_DAILY],
|
||||||
|
['w', values.RESTIC_KEEP_WEEKLY],
|
||||||
|
['m', values.RESTIC_KEEP_MONTHLY],
|
||||||
|
].filter(([, value]) => value !== undefined && value !== '');
|
||||||
|
if (!parts.length) return '-';
|
||||||
|
return parts.map(([label, value]) => `${value}${label}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(row, loading) {
|
||||||
|
if (loading) return 'bg-amber-400';
|
||||||
|
if (row.settingsError) return 'bg-red-500';
|
||||||
|
if (row.lastHealthStatus === 'ok' || row.lastHealthStatus === 'healthy') return 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.8)]';
|
||||||
|
return 'bg-zinc-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notice({ text, tone }) {
|
||||||
|
const classes = {
|
||||||
|
bad: 'border-red-900 bg-red-950/50 text-red-200',
|
||||||
|
good: 'border-emerald-900 bg-emerald-950/50 text-emerald-200',
|
||||||
|
};
|
||||||
|
return <div className={`rounded-md border px-4 py-3 text-sm ${classes[tone]}`}>{text}</div>;
|
||||||
|
}
|
||||||
+10
-1
@@ -17,7 +17,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,3 +26,11 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-grid-bg {
|
||||||
|
background-color: #09090b;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(63, 63, 70, 0.28) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(63, 63, 70, 0.28) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user