158 lines
5.8 KiB
React
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));
|
|
}
|