255 lines
9.7 KiB
React
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>
|
|
);
|
|
}
|