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:
+68
-7
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 || '',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user