added to remote repo
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, DatabaseBackup, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { api, errorMessage } from './api.js';
|
||||
import { Dashboard } from './components/Dashboard.jsx';
|
||||
import { Settings } from './components/Settings.jsx';
|
||||
import { VMDetail } from './components/VMDetail.jsx';
|
||||
|
||||
export default function App() {
|
||||
const [health, setHealth] = useState(null);
|
||||
const [vms, setVms] = useState([]);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [selectedVm, setSelectedVm] = useState(null);
|
||||
const [view, setView] = useState('dashboard');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function refresh() {
|
||||
setError('');
|
||||
try {
|
||||
const [healthResult, vmsResult, jobsResult] = await Promise.allSettled([
|
||||
api.get('/health'),
|
||||
api.get('/vms'),
|
||||
api.get('/jobs'),
|
||||
]);
|
||||
|
||||
if (healthResult.status === 'fulfilled') {
|
||||
setHealth(healthResult.value.data);
|
||||
} else {
|
||||
setHealth(healthResult.reason.response?.data || { ok: false, checks: {} });
|
||||
}
|
||||
|
||||
if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data);
|
||||
if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data);
|
||||
|
||||
const firstFailure = [healthResult, vmsResult, jobsResult].find((result) => result.status === 'rejected');
|
||||
if (firstFailure) setError(errorMessage(firstFailure.reason));
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const currentVm = useMemo(
|
||||
() => vms.find((vm) => vm.name === selectedVm) || null,
|
||||
[selectedVm, vms],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="border-b border-zinc-800 bg-zinc-950/95">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<button
|
||||
className="flex items-center gap-3 text-left"
|
||||
onClick={() => {
|
||||
setSelectedVm(null);
|
||||
setView('dashboard');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900">
|
||||
<DatabaseBackup className="h-5 w-5 text-cyan-300" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold tracking-wide text-zinc-100">Incus Backup Control</span>
|
||||
<span className="block text-xs text-zinc-500">ZFS block-level Restic operations</span>
|
||||
</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('settings');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
Settings
|
||||
</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={refresh}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="mx-auto mt-4 flex max-w-7xl items-center gap-2 px-4 text-sm text-amber-300 sm:px-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
|
||||
{view === 'settings' ? (
|
||||
<Settings onChanged={refresh} />
|
||||
) : currentVm ? (
|
||||
<VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} />
|
||||
) : (
|
||||
<Dashboard
|
||||
health={health}
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
onManage={(vmName) => {
|
||||
setSelectedVm(vmName);
|
||||
setView('dashboard');
|
||||
}}
|
||||
vms={vms}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const token = window.localStorage.getItem('incusBackupApiToken') || import.meta.env.VITE_API_TOKEN;
|
||||
if (token) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export function setApiToken(tokenValue) {
|
||||
const token = String(tokenValue || '');
|
||||
if (token) {
|
||||
window.localStorage.setItem('incusBackupApiToken', token);
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem('incusBackupApiToken');
|
||||
delete api.defaults.headers.common.Authorization;
|
||||
}
|
||||
|
||||
export function errorMessage(error) {
|
||||
return error.response?.data?.error || error.message || 'Request failed.';
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ChevronRight, Circle } from 'lucide-react';
|
||||
import { StatusCard } from './StatusCard.jsx';
|
||||
|
||||
export function Dashboard({ health, jobs, loading, onManage, vms }) {
|
||||
const activeJobs = jobs.filter((job) => ['queued', 'running'].includes(job.status));
|
||||
const runningVms = vms.filter((vm) => vm.status === 'Running');
|
||||
const lastSuccess = jobs.find((job) => job.status === 'success');
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatusCard label="Total VMs" value={loading ? '...' : vms.length} />
|
||||
<StatusCard label="Running VMs" value={runningVms.length} tone="good" />
|
||||
<StatusCard label="Active Jobs" value={activeJobs.length} tone={activeJobs.length ? 'active' : 'neutral'} />
|
||||
<StatusCard label="Last Success" value={lastSuccess ? formatTime(lastSuccess.finishedAt) : 'None'} tone="good" />
|
||||
<StatusCard label="Repository" value={health?.ok ? 'Ready' : 'Check'} tone={health?.ok ? 'good' : 'warn'} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden 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">
|
||||
<h1 className="text-sm font-semibold text-zinc-100">VM Backup Status</h1>
|
||||
<span className="text-xs text-zinc-500">{healthSummary(health)}</span>
|
||||
</div>
|
||||
<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 tracking-wide text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">VM</th>
|
||||
<th className="px-4 py-3 font-medium">Incus</th>
|
||||
<th className="px-4 py-3 font-medium">Last Job</th>
|
||||
<th className="px-4 py-3 font-medium">Active Job</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{vms.map((vm) => (
|
||||
<tr className="hover:bg-zinc-900" key={vm.name}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{vm.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-2 text-zinc-300">
|
||||
<Circle className={`h-2.5 w-2.5 fill-current ${vm.status === 'Running' ? 'text-emerald-400' : 'text-red-400'}`} />
|
||||
{vm.status || 'Unknown'}
|
||||
</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-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"
|
||||
onClick={() => onManage(vm.name)}
|
||||
type="button"
|
||||
>
|
||||
Manage <ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!vms.length ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-zinc-500" colSpan="5">
|
||||
No VMs loaded.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return 'None';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value));
|
||||
}
|
||||
|
||||
function healthSummary(health) {
|
||||
if (!health) return 'Health loading';
|
||||
if (health.ok) return 'All checks ok';
|
||||
return Object.entries(health.checks || {})
|
||||
.filter(([, value]) => value !== 'ok')
|
||||
.map(([key]) => key)
|
||||
.join(', ') || 'Health degraded';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export function JobStatusPanel({ job }) {
|
||||
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">
|
||||
<h2 className="text-sm font-semibold">Job Status</h2>
|
||||
<span className={`rounded-md border px-2 py-1 text-xs ${statusClass(job?.status)}`}>
|
||||
{job?.status || 'idle'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[280px_1fr]">
|
||||
<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="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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value, tone = 'text-zinc-300' }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-zinc-500">{label}</dt>
|
||||
<dd className={`mt-1 break-words ${tone}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-300';
|
||||
if (status === 'failed') return 'border-red-800 bg-red-950 text-red-300';
|
||||
if (status === 'running' || status === 'queued') return 'border-cyan-800 bg-cyan-950 text-cyan-300';
|
||||
return 'border-zinc-800 bg-zinc-950 text-zinc-400';
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
export function RestoreModal({ snapshot, vmName, confirmText, onCancel, onConfirm, onTextChange, busy }) {
|
||||
if (!snapshot) return null;
|
||||
const canConfirm = confirmText === vmName && !busy;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-lg rounded-md border border-red-900 bg-zinc-950 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-red-950 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-red-300">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-sm font-semibold">Destructive Restore</h2>
|
||||
</div>
|
||||
<button className="rounded-md p-1 text-zinc-400 hover:text-zinc-100" onClick={onCancel} type="button">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 p-4">
|
||||
<p className="text-sm leading-6 text-zinc-300">
|
||||
Warning: The VM will be stopped and the current disk will be irreversibly overwritten with
|
||||
snapshot <span className="font-mono text-red-200">{snapshot.id.slice(0, 8)}</span>.
|
||||
</p>
|
||||
<label className="block text-sm text-zinc-300">
|
||||
Type <span className="font-mono text-zinc-100">{vmName}</span> to confirm.
|
||||
<input
|
||||
className="mt-2 h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-zinc-100 outline-none focus:border-red-500"
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
value={confirmText}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="h-9 rounded-md border border-zinc-700 px-3 text-sm text-zinc-200" onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="h-9 rounded-md border border-red-700 bg-red-950 px-3 text-sm text-red-100 hover:bg-red-900 disabled:hover:bg-red-950"
|
||||
disabled={!canConfirm}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
Confirm Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Eye, EyeOff, Save } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage, setApiToken } from '../api.js';
|
||||
|
||||
export function Settings({ onChanged }) {
|
||||
const [fields, setFields] = useState([]);
|
||||
const [values, setValues] = useState({});
|
||||
const [visibleSecrets, setVisibleSecrets] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const requiredMissing = useMemo(
|
||||
() => fields.filter((field) => field.required && !String(values[field.key] || '').trim()),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
async function loadSettings() {
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.get('/settings');
|
||||
setFields(result.data.fields || []);
|
||||
setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])));
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
try {
|
||||
setApiToken(values.API_TOKEN);
|
||||
const result = await api.put('/settings', { values });
|
||||
setFields(result.data.fields || []);
|
||||
setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])));
|
||||
setMessage('Settings saved. Running jobs use the updated values; a changed PORT still needs a backend restart.');
|
||||
await onChanged?.();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="border-b border-zinc-800 pb-5">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Settings</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-500">
|
||||
Values are written to <span className="font-mono text-zinc-400">backend/.env</span>. Keep access to this UI restricted because secrets are editable here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? <Notice tone="bad" text={error} /> : null}
|
||||
{message ? <Notice tone="good" text={message} /> : null}
|
||||
{requiredMissing.length ? (
|
||||
<Notice tone="warn" text={`Missing required values: ${requiredMissing.map((field) => field.key).join(', ')}`} />
|
||||
) : null}
|
||||
|
||||
<form className="rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={saveSettings}>
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
const secretVisible = visibleSecrets[field.key];
|
||||
return (
|
||||
<label className="block text-sm text-zinc-300" key={field.key}>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{field.label}
|
||||
{field.required ? <span className="text-red-300"> *</span> : null}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500">{field.key}</span>
|
||||
</span>
|
||||
<span className="mt-2 flex rounded-md border border-zinc-700 bg-zinc-950 focus-within:border-cyan-600">
|
||||
<input
|
||||
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
||||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||||
type={field.secret && !secretVisible ? 'password' : 'text'}
|
||||
value={values[field.key] || ''}
|
||||
/>
|
||||
{field.secret ? (
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center border-l border-zinc-800 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setVisibleSecrets((current) => ({ ...current, [field.key]: !current[field.key] }))}
|
||||
type="button"
|
||||
>
|
||||
{secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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 Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
warn: 'border-amber-900 bg-amber-950/50 text-amber-200',
|
||||
};
|
||||
|
||||
return <div className={`rounded-md border px-4 py-3 text-sm ${classes[tone]}`}>{text}</div>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function SnapshotTable({ onRestore, snapshots }) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
|
||||
<div className="border-b border-zinc-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Snapshots</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<thead className="border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Date & Time</th>
|
||||
<th className="px-4 py-3 font-medium">Tags</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr className="hover:bg-zinc-900" key={snapshot.id}>
|
||||
<td className="px-4 py-3 font-mono text-zinc-100">{snapshot.id.slice(0, 8)}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{formatTime(snapshot.time)}</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{(snapshot.tags || []).join(', ') || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
className="inline-flex h-8 items-center gap-2 rounded-md border border-red-900 px-2.5 text-xs text-red-300 hover:border-red-600 hover:text-red-200"
|
||||
onClick={() => onRestore(snapshot)}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Restore
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!snapshots.length ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-zinc-500" colSpan="4">
|
||||
No snapshots found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function StatusCard({ label, value, tone = 'neutral' }) {
|
||||
const tones = {
|
||||
neutral: 'text-zinc-100',
|
||||
good: 'text-emerald-300',
|
||||
warn: 'text-amber-300',
|
||||
bad: 'text-red-300',
|
||||
active: 'text-cyan-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/70 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">{label}</div>
|
||||
<div className={`mt-2 text-2xl font-semibold ${tones[tone]}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ArrowLeft, Play, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../api.js';
|
||||
import { JobStatusPanel } from './JobStatusPanel.jsx';
|
||||
import { RestoreModal } from './RestoreModal.jsx';
|
||||
import { SnapshotTable } from './SnapshotTable.jsx';
|
||||
|
||||
export function VMDetail({ jobs, onBack, onChanged, vm }) {
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const vmJobs = useMemo(() => jobs.filter((job) => job.vmName === vm.name), [jobs, vm.name]);
|
||||
const visibleJob = vmJobs.find((job) => ['queued', 'running'].includes(job.status)) || vmJobs[0] || null;
|
||||
const latestSnapshot = snapshots[0]?.time;
|
||||
|
||||
async function loadSnapshots() {
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.get(`/snapshots/${encodeURIComponent(vm.name)}`);
|
||||
setSnapshots(result.data);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSnapshots();
|
||||
}, [vm.name]);
|
||||
|
||||
async function createBackup() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/backup/${encodeURIComponent(vm.name)}`);
|
||||
await onChanged();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/restore/${encodeURIComponent(vm.name)}`, {
|
||||
snapshotId: selectedSnapshot.id,
|
||||
confirmVmName: confirmText,
|
||||
});
|
||||
setSelectedSnapshot(null);
|
||||
setConfirmText('');
|
||||
await onChanged();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<button className="mb-4 inline-flex items-center gap-2 text-sm text-zinc-400 hover:text-zinc-100" onClick={onBack} type="button">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">{vm.name}</h1>
|
||||
<span className="rounded-md border border-zinc-800 px-2 py-1 text-xs text-zinc-300">{vm.status || 'Unknown'}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-zinc-500">Latest snapshot: {latestSnapshot ? formatTime(latestSnapshot) : 'None'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-700 px-3 text-sm text-zinc-100" onClick={loadSnapshots} type="button">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Snapshots
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-emerald-800 bg-emerald-950 px-3 text-sm text-emerald-100 hover:bg-emerald-900"
|
||||
disabled={busy || Boolean(visibleJob && ['queued', 'running'].includes(visibleJob.status))}
|
||||
onClick={createBackup}
|
||||
type="button"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Create Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="rounded-md border border-amber-900 bg-amber-950/50 px-4 py-3 text-sm text-amber-200">{error}</div> : null}
|
||||
|
||||
<JobStatusPanel job={visibleJob} />
|
||||
<SnapshotTable snapshots={snapshots} onRestore={(snapshot) => setSelectedSnapshot(snapshot)} />
|
||||
<RestoreModal
|
||||
busy={busy}
|
||||
confirmText={confirmText}
|
||||
onCancel={() => {
|
||||
setSelectedSnapshot(null);
|
||||
setConfirmText('');
|
||||
}}
|
||||
onConfirm={confirmRestore}
|
||||
onTextChange={setConfirmText}
|
||||
snapshot={selectedSnapshot}
|
||||
vmName={vm.name}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
Reference in New Issue
Block a user