From 7eb9fdef18ccc036c70ae668a20e0e2610459353 Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 21 May 2026 12:49:32 +0200 Subject: [PATCH] changed UI layout new Navigation Dashboard --- docs/fixes-todo.md | 189 ++++++++++++++++ frontend/src/App.jsx | 227 ++++++++++--------- frontend/src/components/Dashboard.jsx | 189 +++++++++------- frontend/src/components/Repositories.jsx | 270 +++++++++++++++++++++++ frontend/src/styles.css | 11 +- 5 files changed, 707 insertions(+), 179 deletions(-) create mode 100644 docs/fixes-todo.md create mode 100644 frontend/src/components/Repositories.jsx diff --git a/docs/fixes-todo.md b/docs/fixes-todo.md new file mode 100644 index 0000000..bd7b4d8 --- /dev/null +++ b/docs/fixes-todo.md @@ -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 ` 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). diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 57d835c..3fa0bb5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,23 @@ 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 { Dashboard } from './components/Dashboard.jsx'; import { Login } from './components/Login.jsx'; import { Nodes } from './components/Nodes.jsx'; import { Operations } from './components/Operations.jsx'; +import { Repositories } from './components/Repositories.jsx'; import { Scheduler } from './components/Scheduler.jsx'; import { Settings } from './components/Settings.jsx'; import { VMDetail } from './components/VMDetail.jsx'; @@ -97,122 +110,136 @@ export default function App() { return ; } + const pageTitle = currentVm + ? currentVm.name + : { + dashboard: 'Backups', + nodes: 'Nodes', + repositories: 'Repositories', + operations: 'Operations', + scheduler: 'Scheduler', + settings: 'Settings', + }[view] || 'Backups'; + return (
-
-
+
+
+ - {error ? ( -
- - {error} -
- ) : null} +
+
+
+ +

{pageTitle}

+
+
+ {session.user?.username || 'admin'} + + + +
+
-
- {view === 'nodes' ? ( - - ) : view === 'operations' ? ( - - ) : view === 'settings' ? ( - - ) : view === 'scheduler' ? ( - - ) : currentVm ? ( - setSelectedVm(null)} onChanged={refresh} /> - ) : ( - { - setSelectedVm(vmName); - setView('dashboard'); - }} - vms={vms} - /> - )} +
+ {error ? ( +
+ + {error} +
+ ) : null} + +
+ {view === 'nodes' ? ( + + ) : view === 'repositories' ? ( + + ) : view === 'operations' ? ( + + ) : view === 'settings' ? ( + + ) : view === 'scheduler' ? ( + + ) : currentVm ? ( + setSelectedVm(null)} onChanged={refresh} /> + ) : ( + switchView('scheduler')} + onManage={(vmName) => { + setSelectedVm(vmName); + setView('dashboard'); + }} + schedules={schedules} + vms={vms} + /> + )} +
+
+
); + + function switchView(nextView) { + setSelectedVm(null); + setView(nextView); + } +} + +function NavButton({ active, icon: Icon, label, onClick }) { + return ( + + ); } diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 0029af1..629c32f 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,96 +1,129 @@ -import { ChevronRight, Circle } from 'lucide-react'; -import { StatusCard } from './StatusCard.jsx'; +import { Box, Circle, Container, Plus, Server, Timer } from 'lucide-react'; -export function Dashboard({ health, jobs, loading, onManage, vms }) { - const activeJobs = jobs.filter((job) => ['queued', 'running'].includes(job.status)); - const runningVms = vms.filter((vm) => vm.status === 'Running'); - const lastSuccess = jobs.find((job) => job.status === 'success'); +export function Dashboard({ health, jobs, loading, onCreateSchedule, onManage, schedules, vms }) { + const scheduleByVm = new Map((schedules || []).map((schedule) => [`${schedule.nodeId}:${schedule.vmName}`, schedule])); return (
-
- - - - - +
+ {vms.map((vm) => { + const activeJob = vm.activeJob || jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && ['queued', 'running'].includes(job.status)); + const lastJob = jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && job.status === 'success'); + const schedule = scheduleByVm.get(`${vm.nodeId}:${vm.name}`); + return ( + + ); + })} + +
-
-
-

VM Backup Status

- {healthSummary(health)} -
-
- - - - - - - - - - - - - - {vms.map((vm) => ( - - - - - - - - - - ))} - {!vms.length ? ( - - - - ) : null} - -
VMNodeTypeIncusLast JobActive JobActions
{vm.name}{vm.nodeName || '-'}{vm.type === 'container' ? 'Container' : 'VM'} - - - {vm.status || 'Unknown'} - - {vm.lastJobStatus || 'None'} - {vm.activeJob ? ( -
-
- {vm.activeJob.currentStep} - {Math.round(vm.activeJob.progress?.percent || 0)}% -
-
-
-
-
- ) : 'Idle'} -
- -
- No VMs loaded. -
+ {!vms.length ? ( +
+ {loading ? 'Loading instances...' : 'No VMs or containers loaded.'}
+ ) : null} + +
+ + {healthSummary(health)}
); } -function formatTime(value) { - if (!value) return 'None'; +function Metric({ label, value }) { + return ( +
+
{label}
+
+
{value}
+
+ ); +} + +function CornerFrame() { + return ( + <> + + + + + + ); +} + +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)); } +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) { if (!health) return 'Health loading'; if (health.ok) return 'All checks ok'; diff --git a/frontend/src/components/Repositories.jsx b/frontend/src/components/Repositories.jsx new file mode 100644 index 0000000..70808b1 --- /dev/null +++ b/frontend/src/components/Repositories.jsx @@ -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 ( +
+ {error ? : null} + {message ? : null} + +
+
+
+ + + +
+ +
+ +
+ + + + + + + + + + + + {rows.map((row) => ( + setSelectedNodeId(row.id)} + > + + + + + + + ))} + {!rows.length ? ( + + + + ) : null} + +
NameBackendRepositoryRetentionStatus
{row.name} + + + {row.backend} + + {row.repository || '-'}{row.retention} + +
No nodes configured.
+
+
+ {rows.length} repositories +
+
+ + {selectedNodeId ? ( +
+
+
+

Repository Settings

+

{nodes.find((node) => node.id === selectedNodeId)?.name || selectedNodeId}

+
+ +
+ + {selectedSettings?.error ? ( +
{selectedSettings.error}
+ ) : ( +
+ {editableFields.map((field) => { + const secretVisible = visibleSecrets[field.key]; + return ( + + ); + })} +
+ )} +
+ ) : null} +
+ ); +} + +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
{text}
; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6ab0ab2..78f3a59 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -17,7 +17,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -25,3 +26,11 @@ button:disabled { cursor: not-allowed; 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; +}