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
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"
+17 -3
View File
@@ -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,13 +13,25 @@ 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>
<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 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));
}