changed poll and some UI bugs.
This commit is contained in:
Generated
+3
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "incus-backup-ui-agent",
|
"name": "incus-backup-ui-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.5.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"dev": "node --watch src/index.js",
|
"dev": "node --watch src/index.js",
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.5.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ need_cmd() {
|
|||||||
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
|
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
node_major_version() {
|
||||||
|
node -p "Number(process.versions.node.split('.')[0])"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_node_version() {
|
||||||
|
need_cmd node
|
||||||
|
major=$(node_major_version)
|
||||||
|
if [ "$major" -lt 22 ]; then
|
||||||
|
fail "Node.js 22.5 or newer is required. Found: $(node -v). Install a current Node.js release and rerun this installer."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
as_root() {
|
as_root() {
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
"$@"
|
"$@"
|
||||||
@@ -177,6 +189,7 @@ configure_env() {
|
|||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
need_cmd npm
|
need_cmd npm
|
||||||
|
check_node_version
|
||||||
log "Installing agent dependencies..."
|
log "Installing agent dependencies..."
|
||||||
if [ -f "$AGENT_DIR/package-lock.json" ]; then
|
if [ -f "$AGENT_DIR/package-lock.json" ]; then
|
||||||
npm --prefix "$AGENT_DIR" ci --omit=dev
|
npm --prefix "$AGENT_DIR" ci --omit=dev
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
Run this on every Incus host. The installer clones or updates the repository, creates the agent state/config directories, prepares `agent/.env`, installs npm dependencies, installs the systemd unit, and starts the service:
|
Run this on every Incus host. The installer clones or updates the repository, creates the agent state/config directories, prepares `agent/.env`, installs npm dependencies, installs the systemd unit, and starts the service:
|
||||||
|
|
||||||
|
The agent requires Node.js 22.5 or newer because it uses Node's built-in SQLite module.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://forgejo.digital-droplets.de/philschlo/incus-backup-ui/raw/branch/main/deploy/install-agent.sh | sudo sh
|
curl -fsSL https://forgejo.digital-droplets.de/philschlo/incus-backup-ui/raw/branch/main/deploy/install-agent.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|||||||
+18
-9
@@ -36,7 +36,6 @@ export default function App() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
const [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult] = await Promise.allSettled([
|
const [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult] = await Promise.allSettled([
|
||||||
api.get('/health'),
|
api.get('/health'),
|
||||||
@@ -47,20 +46,20 @@ export default function App() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (healthResult.status === 'fulfilled') {
|
if (healthResult.status === 'fulfilled') {
|
||||||
setHealth(healthResult.value.data);
|
setStableState(setHealth, healthResult.value.data);
|
||||||
} else {
|
} else {
|
||||||
setHealth(healthResult.reason.response?.data || { ok: false, checks: {} });
|
setStableState(setHealth, healthResult.reason.response?.data || { ok: false, checks: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data);
|
if (vmsResult.status === 'fulfilled') setStableState(setVms, vmsResult.value.data);
|
||||||
if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data);
|
if (jobsResult.status === 'fulfilled') setStableState(setJobs, jobsResult.value.data);
|
||||||
if (schedulesResult.status === 'fulfilled') setSchedules(schedulesResult.value.data);
|
if (schedulesResult.status === 'fulfilled') setStableState(setSchedules, schedulesResult.value.data);
|
||||||
if (nodesResult.status === 'fulfilled') setNodes(nodesResult.value.data);
|
if (nodesResult.status === 'fulfilled') setStableState(setNodes, nodesResult.value.data);
|
||||||
|
|
||||||
const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected');
|
const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected');
|
||||||
if (firstFailure) setError(errorMessage(firstFailure.reason));
|
setStableState(setError, firstFailure ? errorMessage(firstFailure.reason) : '');
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(errorMessage(requestError));
|
setStableState(setError, errorMessage(requestError));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -227,6 +226,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStableState(setter, nextValue) {
|
||||||
|
setter((currentValue) => (
|
||||||
|
stableStringify(currentValue) === stableStringify(nextValue) ? currentValue : nextValue
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableStringify(value) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
function NavButton({ active, icon: Icon, label, onClick }) {
|
function NavButton({ active, icon: Icon, label, onClick }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api, errorMessage } from '../api.js';
|
|||||||
|
|
||||||
export function Nodes({ nodes, onChanged }) {
|
export function Nodes({ nodes, onChanged }) {
|
||||||
const [drafts, setDrafts] = useState(() => nodes);
|
const [drafts, setDrafts] = useState(() => nodes);
|
||||||
|
const [dirtyIds, setDirtyIds] = useState(() => new Set());
|
||||||
const [newNode, setNewNode] = useState({ name: '', baseUrl: '', token: '', enabled: true });
|
const [newNode, setNewNode] = useState({ name: '', baseUrl: '', token: '', enabled: true });
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -29,6 +30,7 @@ export function Nodes({ nodes, onChanged }) {
|
|||||||
const body = { ...node };
|
const body = { ...node };
|
||||||
if (!body.token) delete body.token;
|
if (!body.token) delete body.token;
|
||||||
await api.put(`/nodes/${encodeURIComponent(node.id)}`, body);
|
await api.put(`/nodes/${encodeURIComponent(node.id)}`, body);
|
||||||
|
setDirtyIds((current) => withoutId(current, node.id));
|
||||||
setMessage('Node saved.');
|
setMessage('Node saved.');
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
@@ -53,6 +55,7 @@ export function Nodes({ nodes, onChanged }) {
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
try {
|
try {
|
||||||
await api.delete(`/nodes/${encodeURIComponent(node.id)}`);
|
await api.delete(`/nodes/${encodeURIComponent(node.id)}`);
|
||||||
|
setDirtyIds((current) => withoutId(current, node.id));
|
||||||
setMessage('Node deleted.');
|
setMessage('Node deleted.');
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
@@ -61,12 +64,30 @@ export function Nodes({ nodes, onChanged }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateDraft(id, values) {
|
function updateDraft(id, values) {
|
||||||
|
setDirtyIds((current) => withId(current, id));
|
||||||
setDrafts((current) => current.map((node) => node.id === id ? { ...node, ...values } : node));
|
setDrafts((current) => current.map((node) => node.id === id ? { ...node, ...values } : node));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleNodeEnabled(node, enabled) {
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
setDrafts((current) => current.map((draft) => draft.id === node.id ? { ...draft, enabled } : draft));
|
||||||
|
try {
|
||||||
|
await api.put(`/nodes/${encodeURIComponent(node.id)}`, { enabled });
|
||||||
|
setMessage(enabled ? 'Node enabled.' : 'Node disabled.');
|
||||||
|
await onChanged();
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(errorMessage(requestError));
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDrafts(nodes);
|
setDrafts((current) => {
|
||||||
}, [nodes]);
|
const currentById = new Map(current.map((node) => [node.id, node]));
|
||||||
|
return nodes.map((node) => dirtyIds.has(node.id) ? { ...node, ...currentById.get(node.id) } : node);
|
||||||
|
});
|
||||||
|
}, [dirtyIds, nodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
@@ -107,7 +128,7 @@ export function Nodes({ nodes, onChanged }) {
|
|||||||
{drafts.map((node) => (
|
{drafts.map((node) => (
|
||||||
<tr key={node.id}>
|
<tr key={node.id}>
|
||||||
<td className="px-4 py-3">
|
<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" />
|
<input checked={node.enabled} className="h-4 w-4 accent-cyan-500" onChange={(event) => toggleNodeEnabled(node, event.target.checked)} type="checkbox" />
|
||||||
</td>
|
</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, { 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, { baseUrl: event.target.value })} value={node.baseUrl} /></td>
|
||||||
@@ -131,6 +152,18 @@ export function Nodes({ nodes, onChanged }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withId(set, id) {
|
||||||
|
const next = new Set(set);
|
||||||
|
next.add(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutId(set, id) {
|
||||||
|
const next = new Set(set);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function Input({ label, onChange, placeholder = '', type = 'text', value }) {
|
function Input({ label, onChange, placeholder = '', type = 'text', value }) {
|
||||||
return (
|
return (
|
||||||
<label className="block text-sm text-zinc-300">
|
<label className="block text-sm text-zinc-300">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ proxyRouter.get('/vms', async (_req, res) => {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
await Promise.all(nodes.map(async (node) => {
|
await Promise.all(nodes.map(async (node) => {
|
||||||
try {
|
try {
|
||||||
const vms = await agentRequest(node, '/vms');
|
const vms = await agentRequest(node, '/vms', { timeout: 8000 });
|
||||||
for (const vm of vms) {
|
for (const vm of vms) {
|
||||||
rows.push({ ...vm, id: `${node.id}:${vm.name}`, nodeId: node.id, nodeName: node.name });
|
rows.push({ ...vm, id: `${node.id}:${vm.name}`, nodeId: node.id, nodeName: node.name });
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ proxyRouter.get('/jobs', async (_req, res) => {
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
await Promise.all(nodes.map(async (node) => {
|
await Promise.all(nodes.map(async (node) => {
|
||||||
try {
|
try {
|
||||||
const jobs = await agentRequest(node, '/jobs');
|
const jobs = await agentRequest(node, '/jobs', { timeout: 8000 });
|
||||||
for (const job of jobs) rows.push({ ...job, nodeId: node.id, nodeName: node.name });
|
for (const job of jobs) rows.push({ ...job, nodeId: node.id, nodeName: node.name });
|
||||||
} catch {
|
} catch {
|
||||||
recordNodeHealth(node.id, 'error');
|
recordNodeHealth(node.id, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user