diff --git a/.gitignore b/.gitignore
index 12f230b..29dee2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,14 +4,20 @@ node_modules/
# Local environment and secrets
.env
.env.*
-.env.example
+!.env.example
backend/.env
backend/.env.*
!backend/.env.example
+management/.env
+management/.env.*
+!management/.env.example
# Runtime state
backend/schedules.json
backend/schedules.json.tmp
+management/*.sqlite
+management/*.sqlite-shm
+management/*.sqlite-wal
# Build output
dist/
diff --git a/README.md b/README.md
index 12e6d77..cd8396e 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,12 @@ Dark-mode control plane for Incus VM backups using ZFS block devices, Restic, an
## Layout
-- `backend/`: Express API that validates Incus VMs, runs host commands with `spawn`, manages in-memory jobs, and enforces one active backup/restore per VM.
-- `frontend/`: React/Vite dashboard for health, VM status, snapshots, backup jobs, and explicit destructive restore confirmation.
+- `management/`: Central Express API with cookie login, SQLite storage, node registry, central schedules, and proxy calls to node agents.
+- `backend/`: Node agent API that runs on each Incus host, validates Incus VMs, runs host commands with `spawn`, manages in-memory jobs, and enforces one active backup/restore per VM.
+- `frontend/`: React/Vite dashboard for management login, node management, health, VM status, snapshots, backup jobs, and explicit destructive restore confirmation.
- `incus-backup-ui-plan.md`: Product and implementation plan.
-## Backend
+## Node Agent
```bash
cd backend
@@ -17,7 +18,9 @@ npm install
npm run dev
```
-The backend must run on the Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges.
+The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges.
+
+Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent.
Required commands:
@@ -27,6 +30,19 @@ Required commands:
- `udevadm`
- `dd`
+## Management API
+
+```bash
+cd management
+cp .env.example .env
+npm install
+npm run dev
+```
+
+The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. If no password is configured, the development fallback is `admin`.
+
+The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer.
+
## Frontend
```bash
@@ -35,17 +51,13 @@ npm install
npm run dev
```
-Set `VITE_API_URL` if the API is not available at `http://localhost:3000/api`.
-
-If `API_TOKEN` is set in the backend, set matching `VITE_API_TOKEN` for the frontend.
+Set `VITE_API_URL` if the management API is not available at `http://localhost:3100/api`.
## Settings Page
-The UI includes a Settings page for editing the backend environment values directly. It writes to `backend/.env` through `GET /api/settings` and `PUT /api/settings`.
+The UI includes a Settings page for editing selected node-agent environment values through the management API. The management server forwards those requests to the selected node agent.
-If `API_TOKEN` is set from the Settings page, the browser stores that token locally and immediately uses it for subsequent API requests. If a token is already configured on the backend and the browser does not know it yet, start the frontend with `VITE_API_TOKEN` or clear/update the token manually in browser storage.
-
-Changing most values applies to new API calls and jobs immediately. Changing `PORT` requires restarting the backend process.
+Changing most node-agent values applies to new API calls and jobs immediately. Changing a node-agent `PORT` requires restarting that agent process.
## Safety Notes
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5431d5a..928ad93 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
-import { AlertTriangle, CalendarClock, DatabaseBackup, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
+import { AlertTriangle, CalendarClock, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } 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 { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx';
@@ -11,7 +13,10 @@ export default function App() {
const [vms, setVms] = useState([]);
const [jobs, setJobs] = useState([]);
const [schedules, setSchedules] = useState([]);
+ const [nodes, setNodes] = useState([]);
const [selectedVm, setSelectedVm] = useState(null);
+ const [session, setSession] = useState(null);
+ const [sessionLoading, setSessionLoading] = useState(true);
const [view, setView] = useState('dashboard');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
@@ -19,11 +24,12 @@ export default function App() {
async function refresh() {
setError('');
try {
- const [healthResult, vmsResult, jobsResult, schedulesResult] = await Promise.allSettled([
+ const [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult] = await Promise.allSettled([
api.get('/health'),
api.get('/vms'),
api.get('/jobs'),
api.get('/schedules'),
+ api.get('/nodes'),
]);
if (healthResult.status === 'fulfilled') {
@@ -35,8 +41,9 @@ export default function App() {
if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data);
if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data);
if (schedulesResult.status === 'fulfilled') setSchedules(schedulesResult.value.data);
+ if (nodesResult.status === 'fulfilled') setNodes(nodesResult.value.data);
- const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult].find((result) => result.status === 'rejected');
+ const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected');
if (firstFailure) setError(errorMessage(firstFailure.reason));
} catch (requestError) {
setError(errorMessage(requestError));
@@ -46,16 +53,49 @@ export default function App() {
}
useEffect(() => {
+ loadSession();
+ }, []);
+
+ useEffect(() => {
+ if (!session?.authenticated) return undefined;
refresh();
const timer = window.setInterval(refresh, 5000);
return () => window.clearInterval(timer);
- }, []);
+ }, [session?.authenticated]);
const currentVm = useMemo(
- () => vms.find((vm) => vm.name === selectedVm) || null,
+ () => vms.find((vm) => vm.id === selectedVm) || null,
[selectedVm, vms],
);
+ async function loadSession() {
+ try {
+ const result = await api.get('/auth/session');
+ setSession(result.data);
+ } catch {
+ setSession({ authenticated: false, user: null });
+ } finally {
+ setSessionLoading(false);
+ }
+ }
+
+ async function logout() {
+ await api.post('/auth/logout');
+ setSession({ authenticated: false, user: null });
+ setVms([]);
+ setJobs([]);
+ setSchedules([]);
+ setNodes([]);
+ }
+
+ if (sessionLoading) {
+ return ;
+ }
+
+ if (!session?.authenticated) {
+ return ;
+ }
+
return (
@@ -77,6 +117,17 @@ export default function App() {