changed UI layout
new Navigation Dashboard
This commit is contained in:
+127
-100
@@ -1,10 +1,23 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CalendarClock, ClipboardList, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-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';
|
||||
@@ -97,122 +110,136 @@ export default function App() {
|
||||
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">
|
||||
<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">
|
||||
<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 items-center gap-3 text-left"
|
||||
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 border border-zinc-800 bg-zinc-900">
|
||||
<DatabaseBackup className="h-5 w-5 text-cyan-300" />
|
||||
<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-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 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>
|
||||
<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('nodes');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Nodes
|
||||
</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={() => {
|
||||
setSelectedVm(null);
|
||||
setView('scheduler');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Scheduler
|
||||
</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={() => {
|
||||
setSelectedVm(null);
|
||||
setView('operations');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Operations
|
||||
</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={() => {
|
||||
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>
|
||||
<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={logout}
|
||||
type="button"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
{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}
|
||||
<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="mx-auto max-w-7xl px-4 py-6 sm:px-6">
|
||||
{view === 'nodes' ? (
|
||||
<Nodes 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}
|
||||
onManage={(vmName) => {
|
||||
setSelectedVm(vmName);
|
||||
setView('dashboard');
|
||||
}}
|
||||
vms={vms}
|
||||
/>
|
||||
)}
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,96 +1,129 @@
|
||||
import { ChevronRight, Circle } from 'lucide-react';
|
||||
import { StatusCard } from './StatusCard.jsx';
|
||||
import { Box, Circle, Container, Plus, Server, Timer } from 'lucide-react';
|
||||
|
||||
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');
|
||||
export function Dashboard({ health, jobs, loading, onCreateSchedule, onManage, schedules, vms }) {
|
||||
const scheduleByVm = new Map((schedules || []).map((schedule) => [`${schedule.nodeId}:${schedule.vmName}`, schedule]));
|
||||
|
||||
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 className="grid gap-5 xl:grid-cols-3">
|
||||
{vms.map((vm) => {
|
||||
const activeJob = vm.activeJob || jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && ['queued', 'running'].includes(job.status));
|
||||
const lastJob = jobs.find((job) => job.nodeId === vm.nodeId && job.vmName === vm.name && job.status === 'success');
|
||||
const schedule = scheduleByVm.get(`${vm.nodeId}:${vm.name}`);
|
||||
return (
|
||||
<button
|
||||
className="group relative min-h-[178px] overflow-hidden border border-zinc-700 bg-zinc-950/80 p-7 text-left outline-none transition hover:border-zinc-500 focus:border-red-400"
|
||||
key={vm.id || `${vm.nodeId}:${vm.name}`}
|
||||
onClick={() => onManage(vm.id || vm.name)}
|
||||
type="button"
|
||||
>
|
||||
<CornerFrame />
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{vm.type === 'container' ? <Container className="h-5 w-5 text-zinc-400" /> : <Box className="h-5 w-5 text-zinc-400" />}
|
||||
<h2 className="truncate text-xl font-bold tracking-wide text-zinc-100">{vm.name}</h2>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="truncate">{vm.nodeName || 'Unknown node'}</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span>{vm.type === 'container' ? 'Container' : 'VM'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Circle className={`mt-1 h-4 w-4 shrink-0 fill-current ${vm.status === 'Running' ? 'text-emerald-400' : 'text-zinc-600'}`} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-10 space-y-3 text-sm">
|
||||
<Metric label="Schedule" value={scheduleLabel(schedule)} />
|
||||
<Metric label="Last backup" value={lastJob ? relativeTime(lastJob.finishedAt || lastJob.createdAt) : vm.lastJobStatus || 'None'} />
|
||||
<Metric label="Next backup" value={schedule?.enabled && schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '-'} />
|
||||
</dl>
|
||||
|
||||
{activeJob ? (
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-zinc-400">
|
||||
<span className="truncate">{activeJob.currentStep || activeJob.status}</span>
|
||||
<span className="font-mono">{Math.round(activeJob.progress?.percent || 0)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-sm bg-zinc-800">
|
||||
<div className="h-full bg-cyan-400" style={{ width: `${Math.round(activeJob.progress?.percent || 0)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
className="flex min-h-[178px] flex-col items-center justify-center gap-5 border border-dashed border-zinc-700 bg-zinc-950/50 text-zinc-100 transition hover:border-zinc-500"
|
||||
onClick={onCreateSchedule}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-900 text-zinc-200">
|
||||
<Plus className="h-7 w-7" />
|
||||
</span>
|
||||
<span className="font-semibold tracking-wide">Create a backup schedule</span>
|
||||
</button>
|
||||
</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">Node</th>
|
||||
<th className="px-4 py-3 font-medium">Type</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.id || vm.name}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{vm.name}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{vm.nodeName || '-'}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{vm.type === 'container' ? 'Container' : 'VM'}</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 ? (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate">{vm.activeJob.currentStep}</span>
|
||||
<span className="font-mono text-xs text-zinc-500">{Math.round(vm.activeJob.progress?.percent || 0)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-md bg-zinc-800">
|
||||
<div className="h-full bg-cyan-400" style={{ width: `${Math.round(vm.activeJob.progress?.percent || 0)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : '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.id || 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="7">
|
||||
No VMs loaded.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{!vms.length ? (
|
||||
<div className="border border-zinc-800 bg-zinc-950/80 px-5 py-10 text-center text-sm text-zinc-500">
|
||||
{loading ? 'Loading instances...' : 'No VMs or containers loaded.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<Timer className="h-4 w-4" />
|
||||
<span>{healthSummary(health)}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return 'None';
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-3 text-zinc-400">
|
||||
<dt className="font-medium">{label}</dt>
|
||||
<dd className="border-b border-dashed border-zinc-800" />
|
||||
<dd className="max-w-[13rem] truncate text-right font-semibold text-zinc-200">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CornerFrame() {
|
||||
return (
|
||||
<>
|
||||
<span className="pointer-events-none absolute left-0 top-0 h-5 w-5 border-l-2 border-t-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||
<span className="pointer-events-none absolute right-0 top-0 h-5 w-5 border-r-2 border-t-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||
<span className="pointer-events-none absolute bottom-0 left-0 h-5 w-5 border-b-2 border-l-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||
<span className="pointer-events-none absolute bottom-0 right-0 h-5 w-5 border-b-2 border-r-2 border-zinc-600 transition group-hover:border-zinc-400" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleLabel(schedule) {
|
||||
if (!schedule?.enabled) return '-';
|
||||
if (schedule.timeOfDay) return schedule.timeOfDay;
|
||||
return schedule.cron || 'enabled';
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value));
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
if (!value) return 'None';
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||||
if (diffMinutes < 1) return 'just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 48) return `${diffHours} hours ago`;
|
||||
return `${Math.round(diffHours / 24)} days ago`;
|
||||
}
|
||||
|
||||
function healthSummary(health) {
|
||||
if (!health) return 'Health loading';
|
||||
if (health.ok) return 'All checks ok';
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Cloud, Database, Eye, EyeOff, Save } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../api.js';
|
||||
|
||||
const repositoryKeys = [
|
||||
'RESTIC_REPOSITORY',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'RESTIC_PASSWORD',
|
||||
'RESTIC_KEEP_HOURLY',
|
||||
'RESTIC_KEEP_DAILY',
|
||||
'RESTIC_KEEP_WEEKLY',
|
||||
'RESTIC_KEEP_MONTHLY',
|
||||
];
|
||||
|
||||
export function Repositories({ nodes, onChanged }) {
|
||||
const [settingsByNode, setSettingsByNode] = useState({});
|
||||
const [selectedNodeId, setSelectedNodeId] = useState('');
|
||||
const [values, setValues] = useState({});
|
||||
const [visibleSecrets, setVisibleSecrets] = useState({});
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const rows = useMemo(() => nodes.map((node) => {
|
||||
const settings = settingsByNode[node.id];
|
||||
const repository = settings?.values.RESTIC_REPOSITORY || '';
|
||||
return {
|
||||
...node,
|
||||
backend: backendName(repository),
|
||||
repository,
|
||||
retention: retentionLabel(settings?.values || {}),
|
||||
settingsError: settings?.error,
|
||||
};
|
||||
}), [nodes, settingsByNode]);
|
||||
|
||||
const selectedSettings = settingsByNode[selectedNodeId];
|
||||
const editableFields = (selectedSettings?.fields || []).filter((field) => repositoryKeys.includes(field.key));
|
||||
const nodeSignature = nodes.map((node) => node.id).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
loadRepositorySettings();
|
||||
}, [nodeSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId && rows[0]?.id) setSelectedNodeId(rows[0].id);
|
||||
}, [rows, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSettings?.values) setValues(selectedSettings.values);
|
||||
}, [selectedSettings]);
|
||||
|
||||
async function loadRepositorySettings() {
|
||||
if (!nodes.length) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const results = await Promise.allSettled(nodes.map(async (node) => {
|
||||
const result = await api.get(`/settings/${encodeURIComponent(node.id)}`);
|
||||
return {
|
||||
nodeId: node.id,
|
||||
fields: result.data.fields || [],
|
||||
values: Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])),
|
||||
};
|
||||
}));
|
||||
|
||||
const next = {};
|
||||
results.forEach((result, index) => {
|
||||
const node = nodes[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
next[node.id] = result.value;
|
||||
} else {
|
||||
next[node.id] = { fields: [], values: {}, error: errorMessage(result.reason) };
|
||||
}
|
||||
});
|
||||
setSettingsByNode(next);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function saveRepository(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedNodeId) return;
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.put(`/settings/${encodeURIComponent(selectedNodeId)}`, { values });
|
||||
const fields = result.data.fields || [];
|
||||
const nextValues = Object.fromEntries(fields.map((field) => [field.key, field.value || '']));
|
||||
setSettingsByNode((current) => ({
|
||||
...current,
|
||||
[selectedNodeId]: { fields, values: nextValues },
|
||||
}));
|
||||
setValues(nextValues);
|
||||
setMessage('Repository settings saved.');
|
||||
await onChanged?.();
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
{error ? <Notice tone="bad" text={error} /> : null}
|
||||
{message ? <Notice tone="good" text={message} /> : null}
|
||||
|
||||
<div className="overflow-hidden border border-zinc-700 bg-zinc-950/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="h-10 w-64 max-w-full border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none"
|
||||
disabled
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
/>
|
||||
<select className="h-10 border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-300 outline-none" disabled>
|
||||
<option>All status</option>
|
||||
</select>
|
||||
<select className="h-10 border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-300 outline-none" disabled>
|
||||
<option>All backends</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-10 items-center gap-2 rounded-md border border-zinc-700 px-3 text-sm font-semibold text-zinc-100 hover:border-zinc-500"
|
||||
onClick={loadRepositorySettings}
|
||||
type="button"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] text-left text-sm">
|
||||
<thead className="border-b border-zinc-800 text-xs uppercase tracking-wide text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Name</th>
|
||||
<th className="px-4 py-3 font-semibold">Backend</th>
|
||||
<th className="px-4 py-3 font-semibold">Repository</th>
|
||||
<th className="px-4 py-3 font-semibold">Retention</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
className={`cursor-pointer hover:bg-zinc-900 ${selectedNodeId === row.id ? 'bg-red-950/20' : ''}`}
|
||||
key={row.id}
|
||||
onClick={() => setSelectedNodeId(row.id)}
|
||||
>
|
||||
<td className="px-4 py-4 font-semibold text-red-400">{row.name}</td>
|
||||
<td className="px-4 py-4 text-zinc-300">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4 text-zinc-500" />
|
||||
{row.backend}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[30rem] truncate px-4 py-4 font-mono text-xs text-zinc-300">{row.repository || '-'}</td>
|
||||
<td className="px-4 py-4 text-zinc-300">{row.retention}</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`inline-flex h-3 w-3 rounded-full ${statusClass(row, loading)}`} title={row.settingsError || row.lastHealthStatus || 'unknown'} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!rows.length ? (
|
||||
<tr>
|
||||
<td className="px-4 py-10 text-center text-zinc-500" colSpan="5">No nodes configured.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 px-4 py-3 text-right text-sm text-zinc-400">
|
||||
<span className="font-semibold text-red-400">{rows.length}</span> repositories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNodeId ? (
|
||||
<form className="border border-zinc-700 bg-zinc-950/80" onSubmit={saveRepository}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-zinc-100">Repository Settings</h2>
|
||||
<p className="text-sm text-zinc-500">{nodes.find((node) => node.id === selectedNodeId)?.name || selectedNodeId}</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-red-900 bg-red-950/40 px-3 text-sm text-red-100 hover:border-red-700"
|
||||
disabled={saving || Boolean(selectedSettings?.error)}
|
||||
type="submit"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedSettings?.error ? (
|
||||
<div className="px-4 py-6 text-sm text-red-300">{selectedSettings.error}</div>
|
||||
) : (
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
||||
{editableFields.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}</span>
|
||||
<span className="font-mono text-xs text-zinc-500">{field.key}</span>
|
||||
</span>
|
||||
<span className="mt-2 flex border border-zinc-700 bg-zinc-900 focus-within:border-red-700">
|
||||
<input
|
||||
className="h-10 min-w-0 flex-1 bg-transparent px-3 text-zinc-100 outline-none"
|
||||
min={field.key.startsWith('RESTIC_KEEP_') ? '0' : undefined}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
|
||||
type={field.secret && !secretVisible ? 'password' : field.key.startsWith('RESTIC_KEEP_') ? 'number' : '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>
|
||||
)}
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function backendName(repository) {
|
||||
if (!repository) return '-';
|
||||
if (repository.startsWith('s3:')) return 's3';
|
||||
if (repository.startsWith('sftp:')) return 'sftp';
|
||||
if (repository.startsWith('rest:')) return 'rest';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
function retentionLabel(values) {
|
||||
const parts = [
|
||||
['h', values.RESTIC_KEEP_HOURLY],
|
||||
['d', values.RESTIC_KEEP_DAILY],
|
||||
['w', values.RESTIC_KEEP_WEEKLY],
|
||||
['m', values.RESTIC_KEEP_MONTHLY],
|
||||
].filter(([, value]) => value !== undefined && value !== '');
|
||||
if (!parts.length) return '-';
|
||||
return parts.map(([label, value]) => `${value}${label}`).join(' ');
|
||||
}
|
||||
|
||||
function statusClass(row, loading) {
|
||||
if (loading) return 'bg-amber-400';
|
||||
if (row.settingsError) return 'bg-red-500';
|
||||
if (row.lastHealthStatus === 'ok' || row.lastHealthStatus === 'healthy') return 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.8)]';
|
||||
return 'bg-zinc-600';
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
return <div className={`rounded-md border px-4 py-3 text-sm ${classes[tone]}`}>{text}</div>;
|
||||
}
|
||||
+10
-1
@@ -17,7 +17,8 @@ body {
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -25,3 +26,11 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-grid-bg {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
linear-gradient(rgba(63, 63, 70, 0.28) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(63, 63, 70, 0.28) 1px, transparent 1px);
|
||||
background-size: 46px 46px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user