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 { PassThrough } from 'node:stream';
import { PassThrough, Transform } from 'node:stream';
import { resticProcessEnv } from './config.js';
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) => {
const restic = spawn('restic', ['dump', snapshotId, filename], {
env: resticProcessEnv(),
@@ -91,7 +106,11 @@ export function streamResticDumpToDd(snapshotId, filename, outputPath, log) {
let resticClosed = false;
let ddClosed = false;
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()));
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 { jobsRouter } from './routes/jobs.js';
import { restoreRouter } from './routes/restore.js';
import { schedulesRouter } from './routes/schedules.js';
import { settingsRouter } from './routes/settings.js';
import { snapshotsRouter } from './routes/snapshots.js';
import { vmsRouter } from './routes/vms.js';
import { startScheduler } from './scheduler.js';
const app = express();
@@ -33,6 +35,7 @@ app.use('/api/snapshots', snapshotsRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/backup', backupRouter);
app.use('/api/restore', restoreRouter);
app.use('/api/schedules', schedulesRouter);
app.use('/api/settings', settingsRouter);
app.use((error, _req, res, _next) => {
@@ -43,3 +46,7 @@ app.use((error, _req, res, _next) => {
app.listen(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,
finishedAt: null,
currentStep: 'Queued',
progress: {
percent: 0,
detail: 'Queued',
currentBytes: 0,
totalBytes: null,
},
logs: [],
error: null,
};
@@ -56,9 +62,22 @@ export function setJobRunning(job, step) {
export function setJobStep(job, step) {
job.currentStep = step;
job.progress = {
...job.progress,
detail: 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) {
if (!line) return;
job.logs.push(...String(line).split('\n').filter(Boolean));
@@ -71,6 +90,11 @@ export function finishJob(job, status, error = null) {
job.status = status;
job.finishedAt = new Date().toISOString();
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;
locks.delete(job.vmName);
}
+46 -7
View File
@@ -2,8 +2,8 @@ import { Router } from 'express';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { config } from '../config.js';
import { spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobRunning, setJobStep } from '../jobs.js';
import { createProgressStream, spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateVmExists } from '../validators.js';
export const backupRouter = Router();
@@ -11,37 +11,55 @@ export const backupRouter = Router();
backupRouter.post('/:vmName', async (req, res, next) => {
try {
const vmName = req.params.vmName;
await validateVmExists(vmName);
const job = createJob('backup', vmName);
runBackupJob(job).catch(() => {});
const job = await startBackupForVm(vmName);
res.status(202).json({ jobId: job.id, message: 'Backup job started.' });
} catch (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 snapshotName = `s3-backup-${timestamp}`;
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
const snapshotDevice = `/dev/zvol/${zvol}@snapshot-${snapshotName}`;
try {
const totalBytes = await zfsVolumeSize(zvol);
setJobRunning(job, 'Creating Incus snapshot');
setJobProgress(job, { percent: 2 });
await spawnCommand('incus', ['snapshot', 'create', job.vmName, snapshotName], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Making ZFS snapshot device visible');
setJobProgress(job, { percent: 5 });
await spawnCommand('zfs', ['set', 'snapdev=visible', zvol], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Waiting for snapshot device');
setJobProgress(job, { percent: 8 });
await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Streaming block device to Restic');
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 {
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName], {
env: { ...process.env, ...config.resticEnv },
input: snapshotStream,
input: snapshotStream.pipe(progressStream),
log: (line) => appendJobLog(job, line),
});
} finally {
@@ -52,13 +70,16 @@ async function runBackupJob(job) {
}
setJobStep(job, 'Hiding ZFS snapshot device');
setJobProgress(job, { percent: 90 });
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { log: (line) => appendJobLog(job, line) });
await settleUdev(job);
setJobStep(job, 'Deleting temporary Incus snapshot');
setJobProgress(job, { percent: 93 });
await deleteIncusSnapshotWithRetry(job, snapshotName);
setJobStep(job, 'Applying Restic retention policy');
setJobProgress(job, { percent: 96 });
await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], {
log: (line) => appendJobLog(job, line),
});
@@ -76,6 +97,24 @@ async function deviceReadStream(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) {
setJobStep(job, 'Running cleanup');
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
+36 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { config } from '../config.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';
export const restoreRouter = Router();
@@ -34,29 +34,45 @@ async function runRestoreJob(job, snapshotId) {
try {
setJobRunning(job, 'Stopping VM');
setJobProgress(job, { percent: 3 });
await spawnCommand('incus', ['stop', job.vmName, '--force'], {
ignoreExitCode: true,
log: (line) => appendJobLog(job, line),
});
setJobStep(job, 'Setting ZFS volume to device mode');
setJobProgress(job, { percent: 8 });
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = true;
setJobStep(job, 'Settling device nodes');
setJobProgress(job, { percent: 12 });
await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) });
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
await new Promise((resolve) => setTimeout(resolve, 2000));
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;
setJobStep(job, 'Restoring ZFS volume mode');
setJobProgress(job, { percent: 94 });
await spawnCommand('zfs', ['set', 'volmode=none', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = false;
setJobStep(job, 'Starting VM');
setJobProgress(job, { percent: 97 });
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
finishJob(job, 'success');
@@ -75,3 +91,21 @@ async function runRestoreJob(job, snapshotId) {
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,
status: job.status,
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Incus Backup UI</title>
<script type="module" crossorigin src="/assets/index-JZykJ0Bp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dw_yq1Cq.css">
<script type="module" crossorigin src="/assets/index-IGuLjsuH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhslfNoW.css">
</head>
<body>
<div id="root"></div>
+20 -3
View File
@@ -1,7 +1,8 @@
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 { Dashboard } from './components/Dashboard.jsx';
import { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx';
@@ -9,6 +10,7 @@ export default function App() {
const [health, setHealth] = useState(null);
const [vms, setVms] = useState([]);
const [jobs, setJobs] = useState([]);
const [schedules, setSchedules] = useState([]);
const [selectedVm, setSelectedVm] = useState(null);
const [view, setView] = useState('dashboard');
const [error, setError] = useState('');
@@ -17,10 +19,11 @@ export default function App() {
async function refresh() {
setError('');
try {
const [healthResult, vmsResult, jobsResult] = await Promise.allSettled([
const [healthResult, vmsResult, jobsResult, schedulesResult] = await Promise.allSettled([
api.get('/health'),
api.get('/vms'),
api.get('/jobs'),
api.get('/schedules'),
]);
if (healthResult.status === 'fulfilled') {
@@ -31,8 +34,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);
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));
} catch (requestError) {
setError(errorMessage(requestError));
@@ -73,6 +77,17 @@ export default function App() {
</span>
</button>
<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
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={() => {
@@ -106,6 +121,8 @@ export default function App() {
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'settings' ? (
<Settings onChanged={refresh} />
) : view === 'scheduler' ? (
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
) : currentVm ? (
<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>
</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">
<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"
@@ -1,4 +1,6 @@
export function JobStatusPanel({ job }) {
const percent = Math.round(job?.progress?.percent || 0);
return (
<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">
@@ -11,14 +13,26 @@ export function JobStatusPanel({ job }) {
<dl className="space-y-3 text-sm">
<Info label="Type" value={job?.type || '-'} />
<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="Finished" value={formatTime(job?.finishedAt)} />
{job?.error ? <Info label="Error" value={job.error} tone="text-red-300" /> : null}
</dl>
<div className="space-y-3">
<div className="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<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>
</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));
}