{view === 'nodes' ? (
+ ) : view === 'operations' ? (
+
) : view === 'settings' ? (
) : view === 'scheduler' ? (
diff --git a/frontend/src/components/Operations.jsx b/frontend/src/components/Operations.jsx
new file mode 100644
index 0000000..ac094db
--- /dev/null
+++ b/frontend/src/components/Operations.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useState } from 'react';
+import { api, errorMessage } from '../api.js';
+
+export function Operations() {
+ const [audit, setAudit] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [error, setError] = useState('');
+
+ async function load() {
+ setError('');
+ try {
+ const [auditResult, historyResult] = await Promise.all([
+ api.get('/operations/audit'),
+ api.get('/operations/history'),
+ ]);
+ setAudit(auditResult.data);
+ setHistory(historyResult.data);
+ } catch (requestError) {
+ setError(errorMessage(requestError));
+ }
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ return (
+
+
+
Operations
+
+ {error ? {error}
: null}
+ [
+ formatTime(row.startedAt),
+ row.type,
+ row.nodeName,
+ row.vmName,
+ row.error || row.status,
+ row.agentJobId || '-',
+ ])}
+ title="Job History"
+ />
+ [
+ formatTime(row.createdAt),
+ row.username || 'system',
+ row.action,
+ `${row.targetType}${row.targetId ? `:${row.targetId}` : ''}`,
+ JSON.stringify(row.details || {}),
+ ])}
+ title="Audit Log"
+ />
+
+ );
+}
+
+function Table({ columns, rows, title }) {
+ return (
+
+
+
{title}
+
+
+
+
+ {columns.map((column) => | {column} | )}
+
+
+ {rows.map((row, index) => (
+
+ {row.map((cell, cellIndex) => | {cell} | )}
+
+ ))}
+ {!rows.length ? | No entries. |
: null}
+
+
+
+
+ );
+}
+
+function formatTime(value) {
+ if (!value) return '-';
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
+}
diff --git a/management/package.json b/management/package.json
index 0318e43..76d2f7b 100644
--- a/management/package.json
+++ b/management/package.json
@@ -5,6 +5,7 @@
"private": true,
"scripts": {
"dev": "node --watch src/index.js",
+ "reset-password": "node src/reset-password.js",
"start": "node src/index.js"
},
"dependencies": {
diff --git a/management/src/db.js b/management/src/db.js
index 9655e8c..c964167 100644
--- a/management/src/db.js
+++ b/management/src/db.js
@@ -46,6 +46,28 @@ export function migrate() {
UNIQUE (node_id, vm_name),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS audit_events (
+ id TEXT PRIMARY KEY,
+ user_id TEXT,
+ username TEXT,
+ action TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_id TEXT,
+ details TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS job_history (
+ id TEXT PRIMARY KEY,
+ node_id TEXT NOT NULL,
+ node_name TEXT NOT NULL,
+ vm_name TEXT NOT NULL,
+ type TEXT NOT NULL,
+ agent_job_id TEXT,
+ status TEXT NOT NULL,
+ error TEXT,
+ started_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
`);
const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
diff --git a/management/src/index.js b/management/src/index.js
index 053f99e..9e2dfc6 100644
--- a/management/src/index.js
+++ b/management/src/index.js
@@ -5,6 +5,7 @@ import { migrate } from './db.js';
import { requireAuth } from './auth.js';
import { authRouter } from './routes/auth.js';
import { nodesRouter } from './routes/nodes.js';
+import { operationsRouter } from './routes/operations.js';
import { proxyRouter } from './routes/proxy.js';
import { schedulesRouter } from './routes/schedules.js';
import { startScheduler } from './scheduler.js';
@@ -19,6 +20,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api', requireAuth, proxyRouter);
app.use('/api/nodes', requireAuth, nodesRouter);
+app.use('/api/operations', requireAuth, operationsRouter);
app.use('/api/schedules', requireAuth, schedulesRouter);
app.use((error, _req, res, _next) => {
diff --git a/management/src/reset-password.js b/management/src/reset-password.js
new file mode 100644
index 0000000..d358eee
--- /dev/null
+++ b/management/src/reset-password.js
@@ -0,0 +1,23 @@
+import { config } from './config.js';
+import { hashPassword } from './crypto.js';
+import { migrate } from './db.js';
+import { getUserByUsername, setUserPassword } from './store.js';
+
+migrate();
+
+const username = process.argv[2] || config.authUsername;
+const password = process.argv[3] || config.authPassword;
+
+if (!username || !password) {
+ console.error('Usage: node src/reset-password.js ');
+ process.exit(1);
+}
+
+const user = getUserByUsername(username);
+if (!user) {
+ console.error(`User "${username}" was not found.`);
+ process.exit(1);
+}
+
+setUserPassword(username, hashPassword(password));
+console.log(`Password updated for ${username}.`);
diff --git a/management/src/routes/auth.js b/management/src/routes/auth.js
index b6b73d2..9c3688c 100644
--- a/management/src/routes/auth.js
+++ b/management/src/routes/auth.js
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { clearSessionCookie, currentSession, login, logout, setSessionCookie } from '../auth.js';
+import { createAuditEvent } from '../store.js';
export const authRouter = Router();
@@ -15,11 +16,25 @@ authRouter.post('/login', (req, res) => {
return;
}
setSessionCookie(res, result.session);
+ createAuditEvent({
+ user: result.user,
+ action: 'login',
+ targetType: 'session',
+ targetId: result.session.id,
+ });
res.json({ user: result.user });
});
authRouter.post('/logout', (req, res) => {
const session = currentSession(req);
+ if (session) {
+ createAuditEvent({
+ user: session.user,
+ action: 'logout',
+ targetType: 'session',
+ targetId: session.sessionId,
+ });
+ }
logout(session?.sessionId);
clearSessionCookie(res);
res.json({ ok: true });
diff --git a/management/src/routes/nodes.js b/management/src/routes/nodes.js
index 3a6f91f..e64c73c 100644
--- a/management/src/routes/nodes.js
+++ b/management/src/routes/nodes.js
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { agentRequest, publicNode } from '../agentClient.js';
-import { createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
+import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
export const nodesRouter = Router();
@@ -11,6 +11,7 @@ nodesRouter.get('/', (_req, res) => {
nodesRouter.post('/', async (req, res, next) => {
try {
const node = createNode(validateNodeInput(req.body));
+ createAuditEvent({ user: req.user, action: 'create', targetType: 'node', targetId: node.id, details: { name: node.name, baseUrl: node.baseUrl } });
res.status(201).json(publicNode(node));
} catch (error) {
next(error);
@@ -24,6 +25,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
res.status(404).json({ error: 'Node not found.' });
return;
}
+ createAuditEvent({ user: req.user, action: 'update', targetType: 'node', targetId: node.id, details: { name: node.name, baseUrl: node.baseUrl, enabled: node.enabled } });
res.json(publicNode(node));
} catch (error) {
next(error);
@@ -31,6 +33,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
});
nodesRouter.delete('/:nodeId', (req, res) => {
+ createAuditEvent({ user: req.user, action: 'delete', targetType: 'node', targetId: req.params.nodeId });
deleteNode(req.params.nodeId);
res.json({ ok: true });
});
diff --git a/management/src/routes/operations.js b/management/src/routes/operations.js
new file mode 100644
index 0000000..8b85b41
--- /dev/null
+++ b/management/src/routes/operations.js
@@ -0,0 +1,12 @@
+import { Router } from 'express';
+import { listAuditEvents, listJobHistory } from '../store.js';
+
+export const operationsRouter = Router();
+
+operationsRouter.get('/audit', (_req, res) => {
+ res.json(listAuditEvents());
+});
+
+operationsRouter.get('/history', (_req, res) => {
+ res.json(listJobHistory());
+});
diff --git a/management/src/routes/proxy.js b/management/src/routes/proxy.js
index 44ba998..43c9fe0 100644
--- a/management/src/routes/proxy.js
+++ b/management/src/routes/proxy.js
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { agentRequest } from '../agentClient.js';
-import { getNode, listNodes, recordNodeHealth } from '../store.js';
+import { createAuditEvent, createJobHistory, getNode, listNodes, recordNodeHealth, updateJobHistory } from '../store.js';
export const proxyRouter = Router();
@@ -73,19 +73,29 @@ proxyRouter.get('/snapshots/:nodeId/:vmName/:snapshotId/files', async (req, res,
});
proxyRouter.post('/backup/:nodeId/:vmName', async (req, res, next) => {
+ const node = requireNode(req.params.nodeId);
+ let historyId = null;
try {
- const node = requireNode(req.params.nodeId);
- res.status(202).json(await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
+ const result = await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
+ historyId = createJobHistory({ node, vmName: req.params.vmName, type: 'backup', agentJobId: result?.jobId, status: 'accepted' });
+ createAuditEvent({ user: req.user, action: 'backup', targetType: 'vm', targetId: `${node.id}:${req.params.vmName}`, details: { historyId, agentJobId: result?.jobId } });
+ res.status(202).json(result);
} catch (error) {
+ if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error);
}
});
proxyRouter.post('/restore/:nodeId/:vmName', async (req, res, next) => {
+ const node = requireNode(req.params.nodeId);
+ let historyId = null;
try {
- const node = requireNode(req.params.nodeId);
- res.status(202).json(await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
+ const result = await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
+ historyId = createJobHistory({ node, vmName: req.params.vmName, type: 'restore', agentJobId: result?.jobId, status: 'accepted' });
+ createAuditEvent({ user: req.user, action: 'restore', targetType: 'vm', targetId: `${node.id}:${req.params.vmName}`, details: { historyId, agentJobId: result?.jobId, snapshotId: req.body?.snapshotId } });
+ res.status(202).json(result);
} catch (error) {
+ if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error);
}
});
@@ -102,7 +112,9 @@ proxyRouter.get('/settings/:nodeId', async (req, res, next) => {
proxyRouter.put('/settings/:nodeId', async (req, res, next) => {
try {
const node = requireNode(req.params.nodeId);
- res.json(await agentRequest(node, '/settings', { method: 'PUT', body: req.body || {} }));
+ const result = await agentRequest(node, '/settings', { method: 'PUT', body: req.body || {} });
+ createAuditEvent({ user: req.user, action: 'update', targetType: 'node-settings', targetId: node.id });
+ res.json(result);
} catch (error) {
next(error);
}
diff --git a/management/src/routes/schedules.js b/management/src/routes/schedules.js
index 8bd0527..2e7feaf 100644
--- a/management/src/routes/schedules.js
+++ b/management/src/routes/schedules.js
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import { listSchedules, replaceSchedules } from '../store.js';
+import { createAuditEvent, listSchedules, replaceSchedules } from '../store.js';
export const schedulesRouter = Router();
@@ -9,7 +9,9 @@ schedulesRouter.get('/', (_req, res) => {
schedulesRouter.put('/', (req, res, next) => {
try {
- res.json(replaceSchedules(req.body?.schedules || []));
+ const schedules = replaceSchedules(req.body?.schedules || []);
+ createAuditEvent({ user: req.user, action: 'replace', targetType: 'schedules', details: { count: schedules.length } });
+ res.json(schedules);
} catch (error) {
next(error);
}
diff --git a/management/src/scheduler.js b/management/src/scheduler.js
index 5646b4b..be48bf1 100644
--- a/management/src/scheduler.js
+++ b/management/src/scheduler.js
@@ -1,5 +1,5 @@
import { agentRequest } from './agentClient.js';
-import { listDueSchedules, markScheduleRun, nextRunFrom } from './store.js';
+import { createAuditEvent, createJobHistory, listDueSchedules, markScheduleRun, nextRunFrom } from './store.js';
let timer = null;
@@ -15,11 +15,15 @@ async function runDueSchedules() {
const schedules = listDueSchedules(now);
for (const schedule of schedules) {
try {
- await agentRequest({
+ const node = {
id: schedule.node_id,
+ name: schedule.node_name,
baseUrl: schedule.base_url,
token: schedule.token,
- }, `/backup/${encodeURIComponent(schedule.vm_name)}`, { method: 'POST' });
+ };
+ const result = await agentRequest(node, `/backup/${encodeURIComponent(schedule.vm_name)}`, { method: 'POST' });
+ const historyId = createJobHistory({ node, vmName: schedule.vm_name, type: 'scheduled-backup', agentJobId: result?.jobId, status: 'accepted' });
+ createAuditEvent({ action: 'scheduled-backup', targetType: 'vm', targetId: `${node.id}:${schedule.vm_name}`, details: { scheduleId: schedule.id, historyId, agentJobId: result?.jobId } });
markScheduleRun(schedule.id, {
lastRunAt: now.toISOString(),
diff --git a/management/src/store.js b/management/src/store.js
index 07e76da..f3358f3 100644
--- a/management/src/store.js
+++ b/management/src/store.js
@@ -5,6 +5,10 @@ export function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
+export function setUserPassword(username, passwordHash) {
+ return db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(passwordHash, username);
+}
+
export function getUserBySession(sessionId) {
return db.prepare(`
SELECT users.id, users.username
@@ -64,6 +68,65 @@ export function deleteNode(id) {
db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
}
+export function createAuditEvent({ user = null, action, targetType, targetId = null, details = {} }) {
+ db.prepare(`
+ INSERT INTO audit_events (id, user_id, username, action, target_type, target_id, details, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ `audit_${randomToken(8)}`,
+ user?.id || null,
+ user?.username || null,
+ action,
+ targetType,
+ targetId,
+ JSON.stringify(details),
+ new Date().toISOString(),
+ );
+}
+
+export function listAuditEvents(limit = 100) {
+ return db.prepare('SELECT * FROM audit_events ORDER BY created_at DESC LIMIT ?').all(limit).map((row) => ({
+ id: row.id,
+ userId: row.user_id,
+ username: row.username,
+ action: row.action,
+ targetType: row.target_type,
+ targetId: row.target_id,
+ details: JSON.parse(row.details || '{}'),
+ createdAt: row.created_at,
+ }));
+}
+
+export function createJobHistory({ node, vmName, type, agentJobId = null, status = 'started', error = null }) {
+ const now = new Date().toISOString();
+ const id = `hist_${randomToken(8)}`;
+ db.prepare(`
+ INSERT INTO job_history (id, node_id, node_name, vm_name, type, agent_job_id, status, error, started_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(id, node.id, node.name, vmName, type, agentJobId, status, error, now, now);
+ return id;
+}
+
+export function updateJobHistory(id, values) {
+ db.prepare('UPDATE job_history SET status = ?, error = ?, updated_at = ? WHERE id = ?')
+ .run(values.status, values.error || null, new Date().toISOString(), id);
+}
+
+export function listJobHistory(limit = 100) {
+ return db.prepare('SELECT * FROM job_history ORDER BY started_at DESC LIMIT ?').all(limit).map((row) => ({
+ id: row.id,
+ nodeId: row.node_id,
+ nodeName: row.node_name,
+ vmName: row.vm_name,
+ type: row.type,
+ agentJobId: row.agent_job_id,
+ status: row.status,
+ error: row.error,
+ startedAt: row.started_at,
+ updatedAt: row.updated_at,
+ }));
+}
+
export function recordNodeHealth(id, status) {
db.prepare('UPDATE nodes SET last_health_status = ?, last_health_at = ? WHERE id = ?')
.run(status, new Date().toISOString(), id);