Builded new structure.

Now with remote setup and management Server
more than one Node can be connected
added user auth
This commit is contained in:
Philipp
2026-05-21 09:51:07 +02:00
parent 4c98d14352
commit aae54167a1
25 changed files with 1942 additions and 52 deletions
+68 -7
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CalendarClock, DatabaseBackup, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
import { AlertTriangle, CalendarClock, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } 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 { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx';
@@ -11,7 +13,10 @@ export default function App() {
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);
@@ -19,11 +24,12 @@ export default function App() {
async function refresh() {
setError('');
try {
const [healthResult, vmsResult, jobsResult, schedulesResult] = await Promise.allSettled([
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') {
@@ -35,8 +41,9 @@ export default function App() {
if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data);
if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data);
if (schedulesResult.status === 'fulfilled') setSchedules(schedulesResult.value.data);
if (nodesResult.status === 'fulfilled') setNodes(nodesResult.value.data);
const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult].find((result) => result.status === 'rejected');
const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected');
if (firstFailure) setError(errorMessage(firstFailure.reason));
} catch (requestError) {
setError(errorMessage(requestError));
@@ -46,16 +53,49 @@ export default function App() {
}
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.name === selectedVm) || null,
() => 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} />;
}
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800 bg-zinc-950/95">
@@ -77,6 +117,17 @@ export default function App() {
</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={() => {
@@ -107,6 +158,14 @@ export default function App() {
<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>
</div>
</div>
</header>
@@ -119,8 +178,10 @@ export default function App() {
) : null}
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'settings' ? (
<Settings onChanged={refresh} />
{view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} />
) : view === 'settings' ? (
<Settings nodes={nodes} onChanged={refresh} />
) : view === 'scheduler' ? (
<Scheduler onChanged={refresh} schedules={schedules} vms={vms} />
) : currentVm ? (
+2 -1
View File
@@ -1,8 +1,9 @@
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3100/api',
timeout: 15000,
withCredentials: true,
});
const token = window.localStorage.getItem('incusBackupApiToken') || import.meta.env.VITE_API_TOKEN;
+5 -3
View File
@@ -26,6 +26,7 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) {
<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">Incus</th>
<th className="px-4 py-3 font-medium">Last Job</th>
<th className="px-4 py-3 font-medium">Active Job</th>
@@ -34,8 +35,9 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) {
</thead>
<tbody className="divide-y divide-zinc-800">
{vms.map((vm) => (
<tr className="hover:bg-zinc-900" key={vm.name}>
<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">
<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'}`} />
@@ -59,7 +61,7 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) {
<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)}
onClick={() => onManage(vm.id || vm.name)}
type="button"
>
Manage <ChevronRight className="h-4 w-4" />
@@ -69,7 +71,7 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) {
))}
{!vms.length ? (
<tr>
<td className="px-4 py-8 text-center text-zinc-500" colSpan="5">
<td className="px-4 py-8 text-center text-zinc-500" colSpan="6">
No VMs loaded.
</td>
</tr>
+64
View File
@@ -0,0 +1,64 @@
import { LogIn } from 'lucide-react';
import { useState } from 'react';
import { api, errorMessage } from '../api.js';
export function Login({ onLogin }) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function submit(event) {
event.preventDefault();
setBusy(true);
setError('');
try {
await api.post('/auth/login', { username, password });
await onLogin();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-zinc-950 px-4 text-zinc-100">
<form className="w-full max-w-sm rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={submit}>
<div className="border-b border-zinc-800 px-4 py-3">
<h1 className="text-sm font-semibold">Incus Backup Control</h1>
</div>
<div className="space-y-4 p-4">
{error ? <div className="rounded-md border border-red-900 bg-red-950/50 px-3 py-2 text-sm text-red-200">{error}</div> : null}
<label className="block text-sm text-zinc-300">
Username
<input
className="mt-2 h-10 w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 text-zinc-100 outline-none focus:border-cyan-600"
onChange={(event) => setUsername(event.target.value)}
value={username}
/>
</label>
<label className="block text-sm text-zinc-300">
Password
<input
className="mt-2 h-10 w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 text-zinc-100 outline-none focus:border-cyan-600"
onChange={(event) => setPassword(event.target.value)}
type="password"
value={password}
/>
</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={busy}
type="submit"
>
<LogIn className="h-4 w-4" />
Login
</button>
</div>
</form>
</main>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { Plus, Save, Trash2, Wifi } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api, errorMessage } from '../api.js';
export function Nodes({ nodes, onChanged }) {
const [drafts, setDrafts] = useState(() => nodes);
const [newNode, setNewNode] = useState({ name: '', baseUrl: '', token: '', enabled: true });
const [message, setMessage] = useState('');
const [error, setError] = useState('');
async function createNode(event) {
event.preventDefault();
setError('');
setMessage('');
try {
await api.post('/nodes', newNode);
setNewNode({ name: '', baseUrl: '', token: '', enabled: true });
setMessage('Node added.');
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
}
}
async function saveNode(node) {
setError('');
setMessage('');
try {
const body = { ...node };
if (!body.token) delete body.token;
await api.put(`/nodes/${encodeURIComponent(node.id)}`, body);
setMessage('Node saved.');
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
}
}
async function testNode(node) {
setError('');
setMessage('');
try {
await api.post(`/nodes/${encodeURIComponent(node.id)}/test`);
setMessage('Node connection ok.');
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
}
}
async function deleteNode(node) {
setError('');
setMessage('');
try {
await api.delete(`/nodes/${encodeURIComponent(node.id)}`);
setMessage('Node deleted.');
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
}
}
function updateDraft(id, values) {
setDrafts((current) => current.map((node) => node.id === id ? { ...node, ...values } : node));
}
useEffect(() => {
setDrafts(nodes);
}, [nodes]);
return (
<section className="space-y-5">
<div className="border-b border-zinc-800 pb-5">
<h1 className="text-2xl font-semibold text-zinc-100">Nodes</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-zinc-500">Register Incus backend agents by URL and token.</p>
</div>
{error ? <Notice tone="bad" text={error} /> : null}
{message ? <Notice tone="good" text={message} /> : null}
<form className="rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={createNode}>
<div className="grid gap-4 p-4 lg:grid-cols-[1fr_1.5fr_1.5fr_auto]">
<Input label="Name" onChange={(value) => setNewNode((current) => ({ ...current, name: value }))} value={newNode.name} />
<Input label="Base URL" onChange={(value) => setNewNode((current) => ({ ...current, baseUrl: value }))} placeholder="http://node:3000" value={newNode.baseUrl} />
<Input label="Token" onChange={(value) => setNewNode((current) => ({ ...current, token: value }))} type="password" value={newNode.token} />
<button className="mt-6 inline-flex h-10 items-center gap-2 rounded-md border border-cyan-800 bg-cyan-950 px-3 text-sm text-cyan-100" type="submit">
<Plus className="h-4 w-4" />
Add
</button>
</div>
</form>
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
<tr>
<th className="px-4 py-3 font-medium">Enabled</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">URL</th>
<th className="px-4 py-3 font-medium">New Token</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{drafts.map((node) => (
<tr key={node.id}>
<td className="px-4 py-3">
<input checked={node.enabled} className="h-4 w-4 accent-cyan-500" onChange={(event) => updateDraft(node.id, { enabled: event.target.checked })} type="checkbox" />
</td>
<td className="px-4 py-3"><input className="h-9 w-full rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100" onChange={(event) => updateDraft(node.id, { name: event.target.value })} value={node.name} /></td>
<td className="px-4 py-3"><input className="h-9 w-full rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100" onChange={(event) => updateDraft(node.id, { baseUrl: event.target.value })} value={node.baseUrl} /></td>
<td className="px-4 py-3"><input className="h-9 w-full rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100" onChange={(event) => updateDraft(node.id, { token: event.target.value })} type="password" value={node.token || ''} /></td>
<td className="px-4 py-3 text-zinc-300">{node.lastHealthStatus || 'unknown'}</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-2">
<button className="inline-flex h-8 items-center gap-1 rounded-md border border-zinc-700 px-2.5 text-xs text-zinc-100" onClick={() => testNode(node)} type="button"><Wifi className="h-4 w-4" /> Test</button>
<button className="inline-flex h-8 items-center gap-1 rounded-md border border-cyan-800 px-2.5 text-xs text-cyan-100" onClick={() => saveNode(node)} type="button"><Save className="h-4 w-4" /> Save</button>
<button className="inline-flex h-8 items-center gap-1 rounded-md border border-red-900 px-2.5 text-xs text-red-300" onClick={() => deleteNode(node)} type="button"><Trash2 className="h-4 w-4" /> Delete</button>
</div>
</td>
</tr>
))}
{!drafts.length ? <tr><td className="px-4 py-8 text-center text-zinc-500" colSpan="6">No nodes configured.</td></tr> : null}
</tbody>
</table>
</div>
</div>
</section>
);
}
function Input({ label, onChange, placeholder = '', type = 'text', value }) {
return (
<label className="block text-sm text-zinc-300">
{label}
<input className="mt-2 h-10 w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 text-zinc-100" onChange={(event) => onChange(event.target.value)} placeholder={placeholder} type={type} value={value} />
</label>
);
}
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>;
}
+20 -15
View File
@@ -27,6 +27,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
.filter((row) => row.enabled)
.map((row) => ({
id: row.id,
nodeId: row.nodeId,
vmName: row.vmName,
enabled: row.enabled,
intervalHours: Number(row.intervalHours) || 24,
@@ -47,10 +48,10 @@ export function Scheduler({ onChanged, schedules, vms }) {
}
}
function updateRow(vmName, values) {
function updateRow(nodeId, vmName, values) {
setDirty(true);
setDrafts((current) => mergeSchedules(vms, current).map((row) => (
row.vmName === vmName ? { ...row, ...values } : row
row.nodeId === nodeId && row.vmName === vmName ? { ...row, ...values } : row
)));
}
@@ -73,6 +74,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
<tr>
<th className="px-4 py-3 font-medium">Enabled</th>
<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">Interval</th>
<th className="px-4 py-3 font-medium">Time</th>
<th className="px-4 py-3 font-medium">Next Run</th>
@@ -82,20 +84,21 @@ export function Scheduler({ onChanged, schedules, vms }) {
</thead>
<tbody className="divide-y divide-zinc-800">
{rows.map((row) => (
<tr key={row.vmName}>
<tr key={`${row.nodeId}:${row.vmName}`}>
<td className="px-4 py-3">
<input
checked={row.enabled}
className="h-4 w-4 accent-cyan-500"
onChange={(event) => updateRow(row.vmName, { enabled: event.target.checked })}
onChange={(event) => updateRow(row.nodeId, row.vmName, { enabled: event.target.checked })}
type="checkbox"
/>
</td>
<td className="px-4 py-3 font-medium text-zinc-100">{row.vmName}</td>
<td className="px-4 py-3 text-zinc-300">{row.nodeName || '-'}</td>
<td className="px-4 py-3">
<select
className="h-9 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100 outline-none"
onChange={(event) => updateRow(row.vmName, { intervalHours: Number(event.target.value), nextRunAt: null })}
onChange={(event) => updateRow(row.nodeId, row.vmName, { intervalHours: Number(event.target.value), nextRunAt: null })}
value={row.intervalHours}
>
<option value={6}>Every 6 hours</option>
@@ -107,7 +110,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
<td className="px-4 py-3">
<input
className="h-9 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-zinc-100 outline-none"
onChange={(event) => updateRow(row.vmName, { timeOfDay: event.target.value, nextRunAt: null })}
onChange={(event) => updateRow(row.nodeId, row.vmName, { timeOfDay: event.target.value, nextRunAt: null })}
type="time"
value={row.timeOfDay || '02:00'}
/>
@@ -119,7 +122,7 @@ export function Scheduler({ onChanged, schedules, vms }) {
))}
{!rows.length ? (
<tr>
<td className="px-4 py-8 text-center text-zinc-500" colSpan="7">
<td className="px-4 py-8 text-center text-zinc-500" colSpan="8">
No VMs loaded.
</td>
</tr>
@@ -143,16 +146,18 @@ export function Scheduler({ onChanged, schedules, vms }) {
}
function mergeSchedules(vms, schedules) {
const byVm = new Map((schedules || []).map((schedule) => [schedule.vmName, schedule]));
const byVm = new Map((schedules || []).map((schedule) => [`${schedule.nodeId}:${schedule.vmName}`, schedule]));
return (vms || []).map((vm) => ({
id: byVm.get(vm.name)?.id,
id: byVm.get(`${vm.nodeId}:${vm.name}`)?.id,
nodeId: vm.nodeId,
nodeName: vm.nodeName,
vmName: vm.name,
enabled: Boolean(byVm.get(vm.name)?.enabled),
intervalHours: byVm.get(vm.name)?.intervalHours || 24,
timeOfDay: byVm.get(vm.name)?.timeOfDay || '02:00',
nextRunAt: byVm.get(vm.name)?.nextRunAt || null,
lastRunAt: byVm.get(vm.name)?.lastRunAt || null,
lastError: byVm.get(vm.name)?.lastError || '',
enabled: Boolean(byVm.get(`${vm.nodeId}:${vm.name}`)?.enabled),
intervalHours: byVm.get(`${vm.nodeId}:${vm.name}`)?.intervalHours || 24,
timeOfDay: byVm.get(`${vm.nodeId}:${vm.name}`)?.timeOfDay || '02:00',
nextRunAt: byVm.get(`${vm.nodeId}:${vm.name}`)?.nextRunAt || null,
lastRunAt: byVm.get(`${vm.nodeId}:${vm.name}`)?.lastRunAt || null,
lastError: byVm.get(`${vm.nodeId}:${vm.name}`)?.lastError || '',
}));
}
+26 -8
View File
@@ -1,10 +1,11 @@
import { Eye, EyeOff, Save } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { api, errorMessage, setApiToken } from '../api.js';
import { api, errorMessage } from '../api.js';
export function Settings({ onChanged }) {
export function Settings({ nodes, onChanged }) {
const [fields, setFields] = useState([]);
const [values, setValues] = useState({});
const [nodeId, setNodeId] = useState(nodes[0]?.id || '');
const [visibleSecrets, setVisibleSecrets] = useState({});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
@@ -16,9 +17,10 @@ export function Settings({ onChanged }) {
);
async function loadSettings() {
if (!nodeId) return;
setError('');
try {
const result = await api.get('/settings');
const result = await api.get(`/settings/${encodeURIComponent(nodeId)}`);
setFields(result.data.fields || []);
setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || ''])));
} catch (requestError) {
@@ -28,7 +30,11 @@ export function Settings({ onChanged }) {
useEffect(() => {
loadSettings();
}, []);
}, [nodeId]);
useEffect(() => {
if (!nodeId && nodes[0]?.id) setNodeId(nodes[0].id);
}, [nodeId, nodes]);
async function saveSettings(event) {
event.preventDefault();
@@ -36,8 +42,7 @@ export function Settings({ onChanged }) {
setMessage('');
setError('');
try {
setApiToken(values.API_TOKEN);
const result = await api.put('/settings', { values });
const result = await api.put(`/settings/${encodeURIComponent(nodeId)}`, { 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.');
@@ -54,7 +59,7 @@ export function Settings({ onChanged }) {
<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.
Values are written to the selected node agent. Keep access to this UI restricted because secrets are editable here.
</p>
</div>
@@ -65,6 +70,19 @@ export function Settings({ onChanged }) {
) : null}
<form className="rounded-md border border-zinc-800 bg-zinc-900/70" onSubmit={saveSettings}>
<div className="border-b border-zinc-800 p-4">
<label className="block max-w-sm text-sm text-zinc-300">
Node
<select
className="mt-2 h-10 w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 text-zinc-100 outline-none"
onChange={(event) => setNodeId(event.target.value)}
value={nodeId}
>
<option value="">Select node</option>
{nodes.map((node) => <option key={node.id} value={node.id}>{node.name}</option>)}
</select>
</label>
</div>
<div className="grid gap-4 p-4 lg:grid-cols-2">
{fields.map((field) => {
const secretVisible = visibleSecrets[field.key];
@@ -102,7 +120,7 @@ export function Settings({ onChanged }) {
<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}
disabled={saving || !nodeId}
type="submit"
>
<Save className="h-4 w-4" />
+7 -6
View File
@@ -16,14 +16,14 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const vmJobs = useMemo(() => jobs.filter((job) => job.vmName === vm.name), [jobs, vm.name]);
const vmJobs = useMemo(() => jobs.filter((job) => job.vmName === vm.name && job.nodeId === vm.nodeId), [jobs, vm.name, vm.nodeId]);
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)}`);
const result = await api.get(`/snapshots/${encodeURIComponent(vm.nodeId)}/${encodeURIComponent(vm.name)}`);
setSnapshots(result.data);
} catch (requestError) {
setError(errorMessage(requestError));
@@ -32,13 +32,13 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
useEffect(() => {
loadSnapshots();
}, [vm.name]);
}, [vm.name, vm.nodeId]);
async function createBackup() {
setBusy(true);
setError('');
try {
await api.post(`/backup/${encodeURIComponent(vm.name)}`);
await api.post(`/backup/${encodeURIComponent(vm.nodeId)}/${encodeURIComponent(vm.name)}`);
await onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
@@ -51,7 +51,7 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
setBusy(true);
setError('');
try {
await api.post(`/restore/${encodeURIComponent(vm.name)}`, {
await api.post(`/restore/${encodeURIComponent(vm.nodeId)}/${encodeURIComponent(vm.name)}`, {
snapshotId: selectedSnapshot.id,
confirmVmName: confirmText,
});
@@ -71,7 +71,7 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
setSnapshotFilesLoading(true);
setError('');
try {
const result = await api.get(`/snapshots/${encodeURIComponent(vm.name)}/${encodeURIComponent(snapshot.id)}/files`);
const result = await api.get(`/snapshots/${encodeURIComponent(vm.nodeId)}/${encodeURIComponent(vm.name)}/${encodeURIComponent(snapshot.id)}/files`);
setSnapshotFiles(result.data);
} catch (requestError) {
setError(errorMessage(requestError));
@@ -92,6 +92,7 @@ export function VMDetail({ jobs, onBack, onChanged, vm }) {
<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">Node: {vm.nodeName || '-'}</p>
<p className="mt-2 text-sm text-zinc-500">Latest snapshot: {latestSnapshot ? formatTime(latestSnapshot) : 'None'}</p>
</div>
<div className="flex gap-2">