added progress bar and scheduler
This commit is contained in:
@@ -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
@@ -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()));
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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], {
|
||||||
|
|||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+148
File diff suppressed because one or more lines are too long
-143
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user