added progress bar and scheduler

This commit is contained in:
Philipp
2026-05-21 08:53:21 +02:00
parent 3d46f8543b
commit f9350587fe
18 changed files with 624 additions and 165 deletions
+7
View File
@@ -0,0 +1,7 @@
AWS_ACCESS_KEY_ID="FYL6LIS897TPMCMB9SIH"
AWS_SECRET_ACCESS_KEY="TGeYji4XfYohBN4Ut5UYoP6WvU8wq1Hm7fLcbnoR"
RESTIC_REPOSITORY="s3:https://nbg1.your-objectstorage.com/incus001"
RESTIC_PASSWORD="Wwmmsbc#223"
ZFS_POOL_NAME="incus-pool"
PORT=3000
API_TOKEN=""
+22 -3
View File
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { PassThrough } from 'node:stream'; import { PassThrough, Transform } from 'node:stream';
import { resticProcessEnv } from './config.js'; import { resticProcessEnv } from './config.js';
export class CommandError extends Error { export class CommandError extends Error {
@@ -74,7 +74,22 @@ export function runRestic(args, options = {}) {
}); });
} }
export function streamResticDumpToDd(snapshotId, filename, outputPath, log) { export function createProgressStream(totalBytes, onProgress) {
let currentBytes = 0;
return new Transform({
transform(chunk, _encoding, callback) {
currentBytes += chunk.length;
onProgress?.({
currentBytes,
totalBytes,
percent: totalBytes ? (currentBytes / totalBytes) * 100 : 0,
});
callback(null, chunk);
},
});
}
export function streamResticDumpToDd(snapshotId, filename, outputPath, log, progress = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const restic = spawn('restic', ['dump', snapshotId, filename], { const restic = spawn('restic', ['dump', snapshotId, filename], {
env: resticProcessEnv(), env: resticProcessEnv(),
@@ -91,7 +106,11 @@ export function streamResticDumpToDd(snapshotId, filename, outputPath, log) {
let resticClosed = false; let resticClosed = false;
let ddClosed = false; let ddClosed = false;
const pipe = new PassThrough(); const pipe = new PassThrough();
restic.stdout.pipe(pipe).pipe(dd.stdin); let restoreStream = restic.stdout.pipe(pipe);
if (progress) {
restoreStream = restoreStream.pipe(createProgressStream(progress.totalBytes, progress.onProgress));
}
restoreStream.pipe(dd.stdin);
restic.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd())); restic.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd()));
dd.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd())); dd.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd()));
+7
View File
@@ -5,9 +5,11 @@ import { backupRouter } from './routes/backup.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { jobsRouter } from './routes/jobs.js'; import { jobsRouter } from './routes/jobs.js';
import { restoreRouter } from './routes/restore.js'; import { restoreRouter } from './routes/restore.js';
import { schedulesRouter } from './routes/schedules.js';
import { settingsRouter } from './routes/settings.js'; import { settingsRouter } from './routes/settings.js';
import { snapshotsRouter } from './routes/snapshots.js'; import { snapshotsRouter } from './routes/snapshots.js';
import { vmsRouter } from './routes/vms.js'; import { vmsRouter } from './routes/vms.js';
import { startScheduler } from './scheduler.js';
const app = express(); const app = express();
@@ -33,6 +35,7 @@ app.use('/api/snapshots', snapshotsRouter);
app.use('/api/jobs', jobsRouter); app.use('/api/jobs', jobsRouter);
app.use('/api/backup', backupRouter); app.use('/api/backup', backupRouter);
app.use('/api/restore', restoreRouter); app.use('/api/restore', restoreRouter);
app.use('/api/schedules', schedulesRouter);
app.use('/api/settings', settingsRouter); app.use('/api/settings', settingsRouter);
app.use((error, _req, res, _next) => { app.use((error, _req, res, _next) => {
@@ -43,3 +46,7 @@ app.use((error, _req, res, _next) => {
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`Incus backup API listening on http://localhost:${config.port}`); console.log(`Incus backup API listening on http://localhost:${config.port}`);
}); });
startScheduler().catch((error) => {
console.error(`Failed to start scheduler: ${error.message}`);
});
+24
View File
@@ -22,6 +22,12 @@ export function createJob(type, vmName) {
startedAt: now, startedAt: now,
finishedAt: null, finishedAt: null,
currentStep: 'Queued', currentStep: 'Queued',
progress: {
percent: 0,
detail: 'Queued',
currentBytes: 0,
totalBytes: null,
},
logs: [], logs: [],
error: null, error: null,
}; };
@@ -56,9 +62,22 @@ export function setJobRunning(job, step) {
export function setJobStep(job, step) { export function setJobStep(job, step) {
job.currentStep = step; job.currentStep = step;
job.progress = {
...job.progress,
detail: step,
};
appendJobLog(job, `==> ${step}`); appendJobLog(job, `==> ${step}`);
} }
export function setJobProgress(job, progress = {}) {
const nextPercent = progress.percent ?? job.progress?.percent ?? 0;
job.progress = {
...job.progress,
...progress,
percent: Math.max(0, Math.min(100, Number(nextPercent) || 0)),
};
}
export function appendJobLog(job, line) { export function appendJobLog(job, line) {
if (!line) return; if (!line) return;
job.logs.push(...String(line).split('\n').filter(Boolean)); job.logs.push(...String(line).split('\n').filter(Boolean));
@@ -71,6 +90,11 @@ export function finishJob(job, status, error = null) {
job.status = status; job.status = status;
job.finishedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString();
job.currentStep = status === 'success' ? 'Completed' : 'Failed'; job.currentStep = status === 'success' ? 'Completed' : 'Failed';
job.progress = {
...job.progress,
percent: status === 'success' ? 100 : job.progress?.percent || 0,
detail: status === 'success' ? 'Completed' : 'Failed',
};
job.error = error ? String(error.message || error) : null; job.error = error ? String(error.message || error) : null;
locks.delete(job.vmName); locks.delete(job.vmName);
} }
+46 -7
View File
@@ -2,8 +2,8 @@ import { Router } from 'express';
import { finished } from 'node:stream/promises'; import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
import { config } from '../config.js'; import { config } from '../config.js';
import { spawnCommand, runRestic } from '../executor.js'; import { createProgressStream, spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobRunning, setJobStep } from '../jobs.js'; import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateVmExists } from '../validators.js'; import { validateVmExists } from '../validators.js';
export const backupRouter = Router(); export const backupRouter = Router();
@@ -11,37 +11,55 @@ export const backupRouter = Router();
backupRouter.post('/:vmName', async (req, res, next) => { backupRouter.post('/:vmName', async (req, res, next) => {
try { try {
const vmName = req.params.vmName; const vmName = req.params.vmName;
await validateVmExists(vmName); const job = await startBackupForVm(vmName);
const job = createJob('backup', vmName);
runBackupJob(job).catch(() => {});
res.status(202).json({ jobId: job.id, message: 'Backup job started.' }); res.status(202).json({ jobId: job.id, message: 'Backup job started.' });
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
async function runBackupJob(job) { export async function startBackupForVm(vmName) {
await validateVmExists(vmName);
const job = createJob('backup', vmName);
runBackupJob(job).catch(() => {});
return job;
}
export async function runBackupJob(job) {
const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const snapshotName = `s3-backup-${timestamp}`; const snapshotName = `s3-backup-${timestamp}`;
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`; const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
const snapshotDevice = `/dev/zvol/${zvol}@snapshot-${snapshotName}`; const snapshotDevice = `/dev/zvol/${zvol}@snapshot-${snapshotName}`;
try { try {
const totalBytes = await zfsVolumeSize(zvol);
setJobRunning(job, 'Creating Incus snapshot'); setJobRunning(job, 'Creating Incus snapshot');
setJobProgress(job, { percent: 2 });
await spawnCommand('incus', ['snapshot', 'create', job.vmName, snapshotName], { log: (line) => appendJobLog(job, line) }); await spawnCommand('incus', ['snapshot', 'create', job.vmName, snapshotName], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Making ZFS snapshot device visible'); setJobStep(job, 'Making ZFS snapshot device visible');
setJobProgress(job, { percent: 5 });
await spawnCommand('zfs', ['set', 'snapdev=visible', zvol], { log: (line) => appendJobLog(job, line) }); await spawnCommand('zfs', ['set', 'snapdev=visible', zvol], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Waiting for snapshot device'); setJobStep(job, 'Waiting for snapshot device');
setJobProgress(job, { percent: 8 });
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Streaming block device to Restic'); setJobStep(job, 'Streaming block device to Restic');
const snapshotStream = await deviceReadStream(snapshotDevice); const snapshotStream = await deviceReadStream(snapshotDevice);
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
setJobProgress(job, {
currentBytes,
totalBytes: bytesTotal,
percent: 10 + percent * 0.78,
detail: `Streaming ${formatBytes(currentBytes)} of ${formatBytes(bytesTotal)}`,
});
});
try { try {
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName], { await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName], {
env: { ...process.env, ...config.resticEnv }, env: { ...process.env, ...config.resticEnv },
input: snapshotStream, input: snapshotStream.pipe(progressStream),
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
} finally { } finally {
@@ -52,13 +70,16 @@ async function runBackupJob(job) {
} }
setJobStep(job, 'Hiding ZFS snapshot device'); setJobStep(job, 'Hiding ZFS snapshot device');
setJobProgress(job, { percent: 90 });
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { log: (line) => appendJobLog(job, line) }); await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { log: (line) => appendJobLog(job, line) });
await settleUdev(job); await settleUdev(job);
setJobStep(job, 'Deleting temporary Incus snapshot'); setJobStep(job, 'Deleting temporary Incus snapshot');
setJobProgress(job, { percent: 93 });
await deleteIncusSnapshotWithRetry(job, snapshotName); await deleteIncusSnapshotWithRetry(job, snapshotName);
setJobStep(job, 'Applying Restic retention policy'); setJobStep(job, 'Applying Restic retention policy');
setJobProgress(job, { percent: 96 });
await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], { await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], {
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
@@ -76,6 +97,24 @@ async function deviceReadStream(path) {
return createReadStream(path); return createReadStream(path);
} }
async function zfsVolumeSize(zvol) {
const result = await spawnCommand('zfs', ['get', '-Hp', '-o', 'value', 'volsize', zvol]);
const size = Number(result.stdout.trim());
return Number.isFinite(size) && size > 0 ? size : null;
}
function formatBytes(value) {
if (!value) return '-';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let size = Number(value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
async function cleanupBackup(job, zvol, snapshotName) { async function cleanupBackup(job, zvol, snapshotName) {
setJobStep(job, 'Running cleanup'); setJobStep(job, 'Running cleanup');
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
+36 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { config } from '../config.js'; import { config } from '../config.js';
import { spawnCommand, streamResticDumpToDd } from '../executor.js'; import { spawnCommand, streamResticDumpToDd } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobRunning, setJobStep } from '../jobs.js'; import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateSnapshotForVm, validateVmExists } from '../validators.js'; import { validateSnapshotForVm, validateVmExists } from '../validators.js';
export const restoreRouter = Router(); export const restoreRouter = Router();
@@ -34,29 +34,45 @@ async function runRestoreJob(job, snapshotId) {
try { try {
setJobRunning(job, 'Stopping VM'); setJobRunning(job, 'Stopping VM');
setJobProgress(job, { percent: 3 });
await spawnCommand('incus', ['stop', job.vmName, '--force'], { await spawnCommand('incus', ['stop', job.vmName, '--force'], {
ignoreExitCode: true, ignoreExitCode: true,
log: (line) => appendJobLog(job, line), log: (line) => appendJobLog(job, line),
}); });
setJobStep(job, 'Setting ZFS volume to device mode'); setJobStep(job, 'Setting ZFS volume to device mode');
setJobProgress(job, { percent: 8 });
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) }); await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = true; volmodeDev = true;
setJobStep(job, 'Settling device nodes'); setJobStep(job, 'Settling device nodes');
setJobProgress(job, { percent: 12 });
await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) }); await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) });
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) }); await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Writing Restic snapshot to block device'); setJobStep(job, 'Writing Restic snapshot to block device');
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line)); const totalBytes = await zfsVolumeSize(zvol);
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), {
totalBytes,
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
setJobProgress(job, {
currentBytes,
totalBytes: bytesTotal,
percent: 15 + percent * 0.78,
detail: `Writing ${formatBytes(currentBytes)} of ${formatBytes(bytesTotal)}`,
});
},
});
diskWriteOk = true; diskWriteOk = true;
setJobStep(job, 'Restoring ZFS volume mode'); setJobStep(job, 'Restoring ZFS volume mode');
setJobProgress(job, { percent: 94 });
await spawnCommand('zfs', ['set', 'volmode=none', zvol], { log: (line) => appendJobLog(job, line) }); await spawnCommand('zfs', ['set', 'volmode=none', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = false; volmodeDev = false;
setJobStep(job, 'Starting VM'); setJobStep(job, 'Starting VM');
setJobProgress(job, { percent: 97 });
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) }); await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
finishJob(job, 'success'); finishJob(job, 'success');
@@ -75,3 +91,21 @@ async function runRestoreJob(job, snapshotId) {
finishJob(job, 'failed', error); finishJob(job, 'failed', error);
} }
} }
async function zfsVolumeSize(zvol) {
const result = await spawnCommand('zfs', ['get', '-Hp', '-o', 'value', 'volsize', zvol]);
const size = Number(result.stdout.trim());
return Number.isFinite(size) && size > 0 ? size : null;
}
function formatBytes(value) {
if (!value) return '-';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let size = Number(value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { listSchedules, replaceSchedules } from '../scheduler.js';
export const schedulesRouter = Router();
schedulesRouter.get('/', (_req, res) => {
res.json(listSchedules());
});
schedulesRouter.put('/', async (req, res, next) => {
try {
const schedules = await replaceSchedules(req.body?.schedules || []);
res.json(schedules);
} catch (error) {
next(error);
}
});
+1
View File
@@ -28,5 +28,6 @@ function summarizeJob(job) {
type: job.type, type: job.type,
status: job.status, status: job.status,
currentStep: job.currentStep, currentStep: job.currentStep,
progress: job.progress,
}; };
} }
+103
View File
@@ -0,0 +1,103 @@
import crypto from 'node:crypto';
import { readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { startBackupForVm } from './routes/backup.js';
const schedulesPath = path.resolve(process.cwd(), 'schedules.json');
const checkIntervalMs = 30 * 1000;
let schedules = [];
let timer = null;
export async function startScheduler() {
await loadSchedules();
if (timer) return;
timer = setInterval(runDueSchedules, checkIntervalMs);
timer.unref?.();
runDueSchedules();
}
export function listSchedules() {
return schedules
.map((schedule) => ({ ...schedule }))
.sort((a, b) => a.vmName.localeCompare(b.vmName));
}
export async function replaceSchedules(nextSchedules) {
schedules = normalizeSchedules(nextSchedules);
await saveSchedules();
return listSchedules();
}
async function runDueSchedules() {
const now = new Date();
let changed = false;
for (const schedule of schedules) {
if (!schedule.enabled) continue;
if (!schedule.nextRunAt) {
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours);
changed = true;
continue;
}
if (new Date(schedule.nextRunAt) > now) continue;
try {
await startBackupForVm(schedule.vmName);
schedule.lastRunAt = now.toISOString();
schedule.lastError = '';
schedule.nextRunAt = nextRunFrom(now, schedule.intervalHours);
} catch (error) {
schedule.lastError = String(error.message || error);
schedule.nextRunAt = nextRunFrom(new Date(now.getTime() + 5 * 60 * 1000), schedule.intervalHours);
}
changed = true;
}
if (changed) {
await saveSchedules();
}
}
async function loadSchedules() {
try {
const content = await readFile(schedulesPath, 'utf8');
schedules = normalizeSchedules(JSON.parse(content));
} catch (error) {
if (error.code === 'ENOENT') {
schedules = [];
return;
}
throw error;
}
}
async function saveSchedules() {
const tempPath = `${schedulesPath}.tmp`;
await writeFile(tempPath, `${JSON.stringify(schedules, null, 2)}\n`, { mode: 0o600 });
await rename(tempPath, schedulesPath);
}
function normalizeSchedules(values) {
const seen = new Set();
return (Array.isArray(values) ? values : [])
.map((value) => {
const vmName = String(value.vmName || '').trim();
const intervalHours = Math.max(1, Number(value.intervalHours) || 24);
if (!vmName || seen.has(vmName)) return null;
seen.add(vmName);
return {
id: value.id || `schedule_${crypto.randomBytes(8).toString('hex')}`,
vmName,
enabled: Boolean(value.enabled),
intervalHours,
nextRunAt: value.nextRunAt || nextRunFrom(new Date(), intervalHours),
lastRunAt: value.lastRunAt || null,
lastError: value.lastError || '',
};
})
.filter(Boolean);
}
function nextRunFrom(date, intervalHours) {
return new Date(date.getTime() + intervalHours * 60 * 60 * 1000).toISOString();
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Incus Backup UI</title> <title>Incus Backup UI</title>
<script type="module" crossorigin src="/assets/index-JZykJ0Bp.js"></script> <script type="module" crossorigin src="/assets/index-IGuLjsuH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dw_yq1Cq.css"> <link rel="stylesheet" crossorigin href="/assets/index-BhslfNoW.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+20 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, DatabaseBackup, RefreshCw, Settings as SettingsIcon } from 'lucide-react'; import { AlertTriangle, CalendarClock, DatabaseBackup, RefreshCw, Settings as SettingsIcon } 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 { 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';
@@ -9,6 +10,7 @@ export default function App() {
const [health, setHealth] = useState(null); const [health, setHealth] = useState(null);
const [vms, setVms] = useState([]); const [vms, setVms] = useState([]);
const [jobs, setJobs] = useState([]); const [jobs, setJobs] = useState([]);
const [schedules, setSchedules] = useState([]);
const [selectedVm, setSelectedVm] = useState(null); const [selectedVm, setSelectedVm] = useState(null);
const [view, setView] = useState('dashboard'); const [view, setView] = useState('dashboard');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -17,10 +19,11 @@ export default function App() {
async function refresh() { async function refresh() {
setError(''); setError('');
try { try {
const [healthResult, vmsResult, jobsResult] = await Promise.allSettled([ const [healthResult, vmsResult, jobsResult, schedulesResult] = await Promise.allSettled([
api.get('/health'), api.get('/health'),
api.get('/vms'), api.get('/vms'),
api.get('/jobs'), api.get('/jobs'),
api.get('/schedules'),
]); ]);
if (healthResult.status === 'fulfilled') { if (healthResult.status === 'fulfilled') {
@@ -31,8 +34,9 @@ export default function App() {
if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data); if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data);
if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data); if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data);
if (schedulesResult.status === 'fulfilled') setSchedules(schedulesResult.value.data);
const firstFailure = [healthResult, vmsResult, jobsResult].find((result) => result.status === 'rejected'); const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult].find((result) => result.status === 'rejected');
if (firstFailure) setError(errorMessage(firstFailure.reason)); if (firstFailure) setError(errorMessage(firstFailure.reason));
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
@@ -73,6 +77,17 @@ export default function App() {
</span> </span>
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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('scheduler');
}}
type="button"
>
<CalendarClock className="h-4 w-4" />
Scheduler
</button>
<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" 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={() => { onClick={() => {
@@ -106,6 +121,8 @@ export default function App() {
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'settings' ? ( {view === 'settings' ? (
<Settings onChanged={refresh} /> <Settings onChanged={refresh} />
) : view === 'scheduler' ? (
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
) : currentVm ? ( ) : currentVm ? (
<VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} /> <VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} />
) : ( ) : (
+13 -1
View File
@@ -43,7 +43,19 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) {
</span> </span>
</td> </td>
<td className="px-4 py-3 text-zinc-300">{vm.lastJobStatus || 'None'}</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?.currentStep || 'Idle'}</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"> <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="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"
+17 -3
View File
@@ -1,4 +1,6 @@
export function JobStatusPanel({ job }) { export function JobStatusPanel({ job }) {
const percent = Math.round(job?.progress?.percent || 0);
return ( return (
<section className="rounded-md border border-zinc-800 bg-zinc-900/70"> <section className="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"> <div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
@@ -11,13 +13,25 @@ export function JobStatusPanel({ job }) {
<dl className="space-y-3 text-sm"> <dl className="space-y-3 text-sm">
<Info label="Type" value={job?.type || '-'} /> <Info label="Type" value={job?.type || '-'} />
<Info label="Current Step" value={job?.currentStep || 'No active job'} /> <Info label="Current Step" value={job?.currentStep || 'No active job'} />
<Info label="Progress" value={job ? `${percent}%` : '-'} />
<Info label="Started" value={formatTime(job?.startedAt)} /> <Info label="Started" value={formatTime(job?.startedAt)} />
<Info label="Finished" value={formatTime(job?.finishedAt)} /> <Info label="Finished" value={formatTime(job?.finishedAt)} />
{job?.error ? <Info label="Error" value={job.error} tone="text-red-300" /> : null} {job?.error ? <Info label="Error" value={job.error} tone="text-red-300" /> : null}
</dl> </dl>
<pre className="max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs leading-5 text-zinc-300"> <div className="space-y-3">
{(job?.logs || ['No logs yet.']).join('\n')} <div className="rounded-md border border-zinc-800 bg-zinc-950 p-3">
</pre> <div className="mb-2 flex items-center justify-between gap-3 text-xs text-zinc-400">
<span>{job?.progress?.detail || job?.currentStep || 'No active job'}</span>
<span className="font-mono text-zinc-300">{job ? `${percent}%` : '-'}</span>
</div>
<div className="h-2 overflow-hidden rounded-md bg-zinc-800">
<div className="h-full bg-cyan-400 transition-all" style={{ width: `${job ? percent : 0}%` }} />
</div>
</div>
<pre className="max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs leading-5 text-zinc-300">
{(job?.logs || ['No logs yet.']).join('\n')}
</pre>
</div>
</div> </div>
</section> </section>
); );
+160
View File
@@ -0,0 +1,160 @@
import { Save } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../api.js';
export function Scheduler({ onChanged, schedules, vms }) {
const [drafts, setDrafts] = useState(() => mergeSchedules(vms, schedules));
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const rows = useMemo(() => mergeSchedules(vms, drafts), [drafts, vms]);
useEffect(() => {
if (!dirty) {
setDrafts(mergeSchedules(vms, schedules));
}
}, [dirty, schedules, vms]);
async function saveSchedules(event) {
event.preventDefault();
setSaving(true);
setMessage('');
setError('');
try {
const payload = rows
.filter((row) => row.enabled)
.map((row) => ({
id: row.id,
vmName: row.vmName,
enabled: row.enabled,
intervalHours: Number(row.intervalHours) || 24,
nextRunAt: row.nextRunAt,
lastRunAt: row.lastRunAt,
lastError: row.lastError,
}));
const result = await api.put('/schedules', { schedules: payload });
setDrafts(mergeSchedules(vms, result.data));
setDirty(false);
setMessage('Scheduler saved.');
await onChanged?.();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}
function updateRow(vmName, values) {
setDirty(true);
setDrafts((current) => mergeSchedules(vms, current).map((row) => (
row.vmName === vmName ? { ...row, ...values } : row
)));
}
return (
<section className="space-y-5">
<div className="border-b border-zinc-800 pb-5">
<h1 className="text-2xl font-semibold text-zinc-100">Scheduler</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-500">
Configure automatic backups per VM. Schedules are stored on the backend and run while the backend service is active.
</p>
</div>
{error ? <Notice tone="bad" text={error} /> : null}
{message ? <Notice tone="good" text={message} /> : null}
<form className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={saveSchedules}>
<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 text-zinc-500">
<tr>
<th className="px-4 py-3 font-medium">Enabled</th>
<th className="px-4 py-3 font-medium">VM</th>
<th className="px-4 py-3 font-medium">Interval</th>
<th className="px-4 py-3 font-medium">Next Run</th>
<th className="px-4 py-3 font-medium">Last Run</th>
<th className="px-4 py-3 font-medium">Last Error</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{rows.map((row) => (
<tr key={row.vmName}>
<td className="px-4 py-3">
<input
checked={row.enabled}
className="h-4 w-4 accent-cyan-500"
onChange={(event) => updateRow(row.vmName, { enabled: event.target.checked })}
type="checkbox"
/>
</td>
<td className="px-4 py-3 font-medium text-zinc-100">{row.vmName}</td>
<td className="px-4 py-3">
<select
className="h-9 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100 outline-none"
onChange={(event) => updateRow(row.vmName, { intervalHours: Number(event.target.value), nextRunAt: null })}
value={row.intervalHours}
>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Daily</option>
<option value={168}>Weekly</option>
</select>
</td>
<td className="px-4 py-3 text-zinc-300">{formatTime(row.nextRunAt)}</td>
<td className="px-4 py-3 text-zinc-300">{formatTime(row.lastRunAt)}</td>
<td className="px-4 py-3 text-red-300">{row.lastError || '-'}</td>
</tr>
))}
{!rows.length ? (
<tr>
<td className="px-4 py-8 text-center text-zinc-500" colSpan="6">
No VMs loaded.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-zinc-800 px-4 py-3">
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-cyan-800 bg-cyan-950 px-3 text-sm text-cyan-100 hover:bg-cyan-900"
disabled={saving}
type="submit"
>
<Save className="h-4 w-4" />
Save Scheduler
</button>
</div>
</form>
</section>
);
}
function mergeSchedules(vms, schedules) {
const byVm = new Map((schedules || []).map((schedule) => [schedule.vmName, schedule]));
return (vms || []).map((vm) => ({
id: byVm.get(vm.name)?.id,
vmName: vm.name,
enabled: Boolean(byVm.get(vm.name)?.enabled),
intervalHours: byVm.get(vm.name)?.intervalHours || 24,
nextRunAt: byVm.get(vm.name)?.nextRunAt || null,
lastRunAt: byVm.get(vm.name)?.lastRunAt || null,
lastError: byVm.get(vm.name)?.lastError || '',
}));
}
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>;
}
function formatTime(value) {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value));
}