Files
incus-backup-ui/frontend/src/components/VMDetail.jsx
T

158 lines
5.8 KiB
React

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 { SnapshotFilesPanel } from './SnapshotFilesPanel.jsx';
import { SnapshotTable } from './SnapshotTable.jsx';
export function VMDetail({ jobs, onBack, onChanged, vm }) {
const [snapshots, setSnapshots] = useState([]);
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
const [inspectedSnapshot, setInspectedSnapshot] = useState(null);
const [snapshotFiles, setSnapshotFiles] = useState([]);
const [snapshotFilesLoading, setSnapshotFilesLoading] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const vmJobs = useMemo(() => jobs.filter((job) => job.vmName === vm.name && job.nodeId === vm.nodeId), [jobs, vm.name, vm.nodeId]);
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.nodeId)}/${encodeURIComponent(vm.name)}`);
setSnapshots(result.data);
} catch (requestError) {
setError(errorMessage(requestError));
}
}
useEffect(() => {
loadSnapshots();
}, [vm.name, vm.nodeId]);
async function createBackup() {
setBusy(true);
setError('');
try {
await api.post(`/backup/${encodeURIComponent(vm.nodeId)}/${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.nodeId)}/${encodeURIComponent(vm.name)}`, {
snapshotId: selectedSnapshot.id,
confirmVmName: confirmText,
});
setSelectedSnapshot(null);
setConfirmText('');
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
}
async function inspectSnapshot(snapshot) {
setInspectedSnapshot(snapshot);
setSnapshotFiles([]);
setSnapshotFilesLoading(true);
setError('');
try {
const result = await api.get(`/snapshots/${encodeURIComponent(vm.nodeId)}/${encodeURIComponent(vm.name)}/${encodeURIComponent(snapshot.id)}/files`);
setSnapshotFiles(result.data);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSnapshotFilesLoading(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.type === 'container' ? 'Container' : 'VM'}</span>
<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">Node: {vm.nodeName || '-'}</p>
<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}
onInspect={inspectSnapshot}
onRestore={(snapshot) => {
if (vm.type === 'container') {
setError('Container restore is not implemented yet. Backups are available, restore needs a safe zfs receive workflow.');
return;
}
setSelectedSnapshot(snapshot);
}}
/>
<SnapshotFilesPanel
files={snapshotFiles}
loading={snapshotFilesLoading}
onClose={() => {
setInspectedSnapshot(null);
setSnapshotFiles([]);
}}
snapshot={inspectedSnapshot}
/>
<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));
}