changed UI layout

new Navigation
Dashboard
This commit is contained in:
Philipp
2026-05-21 12:49:32 +02:00
parent bef27416bf
commit 7eb9fdef18
5 changed files with 707 additions and 179 deletions
+127 -100
View File
@@ -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>
);
}
+111 -78
View File
@@ -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';
+270
View File
@@ -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
View File
@@ -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;
}