changed UI layout

new Navigation
Dashboard
This commit is contained in:
Philipp
2026-05-21 12:49:32 +02:00
parent bef27416bf
commit 7eb9fdef18
5 changed files with 707 additions and 179 deletions
+189
View File
@@ -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).
+98 -71
View File
@@ -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,101 +110,88 @@ 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>
</aside>
<section className="min-w-0">
<header className="flex h-20 items-center justify-between border-b border-zinc-800 bg-zinc-900/80 px-6">
<div className="flex items-center gap-3">
<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">
<DatabaseBackup className="h-4 w-4" />
</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> </div>
</header> </header>
<div className="app-grid-bg min-h-[calc(100vh-5rem)] px-5 py-7 sm:px-8">
{error ? ( {error ? (
<div className="mx-auto mt-4 flex max-w-7xl items-center gap-2 px-4 text-sm text-amber-300 sm:px-6"> <div className="mb-5 flex items-center gap-2 text-sm text-amber-300">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{error} {error}
</div> </div>
) : null} ) : null}
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6"> <div className="mx-auto max-w-[1500px]">
{view === 'nodes' ? ( {view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} /> <Nodes nodes={nodes} onChanged={refresh} />
) : view === 'repositories' ? (
<Repositories nodes={nodes} onChanged={refresh} />
) : view === 'operations' ? ( ) : view === 'operations' ? (
<Operations /> <Operations />
) : view === 'settings' ? ( ) : view === 'settings' ? (
@@ -205,14 +205,41 @@ export default function App() {
health={health} health={health}
jobs={jobs} jobs={jobs}
loading={loading} loading={loading}
onCreateSchedule={() => switchView('scheduler')}
onManage={(vmName) => { onManage={(vmName) => {
setSelectedVm(vmName); setSelectedVm(vmName);
setView('dashboard'); setView('dashboard');
}} }}
schedules={schedules}
vms={vms} vms={vms}
/> />
)} )}
</div> </div>
</div>
</section>
</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>
);
} }
+109 -76
View File
@@ -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 (
</div>
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<h1 className="text-sm font-semibold text-zinc-100">VM Backup Status</h1>
<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 <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" 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)} onClick={() => onManage(vm.id || vm.name)}
type="button" type="button"
> >
Manage <ChevronRight className="h-4 w-4" /> <CornerFrame />
</button> <div className="flex items-start justify-between gap-5">
</td> <div className="min-w-0">
</tr> <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" />}
{!vms.length ? ( <h2 className="truncate text-xl font-bold tracking-wide text-zinc-100">{vm.name}</h2>
<tr>
<td className="px-4 py-8 text-center text-zinc-500" colSpan="7">
No VMs loaded.
</td>
</tr>
) : null}
</tbody>
</table>
</div> </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>
{!vms.length ? (
<div className="border border-zinc-800 bg-zinc-950/80 px-5 py-10 text-center text-sm text-zinc-500">
{loading ? 'Loading instances...' : 'No VMs or containers loaded.'}
</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';
+270
View File
@@ -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
View File
@@ -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;
}