added to remote repo

This commit is contained in:
Philipp
2026-05-21 08:18:29 +02:00
parent 8697c9f405
commit cc599b6e18
8049 changed files with 1096323 additions and 0 deletions
BIN
View File
Binary file not shown.
+126
View File
@@ -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>
);
}
+26
View File
@@ -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.';
}
+85
View File
@@ -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));
}
+49
View File
@@ -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>
);
}
+124
View File
@@ -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>;
}
+54
View File
@@ -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));
}
+16
View File
@@ -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>
);
}
+117
View File
@@ -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));
}
+10
View File
@@ -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>,
);
+27
View File
@@ -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;
}