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
+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));
}