added progress bar and scheduler
This commit is contained in:
+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 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
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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