Files
incus-backup-ui/frontend/src/App.jsx
T

255 lines
9.7 KiB
React

import { useEffect, useMemo, useState } from 'react';
import {
AlertTriangle,
CalendarClock,
ClipboardList,
Database,
DatabaseBackup,
LogOut,
Moon,
Network,
RefreshCw,
Settings as SettingsIcon,
Shield,
} from 'lucide-react';
import { api, errorMessage } from './api.js';
import { Dashboard } from './components/Dashboard.jsx';
import { Login } from './components/Login.jsx';
import { Nodes } from './components/Nodes.jsx';
import { Operations } from './components/Operations.jsx';
import { Repositories } from './components/Repositories.jsx';
import { Scheduler } from './components/Scheduler.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 [schedules, setSchedules] = useState([]);
const [nodes, setNodes] = useState([]);
const [selectedVm, setSelectedVm] = useState(null);
const [session, setSession] = useState(null);
const [sessionLoading, setSessionLoading] = useState(true);
const [view, setView] = useState('dashboard');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
async function refresh() {
try {
const [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult] = await Promise.allSettled([
api.get('/health'),
api.get('/vms'),
api.get('/jobs'),
api.get('/schedules'),
api.get('/nodes'),
]);
if (healthResult.status === 'fulfilled') {
setStableState(setHealth, healthResult.value.data);
} else {
setStableState(setHealth, healthResult.reason.response?.data || { ok: false, checks: {} });
}
if (vmsResult.status === 'fulfilled') setStableState(setVms, vmsResult.value.data);
if (jobsResult.status === 'fulfilled') setStableState(setJobs, jobsResult.value.data);
if (schedulesResult.status === 'fulfilled') setStableState(setSchedules, schedulesResult.value.data);
if (nodesResult.status === 'fulfilled') setStableState(setNodes, nodesResult.value.data);
const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected');
setStableState(setError, firstFailure ? errorMessage(firstFailure.reason) : '');
} catch (requestError) {
setStableState(setError, errorMessage(requestError));
} finally {
setLoading(false);
}
}
useEffect(() => {
loadSession();
}, []);
useEffect(() => {
if (!session?.authenticated) return undefined;
refresh();
const timer = window.setInterval(refresh, 5000);
return () => window.clearInterval(timer);
}, [session?.authenticated]);
const currentVm = useMemo(
() => vms.find((vm) => vm.id === selectedVm) || null,
[selectedVm, vms],
);
async function loadSession() {
try {
const result = await api.get('/auth/session');
setSession(result.data);
} catch {
setSession({ authenticated: false, user: null });
} finally {
setSessionLoading(false);
}
}
async function logout() {
await api.post('/auth/logout');
setSession({ authenticated: false, user: null });
setVms([]);
setJobs([]);
setSchedules([]);
setNodes([]);
}
if (sessionLoading) {
return <main className="min-h-screen bg-zinc-950 text-zinc-100" />;
}
if (!session?.authenticated) {
return <Login onLogin={loadSession} />;
}
const pageTitle = currentVm
? currentVm.name
: {
dashboard: 'Backups',
nodes: 'Nodes',
repositories: 'Repositories',
operations: 'Operations',
scheduler: 'Scheduler',
settings: 'Settings',
}[view] || 'Backups';
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="grid min-h-screen lg:grid-cols-[260px_1fr]">
<aside className="flex flex-col border-r border-zinc-800 bg-zinc-900/80">
<button
className="flex h-20 w-full items-center gap-3 border-b border-zinc-800 px-6 text-left"
onClick={() => {
setSelectedVm(null);
setView('dashboard');
}}
type="button"
>
<span className="flex h-9 w-9 items-center justify-center rounded-md text-red-400">
<DatabaseBackup className="h-6 w-6" />
</span>
<span>
<span className="block text-base font-semibold tracking-wide text-zinc-100">Incus Backup</span>
<span className="block text-xs text-zinc-500">Management UI</span>
</span>
</button>
<nav className="space-y-1 px-4 py-6">
<NavButton active={view === 'nodes' && !currentVm} icon={Network} label="Nodes" onClick={() => switchView('nodes')} />
<NavButton active={view === 'repositories' && !currentVm} icon={Database} label="Repositories" onClick={() => switchView('repositories')} />
<NavButton active={view === 'dashboard' || Boolean(currentVm)} icon={CalendarClock} label="Backups" onClick={() => switchView('dashboard')} />
<NavButton active={view === 'operations' && !currentVm} icon={ClipboardList} label="Operations" onClick={() => switchView('operations')} />
<NavButton active={view === 'scheduler' && !currentVm} icon={Shield} label="Scheduler" onClick={() => switchView('scheduler')} />
<NavButton active={view === 'settings' && !currentVm} icon={SettingsIcon} label="Settings" onClick={() => switchView('settings')} />
</nav>
<div className="mt-auto hidden border-t border-zinc-800 px-6 py-4 text-xs text-zinc-500 lg:block">
<div>v0.1.0</div>
<div className="mt-2 text-red-400">Management</div>
</div>
</aside>
<section className="min-w-0">
<header className="flex h-20 items-center justify-between border-b border-zinc-800 bg-zinc-900/80 px-6">
<div className="flex items-center gap-3">
<button className="hidden h-9 w-9 items-center justify-center rounded-md border border-zinc-800 text-zinc-400 lg:inline-flex" type="button">
<DatabaseBackup className="h-4 w-4" />
</button>
<h1 className="text-lg font-semibold tracking-wide text-zinc-100">{pageTitle}</h1>
</div>
<div className="flex items-center gap-2 rounded-full border border-zinc-800 bg-zinc-950 px-3 py-2">
<span className="px-2 text-sm font-medium text-zinc-200">{session.user?.username || 'admin'}</span>
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" onClick={refresh} title="Refresh" type="button">
<RefreshCw className="h-4 w-4" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" title="Theme" type="button">
<Moon className="h-4 w-4" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-100" onClick={logout} title="Logout" type="button">
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
<div className="app-grid-bg min-h-[calc(100vh-5rem)] px-5 py-7 sm:px-8">
{error ? (
<div className="mb-5 flex items-center gap-2 text-sm text-amber-300">
<AlertTriangle className="h-4 w-4" />
{error}
</div>
) : null}
<div className="mx-auto max-w-[1500px]">
{view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} />
) : view === 'repositories' ? (
<Repositories nodes={nodes} onChanged={refresh} />
) : view === 'operations' ? (
<Operations />
) : view === 'settings' ? (
<Settings nodes={nodes} onChanged={refresh} />
) : view === 'scheduler' ? (
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
) : currentVm ? (
<VMDetail vm={currentVm} jobs={jobs} onBack={() => setSelectedVm(null)} onChanged={refresh} />
) : (
<Dashboard
health={health}
jobs={jobs}
loading={loading}
onCreateSchedule={() => switchView('scheduler')}
onManage={(vmName) => {
setSelectedVm(vmName);
setView('dashboard');
}}
schedules={schedules}
vms={vms}
/>
)}
</div>
</div>
</section>
</div>
</main>
);
function switchView(nextView) {
setSelectedVm(null);
setView(nextView);
}
}
function setStableState(setter, nextValue) {
setter((currentValue) => (
stableStringify(currentValue) === stableStringify(nextValue) ? currentValue : nextValue
));
}
function stableStringify(value) {
return JSON.stringify(value);
}
function NavButton({ active, icon: Icon, label, onClick }) {
return (
<button
className={`flex h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm font-semibold transition ${
active
? 'border-l-4 border-red-500 bg-red-950/40 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-100'
}`}
onClick={onClick}
type="button"
>
<Icon className={`h-4 w-4 ${active ? 'text-red-400' : 'text-zinc-500'}`} />
{label}
</button>
);
}