From a24e99f59bca207b3a03ddeefa67eda0f33cae84 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Tue, 7 Jul 2026 20:11:28 -0400 Subject: [PATCH 01/16] feat(nodes): add Docker API backend --- create-a-container/client/src/lib/types.ts | 1 + .../client/src/pages/nodes/NodeFormPage.tsx | 185 ++++--- create-a-container/models/node.js | 32 ++ .../routers/api/v1/containers.js | 9 +- create-a-container/routers/api/v1/nodes.js | 9 +- create-a-container/utils/container-status.js | 8 +- create-a-container/utils/docker-api.js | 451 ++++++++++++++++++ 7 files changed, 608 insertions(+), 87 deletions(-) create mode 100644 create-a-container/utils/docker-api.js diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 809db607..4260b0c4 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -18,6 +18,7 @@ export interface Site { export interface Node { id: number; name: string; + nodeType: 'proxmox' | 'dummy' | 'docker'; siteId: number; ipv4Address: string | null; apiUrl: string | null; diff --git a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx index f3a36799..1c3a3b1a 100644 --- a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; +import { useEffect } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { Alert, AlertDescription, @@ -12,23 +12,24 @@ import { Spinner, Switch, useToast, -} from '@mieweb/ui'; -import { Server } from 'lucide-react'; -import { api, ApiError } from '@/lib/api'; -import { keys, queries } from '@/lib/queries'; -import { FormPageLayout } from '@/components/FormPageLayout'; -import type { Node } from '@/lib/types'; +} from "@mieweb/ui"; +import { Server } from "lucide-react"; +import { api, ApiError } from "@/lib/api"; +import { keys, queries } from "@/lib/queries"; +import { FormPageLayout } from "@/components/FormPageLayout"; +import type { Node } from "@/lib/types"; const schema = z.object({ - name: z.string().min(1, 'Required'), + name: z.string().min(1, "Required"), + nodeType: z.enum(["proxmox", "dummy", "docker"]).default("proxmox"), ipv4Address: z.string().optional(), - apiUrl: z.string().url('Must be a valid URL').or(z.literal('')).optional(), + apiUrl: z.string().optional(), tokenId: z.string().optional(), secret: z.string().optional(), tlsVerify: z.boolean().optional(), - imageStorage: z.string().min(1, 'Required'), - volumeStorage: z.string().min(1, 'Required'), - networkBridge: z.string().min(1, 'Required'), + imageStorage: z.string().min(1, "Required"), + volumeStorage: z.string().min(1, "Required"), + networkBridge: z.string().min(1, "Required"), nvidiaAvailable: z.boolean().optional(), }); type FormData = z.infer; @@ -41,32 +42,36 @@ export function NodeFormPage() { const toast = useToast(); const { data: node, isLoading } = useQuery({ - queryKey: keys.node(siteId!, id ?? 'new'), + queryKey: keys.node(siteId!, id ?? "new"), queryFn: () => queries.getNode(siteId!, id!), enabled: isEdit, }); - const { register, handleSubmit, reset, watch, setValue, formState } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - tlsVerify: true, - nvidiaAvailable: false, - imageStorage: 'local', - volumeStorage: 'local-lvm', - networkBridge: 'vmbr0', - }, - }); - const tlsVerify = watch('tlsVerify'); - const nvidiaAvailable = watch('nvidiaAvailable'); + const { register, handleSubmit, reset, watch, setValue, formState } = + useForm({ + resolver: zodResolver(schema), + defaultValues: { + nodeType: "proxmox", + tlsVerify: true, + nvidiaAvailable: false, + imageStorage: "local", + volumeStorage: "local-lvm", + networkBridge: "vmbr0", + }, + }); + const nodeType = watch("nodeType"); + const tlsVerify = watch("tlsVerify"); + const nvidiaAvailable = watch("nvidiaAvailable"); useEffect(() => { if (node) { reset({ name: node.name, - ipv4Address: node.ipv4Address || '', - apiUrl: node.apiUrl || '', - tokenId: node.tokenId || '', - secret: '', + nodeType: node.nodeType || "proxmox", + ipv4Address: node.ipv4Address || "", + apiUrl: node.apiUrl || "", + tokenId: node.tokenId || "", + secret: "", tlsVerify: node.tlsVerify ?? true, imageStorage: node.imageStorage, volumeStorage: node.volumeStorage, @@ -85,7 +90,7 @@ export function NodeFormPage() { : api.post(`/api/v1/sites/${siteId}/nodes`, payload); }, onSuccess: () => { - toast.success(isEdit ? 'Node updated' : 'Node created'); + toast.success(isEdit ? "Node updated" : "Node created"); qc.invalidateQueries({ queryKey: keys.nodes(siteId!) }); navigate(`/sites/${siteId}/nodes`); }, @@ -104,13 +109,13 @@ export function NodeFormPage() {
mutation.mutate(v))} noValidate> } - title={isEdit ? 'Edit node' : 'New node'} + title={isEdit ? "Edit node" : "New node"} subtitle={ isEdit - ? 'Update Proxmox connection details and storage settings.' - : 'Register a Proxmox node with API credentials and default storage.' + ? "Update node connection details and storage settings." + : "Register a Proxmox, Docker, or dummy node." } - backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }} + backTo={{ label: "Back to nodes", to: `/sites/${siteId}/nodes` }} maxWidth="3xl" actions={ <> @@ -121,8 +126,12 @@ export function NodeFormPage() { > Cancel - } @@ -133,57 +142,89 @@ export function NodeFormPage() { placeholder="pve-01" error={formState.errors.name?.message} hasError={!!formState.errors.name} - {...register('name')} + {...register("name")} /> +
+ + +
- - - setValue('tlsVerify', c)} + {...register("apiUrl")} /> + {nodeType === "proxmox" && ( + <> + + + setValue("tlsVerify", c)} + /> + + )}
- - - + + +
setValue('nvidiaAvailable', c)} + onCheckedChange={(c) => setValue("nvidiaAvailable", c)} /> {mutation.error && ( - {(mutation.error as ApiError).message} + + {(mutation.error as ApiError).message} + )}
diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js index b3c79061..93b042aa 100644 --- a/create-a-container/models/node.js +++ b/create-a-container/models/node.js @@ -5,6 +5,7 @@ const { const https = require('https'); const ProxmoxApi = require('../utils/proxmox-api'); const DummyApi = require('../utils/dummy-api'); +const DockerApi = require('../utils/docker-api'); module.exports = (sequelize, DataTypes) => { class Node extends Model { @@ -24,6 +25,30 @@ module.exports = (sequelize, DataTypes) => { }); } + hasApiAccess() { + if (this.nodeType === 'dummy') return true; + if (this.nodeType === 'docker') return !!this.apiUrl; + return !!(this.apiUrl && this.tokenId && this.secret); + } + + static provisionableWhere(Sequelize) { + return { + [Sequelize.Op.or]: [ + { nodeType: 'dummy' }, + { + nodeType: 'docker', + apiUrl: { [Sequelize.Op.ne]: null }, + }, + { + nodeType: 'proxmox', + apiUrl: { [Sequelize.Op.ne]: null }, + tokenId: { [Sequelize.Op.ne]: null }, + secret: { [Sequelize.Op.ne]: null }, + }, + ], + }; + } + /** * Create an API client for this node, selected by `nodeType`. * @@ -42,6 +67,13 @@ module.exports = (sequelize, DataTypes) => { return new DummyApi(this); } + if (this.nodeType === 'docker') { + if (!this.apiUrl) { + throw new Error(`Node ${this.name}: Missing Docker configuration (apiUrl / DOCKER_HOST is required)`); + } + return new DockerApi(this); + } + if (!this.apiUrl || !this.tokenId || !this.secret) { throw new Error(`Node ${this.name}: Missing Proxmox configuration (apiUrl, tokenId, and secret are required)`); } diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index ad517589..4cde5523 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -296,14 +296,7 @@ router.post( // provisioned. const nodeWhere = { siteId: site.id, - [Sequelize.Op.or]: [ - { nodeType: 'dummy' }, - { - apiUrl: { [Sequelize.Op.ne]: null }, - tokenId: { [Sequelize.Op.ne]: null }, - secret: { [Sequelize.Op.ne]: null }, - }, - ], + ...Node.provisionableWhere(Sequelize), }; if (wantsNvidia) nodeWhere.nvidiaAvailable = true; const node = await Node.findOne({ diff --git a/create-a-container/routers/api/v1/nodes.js b/create-a-container/routers/api/v1/nodes.js index 2cbb3303..fb5aaeb0 100644 --- a/create-a-container/routers/api/v1/nodes.js +++ b/create-a-container/routers/api/v1/nodes.js @@ -16,6 +16,7 @@ function serialize(n) { return { id: n.id, name: n.name, + nodeType: n.nodeType, siteId: n.siteId, ipv4Address: n.ipv4Address, apiUrl: n.apiUrl, @@ -63,7 +64,7 @@ router.get( const node = await Node.findOne({ where: { id: parseInt(req.params.id, 10), siteId: site.id }, }); - if (!node || !node.apiUrl || !node.tokenId || !node.secret) return ok(res, []); + if (!node || !node.hasApiAccess()) return ok(res, []); try { const client = await node.api(); const storages = await client.datastores(node.name, 'vztmpl', true); @@ -80,10 +81,11 @@ router.post( apiAdmin, asyncHandler(async (req, res) => { const site = await loadSite(req); - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = + const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; const node = await Node.create({ name, + nodeType: nodeType || 'proxmox', ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, @@ -109,10 +111,11 @@ router.put( where: { id: parseInt(req.params.id, 10), siteId: site.id }, }); if (!node) throw new ApiError(404, 'not_found', 'Node not found'); - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = + const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; const update = { name, + nodeType: nodeType || node.nodeType || 'proxmox', ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, diff --git a/create-a-container/utils/container-status.js b/create-a-container/utils/container-status.js index 0c94c9d5..b561bf5c 100644 --- a/create-a-container/utils/container-status.js +++ b/create-a-container/utils/container-status.js @@ -41,8 +41,8 @@ const STATUS_VALUES = Object.freeze(Object.values(STATUS)); const ACTIVE_JOB_STATUSES = ['pending', 'running']; -function nodeHasCreds(node) { - return !!(node && node.apiUrl && node.tokenId && node.secret); +function nodeHasApiAccess(node) { + return !!(node && typeof node.hasApiAccess === 'function' && node.hasApiAccess()); } /** @@ -118,7 +118,7 @@ function decideStatus(facts) { */ async function computeContainerStatus({ container, Job, api, snapshot }) { const node = container.node; - const hasCreds = nodeHasCreds(node); + const hasCreds = nodeHasApiAccess(node); const hasVmid = container.containerId != null; // --- Determine Proxmox presence / run state --- @@ -202,7 +202,7 @@ async function computeContainerStatuses(containers, Job) { const node = group[0].node; let snapshot = { ok: false, data: null }; - if (nodeHasCreds(node)) { + if (nodeHasApiAccess(node)) { try { const api = await node.api(); const data = await api.clusterResources('lxc'); diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js new file mode 100644 index 00000000..2146c929 --- /dev/null +++ b/create-a-container/utils/docker-api.js @@ -0,0 +1,451 @@ +const axios = require('axios'); + +const MANAGER_NODE_LABEL = 'manager-os.node-id'; +const MANAGER_CONTAINER_LABEL = 'manager-os.container-id'; + +function parseDockerHost(host) { + const raw = host || process.env.DOCKER_HOST || 'unix:///var/run/docker.sock'; + + if (raw.startsWith('unix://')) { + const url = new URL(raw); + return { + baseURL: 'http://docker', + socketPath: decodeURIComponent(url.pathname), + }; + } + + if (raw.startsWith('tcp://')) { + const url = new URL(raw); + return { + baseURL: `http://${url.host}`, + }; + } + + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return { + baseURL: raw.replace(/\/$/, ''), + }; + } + + throw new Error( + `Unsupported Docker host "${raw}". Supported formats: unix://, tcp://, http://, https://`, + ); +} + +function parseEnvString(envStr) { + const env = {}; + if (!envStr || typeof envStr !== 'string') return env; + + for (const pair of envStr.split('\0')) { + const eq = pair.indexOf('='); + if (eq > 0) { + env[pair.substring(0, eq)] = pair.substring(eq + 1); + } + } + + return env; +} + +function envObjectToArray(envObj) { + return Object.entries(envObj || {}).map(([key, value]) => `${key}=${value}`); +} + +function envArrayToObject(envArray) { + const env = {}; + for (const pair of envArray || []) { + const eq = pair.indexOf('='); + if (eq > 0) { + env[pair.substring(0, eq)] = pair.substring(eq + 1); + } + } + return env; +} + +function task(kind, id = '') { + return `docker:${kind}:${id}`; +} + +class DockerApi { + constructor(node = {}) { + this.node = node; + const dockerHost = node.apiUrl || process.env.DOCKER_HOST || 'unix:///var/run/docker.sock'; + const dockerConfig = parseDockerHost(dockerHost); + + this.http = axios.create({ + ...dockerConfig, + timeout: 120000, + }); + + this.lastPulledImage = null; + } + + async request(method, url, options = {}) { + const response = await this.http.request({ + method, + url, + params: options.params, + data: options.data, + responseType: options.responseType, + }); + return response.data; + } + + labels(vmid) { + return { + [MANAGER_NODE_LABEL]: String(this.node.id || this.node.name || 'docker'), + [MANAGER_CONTAINER_LABEL]: String(vmid), + }; + } + + labelFilters(extra = {}) { + return JSON.stringify({ + label: [ + `${MANAGER_NODE_LABEL}=${String(this.node.id || this.node.name || 'docker')}`, + ...Object.entries(extra).map(([k, v]) => `${k}=${v}`), + ], + }); + } + + async findContainerByVmid(vmid) { + const containers = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters({ [MANAGER_CONTAINER_LABEL]: String(vmid) }), + }, + }); + + if (!containers.length) { + throw new Error(`Docker container for manager id ${vmid} not found`); + } + + return containers[0]; + } + + async inspectByVmid(vmid) { + const container = await this.findContainerByVmid(vmid); + return this.request('get', `/containers/${container.Id}/json`); + } + + async nextId() { + const existing = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters(), + }, + }); + + const used = new Set( + existing + .map((c) => c.Labels?.[MANAGER_CONTAINER_LABEL]) + .filter(Boolean) + .map(Number), + ); + + let candidate = Date.now() % 1000000000; + while (used.has(candidate)) candidate += 1; + return candidate; + } + + async nodes() { + const info = await this.request('get', '/info'); + return [ + { + node: this.node.name || info.Name || 'docker', + status: 'online', + type: 'node', + }, + ]; + } + + async nodeNetwork() { + return []; + } + + async clusterResources(type = null) { + if (type && type !== 'lxc' && type !== 'vm') return []; + + const containers = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters(), + }, + }); + + return containers.map((c) => ({ + vmid: Number(c.Labels?.[MANAGER_CONTAINER_LABEL]), + name: (c.Names?.[0] || '').replace(/^\//, ''), + type: 'lxc', + status: c.State === 'running' ? 'running' : 'stopped', + node: this.node.name || 'docker', + })); + } + + async datastores() { + return [ + { + storage: 'docker', + type: 'docker', + content: 'container', + enabled: 1, + active: 1, + total: 0, + avail: 0, + used: 0, + }, + ]; + } + + async storageContents() { + return []; + } + + async pullOciImage(node, storage, options = {}) { + const image = options.reference; + if (!image) throw new Error('Docker image reference is required'); + + this.lastPulledImage = image; + + const stream = await this.request('post', '/images/create', { + params: { fromImage: image }, + responseType: 'stream', + }); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + return task('pull', image); + } + + async createLxc(node, options = {}) { + if (!this.lastPulledImage) { + throw new Error('No Docker image has been pulled for this create operation'); + } + + const vmid = options.vmid; + const name = options.hostname || `manager-${vmid}`; + + const body = { + Image: this.lastPulledImage, + Hostname: name, + Labels: this.labels(vmid), + Env: [], + HostConfig: { + NetworkMode: 'bridge', + }, + }; + + if (options.memory) { + body.HostConfig.Memory = Number(options.memory) * 1024 * 1024; + } + + if (options.cores) { + body.HostConfig.NanoCpus = Number(options.cores) * 1000000000; + } + + const created = await this.request('post', '/containers/create', { + params: { name }, + data: body, + }); + + return task('create', created.Id); + } + + async getLxcTemplates() { + return []; + } + + async cloneLxc() { + throw new Error('Docker nodes do not support cloning Proxmox LXC templates; use a Docker image template'); + } + + async lxcConfig(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + + const network = Object.values(inspect.NetworkSettings?.Networks || {})[0] || {}; + const env = (inspect.Config?.Env || []).join('\0'); + const entrypoint = Array.isArray(inspect.Config?.Entrypoint) + ? inspect.Config.Entrypoint.join(' ') + : inspect.Config?.Entrypoint || null; + + return { + hostname: inspect.Config?.Hostname || inspect.Name?.replace(/^\//, ''), + env, + entrypoint, + cores: inspect.HostConfig?.NanoCpus + ? Math.round(inspect.HostConfig.NanoCpus / 1000000000) + : null, + memory: inspect.HostConfig?.Memory + ? Math.round(inspect.HostConfig.Memory / 1024 / 1024) + : null, + rootfs: null, + net0: `name=eth0,hwaddr=${network.MacAddress || ''},ip=${network.IPAddress || 'dhcp'},bridge=docker0`, + }; + } + + async recreateForConfig(vmid, config = {}) { + const inspect = await this.inspectByVmid(vmid); + const existing = await this.findContainerByVmid(vmid); + + const wasRunning = inspect.State?.Running; + const name = inspect.Name?.replace(/^\//, '') || inspect.Config?.Hostname || `manager-${vmid}`; + + const env = envArrayToObject(inspect.Config?.Env || []); + const deleteList = typeof config.delete === 'string' ? config.delete.split(',') : []; + + if (deleteList.includes('env')) { + for (const key of Object.keys(env)) delete env[key]; + } + + Object.assign(env, parseEnvString(config.env)); + + let entrypoint = inspect.Config?.Entrypoint || undefined; + if (deleteList.includes('entrypoint')) entrypoint = undefined; + if (config.entrypoint) entrypoint = config.entrypoint.split(' '); + + if (wasRunning) { + await this.request('post', `/containers/${existing.Id}/stop`).catch(() => {}); + } + + await this.request('delete', `/containers/${existing.Id}`, { + params: { force: true }, + }); + + const body = { + Image: inspect.Config.Image, + Hostname: inspect.Config.Hostname, + Labels: { + ...(inspect.Config.Labels || {}), + ...this.labels(vmid), + }, + Env: envObjectToArray(env), + Entrypoint: entrypoint, + HostConfig: { + ...(inspect.HostConfig || {}), + NetworkMode: inspect.HostConfig?.NetworkMode || 'bridge', + }, + }; + + delete body.HostConfig.Binds; + delete body.HostConfig.Mounts; + delete body.HostConfig.PortBindings; + + await this.request('post', '/containers/create', { + params: { name }, + data: body, + }); + } + + async updateLxcConfig(node, vmid, config = {}) { + const hasContainerConfigChanges = + config.env !== undefined || + config.entrypoint !== undefined || + String(config.delete || '').includes('env') || + String(config.delete || '').includes('entrypoint'); + + if (hasContainerConfigChanges) { + await this.recreateForConfig(vmid, config); + } + + const container = await this.findContainerByVmid(vmid); + const update = {}; + + if (config.memory) update.Memory = Number(config.memory) * 1024 * 1024; + if (config.cores) update.NanoCpus = Number(config.cores) * 1000000000; + + if (Object.keys(update).length > 0) { + await this.request('post', `/containers/${container.Id}/update`, { + data: update, + }); + } + } + + async startLxc(node, vmid) { + const container = await this.findContainerByVmid(vmid); + await this.request('post', `/containers/${container.Id}/start`); + return task('start', container.Id); + } + + async stopLxc(node, vmid) { + const container = await this.findContainerByVmid(vmid); + await this.request('post', `/containers/${container.Id}/stop`).catch((err) => { + if (err.response?.status !== 304) throw err; + }); + return task('stop', container.Id); + } + + async getLxcStatus(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + return { + status: inspect.State?.Running ? 'running' : 'stopped', + vmid, + }; + } + + async deleteContainer(node, vmid, force = false) { + const container = await this.findContainerByVmid(vmid); + await this.request('delete', `/containers/${container.Id}`, { + params: { force: !!force }, + }); + return { data: null }; + } + + async waitForTask() { + return { status: 'stopped', exitstatus: 'OK' }; + } + + async lxcInterfaces(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + const network = Object.values(inspect.NetworkSettings?.Networks || {})[0] || {}; + + return [ + { + name: 'eth0', + hwaddr: network.MacAddress || null, + inet: network.IPAddress ? `${network.IPAddress}/24` : null, + 'ip-addresses': network.IPAddress + ? [{ 'ip-address-type': 'inet', 'ip-address': network.IPAddress }] + : [], + }, + ]; + } + + async getLxcMacAddress(node, vmid) { + const interfaces = await this.lxcInterfaces(node, vmid); + return interfaces[0]?.hwaddr || null; + } + + async getLxcIpAddress(node, vmid, maxRetries = 10, retryDelay = 3000) { + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + const interfaces = await this.lxcInterfaces(node, vmid); + const ip = interfaces[0]?.['ip-addresses']?.[0]?.['ip-address']; + + if (ip) return ip; + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + return null; + } + + async getLxcNetworkInfo(node, vmid) { + return { + macAddress: await this.getLxcMacAddress(node, vmid), + ipv4Address: await this.getLxcIpAddress(node, vmid), + }; + } + + async updateAcl() { + // Docker has no Proxmox-style ACL path. No-op to keep NodeApi contract. + } + + async syncLdapRealm() { + // Docker has no Proxmox LDAP realm. No-op to keep NodeApi contract. + } +} + +module.exports = DockerApi; \ No newline at end of file From 15ffc48a5e474afa323deb12dd351c7e064932fa Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 12:24:58 -0400 Subject: [PATCH 02/16] fix(nodes): handle Docker credential badge --- .../client/src/pages/nodes/NodesListPage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/create-a-container/client/src/pages/nodes/NodesListPage.tsx b/create-a-container/client/src/pages/nodes/NodesListPage.tsx index 004b146e..a52d1bb1 100644 --- a/create-a-container/client/src/pages/nodes/NodesListPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodesListPage.tsx @@ -46,6 +46,14 @@ function NvidiaBadge({ n }: { n: Node }) { } function CredentialsBadge({ n }: { n: Node }) { + if (n.nodeType === 'dummy') { + return Not required; + } + + if (n.nodeType === 'docker') { + return n.apiUrl ? Host set : Missing; + } + return n.hasSecret ? Set : Missing; } From c7fd90fa3d603eb7bebfd9d2d8ecccf4d8560011 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 12:49:19 -0400 Subject: [PATCH 03/16] fix(nodes): keep provisionable query operators in model --- create-a-container/models/node.js | 15 ++++++++------- create-a-container/routers/api/v1/containers.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js index 93b042aa..fc3488a6 100644 --- a/create-a-container/models/node.js +++ b/create-a-container/models/node.js @@ -1,6 +1,7 @@ 'use strict'; const { - Model + Model, + Op } = require('sequelize'); const https = require('https'); const ProxmoxApi = require('../utils/proxmox-api'); @@ -31,19 +32,19 @@ module.exports = (sequelize, DataTypes) => { return !!(this.apiUrl && this.tokenId && this.secret); } - static provisionableWhere(Sequelize) { + static provisionableWhere() { return { - [Sequelize.Op.or]: [ + [Op.or]: [ { nodeType: 'dummy' }, { nodeType: 'docker', - apiUrl: { [Sequelize.Op.ne]: null }, + apiUrl: { [Op.ne]: null }, }, { nodeType: 'proxmox', - apiUrl: { [Sequelize.Op.ne]: null }, - tokenId: { [Sequelize.Op.ne]: null }, - secret: { [Sequelize.Op.ne]: null }, + apiUrl: { [Op.ne]: null }, + tokenId: { [Op.ne]: null }, + secret: { [Op.ne]: null }, }, ], }; diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 87ccaeaf..da06f253 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -343,7 +343,7 @@ router.post( // provisioned. const nodeWhere = { siteId: site.id, - ...Node.provisionableWhere(Sequelize), + ...Node.provisionableWhere(), }; if (wantsNvidia) nodeWhere.nvidiaAvailable = true; const node = await Node.findOne({ From b5de6b8c6c87b60d12454ac08d60016adcc70b04 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 13:04:49 -0400 Subject: [PATCH 04/16] fix(docker): use org label namespace --- create-a-container/utils/docker-api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 2146c929..fbbad2f9 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -1,7 +1,7 @@ const axios = require('axios'); -const MANAGER_NODE_LABEL = 'manager-os.node-id'; -const MANAGER_CONTAINER_LABEL = 'manager-os.container-id'; +const MANAGER_NODE_LABEL = 'org.mieweb.opensource-server.node-id'; +const MANAGER_CONTAINER_LABEL = 'org.mieweb.opensource-server.container-id'; function parseDockerHost(host) { const raw = host || process.env.DOCKER_HOST || 'unix:///var/run/docker.sock'; From 05b05ebe2ce11aa56cbed3c6a2850fd1b288e6dd Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 13:39:33 -0400 Subject: [PATCH 05/16] fix(docker): require node api url --- create-a-container/utils/docker-api.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index fbbad2f9..02a9e6cb 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -67,9 +67,12 @@ function task(kind, id = '') { class DockerApi { constructor(node = {}) { + if (!node.apiUrl) { + throw new Error('DockerApi requires node.apiUrl'); + } + this.node = node; - const dockerHost = node.apiUrl || process.env.DOCKER_HOST || 'unix:///var/run/docker.sock'; - const dockerConfig = parseDockerHost(dockerHost); + const dockerConfig = parseDockerHost(node.apiUrl); this.http = axios.create({ ...dockerConfig, From 25437b652104a71fc1a04fb0c591ced4bea897ad Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 14:27:32 -0400 Subject: [PATCH 06/16] fix(docker): validate Docker host format --- create-a-container/utils/docker-api.js | 54 +++++++++++++++++++------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 02a9e6cb..86d5e0c6 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -3,33 +3,56 @@ const axios = require('axios'); const MANAGER_NODE_LABEL = 'org.mieweb.opensource-server.node-id'; const MANAGER_CONTAINER_LABEL = 'org.mieweb.opensource-server.container-id'; +function isValidDockerHost(host) { + if (typeof host !== 'string' || host.trim() === '') return false; + + try { + const url = new URL(host.trim()); + + if (url.protocol === 'unix:') { + return !!url.pathname && url.pathname !== '/'; + } + + if (url.protocol === 'tcp:') { + return !!url.hostname && !!url.port && (url.pathname === '' || url.pathname === '/'); + } + + if (url.protocol === 'http:' || url.protocol === 'https:') { + return !!url.hostname && (url.pathname === '' || url.pathname === '/'); + } + + return false; + } catch (err) { + return false; + } +} + function parseDockerHost(host) { - const raw = host || process.env.DOCKER_HOST || 'unix:///var/run/docker.sock'; + if (!isValidDockerHost(host)) { + throw new Error( + 'Invalid Docker host. Supported formats: unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376', + ); + } + + const raw = host.trim(); + const url = new URL(raw); - if (raw.startsWith('unix://')) { - const url = new URL(raw); + if (url.protocol === 'unix:') { return { baseURL: 'http://docker', socketPath: decodeURIComponent(url.pathname), }; } - if (raw.startsWith('tcp://')) { - const url = new URL(raw); + if (url.protocol === 'tcp:') { return { baseURL: `http://${url.host}`, }; } - if (raw.startsWith('http://') || raw.startsWith('https://')) { - return { - baseURL: raw.replace(/\/$/, ''), - }; - } - - throw new Error( - `Unsupported Docker host "${raw}". Supported formats: unix://, tcp://, http://, https://`, - ); + return { + baseURL: raw.replace(/\/$/, ''), + }; } function parseEnvString(envStr) { @@ -451,4 +474,5 @@ class DockerApi { } } -module.exports = DockerApi; \ No newline at end of file +module.exports = DockerApi; +module.exports.isValidDockerHost = isValidDockerHost; From 9afd241dbcce12e1d39dbd5d8e8bce6cb83fcc7f Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 14:52:45 -0400 Subject: [PATCH 07/16] fix(nodes): reject invalid Docker hosts --- create-a-container/routers/api/v1/nodes.js | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/create-a-container/routers/api/v1/nodes.js b/create-a-container/routers/api/v1/nodes.js index fb5aaeb0..888e8ef0 100644 --- a/create-a-container/routers/api/v1/nodes.js +++ b/create-a-container/routers/api/v1/nodes.js @@ -5,6 +5,7 @@ const express = require('express'); const https = require('https'); const { Node, Site, Container } = require('../../../models'); +const { isValidDockerHost } = require('../../../utils/docker-api'); const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } = require('../../../middlewares/api'); @@ -36,6 +37,28 @@ async function loadSite(req) { return site; } +function normalizeNodeType(nodeType) { + return nodeType || 'proxmox'; +} + +function validateNodeInput({ nodeType, apiUrl }) { + const type = normalizeNodeType(nodeType); + + if (!['proxmox', 'docker', 'dummy'].includes(type)) { + throw new ApiError(400, 'invalid_node_type', 'Node type must be proxmox, docker, or dummy'); + } + + if (type === 'docker' && (!apiUrl || !isValidDockerHost(apiUrl))) { + throw new ApiError( + 400, + 'invalid_docker_host', + 'Docker host must use unix://, tcp://, http://, or https:// without extra path components', + ); + } + + return type; +} + router.get( '/', asyncHandler(async (req, res) => { @@ -83,9 +106,10 @@ router.post( const site = await loadSite(req); const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; + const type = validateNodeInput({ nodeType, apiUrl }); const node = await Node.create({ name, - nodeType: nodeType || 'proxmox', + nodeType: type, ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, @@ -113,9 +137,13 @@ router.put( if (!node) throw new ApiError(404, 'not_found', 'Node not found'); const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; + const type = validateNodeInput({ + nodeType: nodeType || node.nodeType, + apiUrl, + }); const update = { name, - nodeType: nodeType || node.nodeType || 'proxmox', + nodeType: type, ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, From c28a68940015e8cb93118ef26ccd39a49fe03d58 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:06:40 -0400 Subject: [PATCH 08/16] fix(docker): remove invalid node fallbacks --- create-a-container/utils/docker-api.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 86d5e0c6..dd8a5a6d 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -94,6 +94,14 @@ class DockerApi { throw new Error('DockerApi requires node.apiUrl'); } + if (!node.id) { + throw new Error('DockerApi requires node.id'); + } + + if (!node.name) { + throw new Error('DockerApi requires node.name'); + } + this.node = node; const dockerConfig = parseDockerHost(node.apiUrl); @@ -118,7 +126,7 @@ class DockerApi { labels(vmid) { return { - [MANAGER_NODE_LABEL]: String(this.node.id || this.node.name || 'docker'), + [MANAGER_NODE_LABEL]: String(this.node.id), [MANAGER_CONTAINER_LABEL]: String(vmid), }; } @@ -126,7 +134,7 @@ class DockerApi { labelFilters(extra = {}) { return JSON.stringify({ label: [ - `${MANAGER_NODE_LABEL}=${String(this.node.id || this.node.name || 'docker')}`, + `${MANAGER_NODE_LABEL}=${String(this.node.id)}`, ...Object.entries(extra).map(([k, v]) => `${k}=${v}`), ], }); From eb4601ac9e1ec2d8feb057058c4e5a1cf1f0103a Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:14:28 -0400 Subject: [PATCH 09/16] feat(docker): list swarm nodes when available --- create-a-container/utils/docker-api.js | 28 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index dd8a5a6d..cc15bed7 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -181,14 +181,28 @@ class DockerApi { } async nodes() { - const info = await this.request('get', '/info'); - return [ - { - node: this.node.name || info.Name || 'docker', - status: 'online', + try { + const swarmNodes = await this.request('get', '/nodes'); + return swarmNodes.map((n) => ({ + node: n.Description?.Hostname || n.ID, + status: n.Status?.State === 'ready' ? 'online' : n.Status?.State || 'unknown', type: 'node', - }, - ]; + })); + } catch (err) { + if (err.response?.status && err.response.status !== 404 && err.response.status !== 503) { + throw err; + } + + const info = await this.request('get', '/info'); + return [ + { + node: this.node.name, + status: 'online', + type: 'node', + id: info.ID, + }, + ]; + } } async nodeNetwork() { From ff8f675f42958696befb0c8200ede02898507f22 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:20:41 -0400 Subject: [PATCH 10/16] feat(docker): read swarm node network info --- create-a-container/utils/docker-api.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index cc15bed7..c70d572c 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -205,8 +205,29 @@ class DockerApi { } } - async nodeNetwork() { - return []; + async nodeNetwork(nodeName) { + try { + const swarmNodes = await this.request('get', '/nodes'); + const swarmNode = swarmNodes.find((n) => n.Description?.Hostname === nodeName || n.ID === nodeName); + + if (!swarmNode) return []; + + const inspected = await this.request('get', `/nodes/${swarmNode.ID}`); + const address = inspected.Status?.Addr || null; + + return address + ? [ + { + iface: 'docker-swarm', + type: 'docker', + active: true, + address, + }, + ] + : []; + } catch (err) { + return []; + } } async clusterResources(type = null) { From 5e429326e31a7cbe60d39d62b3db4a20156bb847 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:24:48 -0400 Subject: [PATCH 11/16] docs(docker): explain image cache handling --- create-a-container/utils/docker-api.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index c70d572c..ebac11c6 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -265,6 +265,9 @@ class DockerApi { } async storageContents() { + // Docker images are cached by Docker Engine itself. The create-container job + // uses storageContents() to skip Proxmox OCI pulls, but Docker's image create + // endpoint already reuses local images/layers when available. return []; } From b652af30ef4d234ab80a6ec7be767b368fac5664 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:30:57 -0400 Subject: [PATCH 12/16] fix(docker): allow cached images during create --- create-a-container/utils/docker-api.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index ebac11c6..45829e04 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -292,15 +292,17 @@ class DockerApi { } async createLxc(node, options = {}) { - if (!this.lastPulledImage) { - throw new Error('No Docker image has been pulled for this create operation'); + const image = this.lastPulledImage || options.reference || options.ostemplate; + + if (!image) { + throw new Error('Docker image reference is required'); } const vmid = options.vmid; const name = options.hostname || `manager-${vmid}`; const body = { - Image: this.lastPulledImage, + Image: image, Hostname: name, Labels: this.labels(vmid), Env: [], From e3e946542c6ec297a5a6cefcdb06a2c88ac6f3ec Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:42:26 -0400 Subject: [PATCH 13/16] fix(docker): support system containers --- create-a-container/utils/docker-api.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 45829e04..5228ecab 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -88,6 +88,22 @@ function task(kind, id = '') { return `docker:${kind}:${id}`; } +function isSystemContainer(options = {}) { + const entrypoint = Array.isArray(options.entrypoint) + ? options.entrypoint.join(' ') + : options.entrypoint || ''; + + const image = options.ostemplate || options.reference || ''; + + return ( + entrypoint.includes('/sbin/init') || + entrypoint.includes('systemd') || + image.includes('/base:') || + image.includes('/base@') || + image.endsWith('/base') + ); +} + class DockerApi { constructor(node = {}) { if (!node.apiUrl) { @@ -311,6 +327,16 @@ class DockerApi { }, }; + if (isSystemContainer(options)) { + body.HostConfig.Privileged = true; + body.HostConfig.CgroupnsMode = 'host'; + body.HostConfig.Tmpfs = { + '/run': 'rw,nosuid,nodev,mode=755', + '/run/lock': 'rw,nosuid,nodev,noexec,mode=755', + '/tmp': 'rw,nosuid,nodev', + }; + } + if (options.memory) { body.HostConfig.Memory = Number(options.memory) * 1024 * 1024; } From 4237179a79565c246965b0fb252cd76aff5868a3 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:45:36 -0400 Subject: [PATCH 14/16] docs(docker): explain task wait no-op --- create-a-container/utils/docker-api.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 5228ecab..973ae63e 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -493,6 +493,9 @@ class DockerApi { } async waitForTask() { + // Docker Engine API calls used here either complete synchronously or stream + // until completion before returning. There is no Proxmox-style background UPID + // to poll, so this satisfies the shared NodeApi task contract as a no-op. return { status: 'stopped', exitstatus: 'OK' }; } From dcde5032c7b98e175f530d2b830dac9a37074397 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Mon, 13 Jul 2026 15:49:38 -0400 Subject: [PATCH 15/16] fix(docker): reuse network inspect result --- create-a-container/utils/docker-api.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js index 973ae63e..43b3cca4 100644 --- a/create-a-container/utils/docker-api.js +++ b/create-a-container/utils/docker-api.js @@ -536,9 +536,12 @@ class DockerApi { } async getLxcNetworkInfo(node, vmid) { + const interfaces = await this.lxcInterfaces(node, vmid); + const eth0 = interfaces[0]; + return { - macAddress: await this.getLxcMacAddress(node, vmid), - ipv4Address: await this.getLxcIpAddress(node, vmid), + macAddress: eth0?.hwaddr || null, + ipv4Address: eth0?.['ip-addresses']?.[0]?.['ip-address'] || null, }; } From 593a4ebad17b5ed767651d7f6e7da6660058acd1 Mon Sep 17 00:00:00 2001 From: Arsh Sandhu Date: Wed, 15 Jul 2026 14:35:57 -0400 Subject: [PATCH 16/16] fix(nodes): remove node form formatting noise --- .../client/src/pages/nodes/NodeFormPage.tsx | 149 ++++++++---------- 1 file changed, 65 insertions(+), 84 deletions(-) diff --git a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx index 1c3a3b1a..07e0d6ef 100644 --- a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; -import { useNavigate, useParams } from "react-router"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Alert, AlertDescription, @@ -12,24 +12,24 @@ import { Spinner, Switch, useToast, -} from "@mieweb/ui"; -import { Server } from "lucide-react"; -import { api, ApiError } from "@/lib/api"; -import { keys, queries } from "@/lib/queries"; -import { FormPageLayout } from "@/components/FormPageLayout"; -import type { Node } from "@/lib/types"; +} from '@mieweb/ui'; +import { Server } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; +import type { Node } from '@/lib/types'; const schema = z.object({ - name: z.string().min(1, "Required"), - nodeType: z.enum(["proxmox", "dummy", "docker"]).default("proxmox"), + name: z.string().min(1, 'Required'), + nodeType: z.enum(['proxmox', 'dummy', 'docker']).default('proxmox'), ipv4Address: z.string().optional(), apiUrl: z.string().optional(), tokenId: z.string().optional(), secret: z.string().optional(), tlsVerify: z.boolean().optional(), - imageStorage: z.string().min(1, "Required"), - volumeStorage: z.string().min(1, "Required"), - networkBridge: z.string().min(1, "Required"), + imageStorage: z.string().min(1, 'Required'), + volumeStorage: z.string().min(1, 'Required'), + networkBridge: z.string().min(1, 'Required'), nvidiaAvailable: z.boolean().optional(), }); type FormData = z.infer; @@ -42,36 +42,38 @@ export function NodeFormPage() { const toast = useToast(); const { data: node, isLoading } = useQuery({ - queryKey: keys.node(siteId!, id ?? "new"), + queryKey: keys.node(siteId!, id ?? 'new'), queryFn: () => queries.getNode(siteId!, id!), enabled: isEdit, }); - const { register, handleSubmit, reset, watch, setValue, formState } = - useForm({ - resolver: zodResolver(schema), - defaultValues: { - nodeType: "proxmox", - tlsVerify: true, - nvidiaAvailable: false, - imageStorage: "local", - volumeStorage: "local-lvm", - networkBridge: "vmbr0", - }, - }); - const nodeType = watch("nodeType"); - const tlsVerify = watch("tlsVerify"); - const nvidiaAvailable = watch("nvidiaAvailable"); + const { register, handleSubmit, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + nodeType: 'proxmox', + ipv4Address: '', + apiUrl: '', + tlsVerify: true, + nvidiaAvailable: false, + imageStorage: 'local', + volumeStorage: 'local-lvm', + networkBridge: 'vmbr0', + }, + }); + const tlsVerify = watch('tlsVerify'); + const nvidiaAvailable = watch('nvidiaAvailable'); + const nodeType = watch('nodeType'); useEffect(() => { if (node) { reset({ name: node.name, - nodeType: node.nodeType || "proxmox", - ipv4Address: node.ipv4Address || "", - apiUrl: node.apiUrl || "", - tokenId: node.tokenId || "", - secret: "", + nodeType: node.nodeType || 'proxmox', + ipv4Address: node.ipv4Address || '', + apiUrl: node.apiUrl || '', + tokenId: node.tokenId || '', + secret: '', tlsVerify: node.tlsVerify ?? true, imageStorage: node.imageStorage, volumeStorage: node.volumeStorage, @@ -90,7 +92,7 @@ export function NodeFormPage() { : api.post(`/api/v1/sites/${siteId}/nodes`, payload); }, onSuccess: () => { - toast.success(isEdit ? "Node updated" : "Node created"); + toast.success(isEdit ? 'Node updated' : 'Node created'); qc.invalidateQueries({ queryKey: keys.nodes(siteId!) }); navigate(`/sites/${siteId}/nodes`); }, @@ -109,13 +111,13 @@ export function NodeFormPage() { mutation.mutate(v))} noValidate> } - title={isEdit ? "Edit node" : "New node"} + title={isEdit ? 'Edit node' : 'New node'} subtitle={ isEdit - ? "Update node connection details and storage settings." - : "Register a Proxmox, Docker, or dummy node." + ? 'Update node connection details and storage settings.' + : 'Register a node with connection details and default storage.' } - backTo={{ label: "Back to nodes", to: `/sites/${siteId}/nodes` }} + backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }} maxWidth="3xl" actions={ <> @@ -126,12 +128,8 @@ export function NodeFormPage() { > Cancel - } @@ -142,14 +140,11 @@ export function NodeFormPage() { placeholder="pve-01" error={formState.errors.name?.message} hasError={!!formState.errors.name} - {...register("name")} + {...register('name')} />
- @@ -160,20 +155,20 @@ export function NodeFormPage() { placeholder="10.0.0.1" inputMode="numeric" autoComplete="off" - {...register("ipv4Address")} + {...register('ipv4Address')} /> - {nodeType === "proxmox" && ( + {nodeType === 'proxmox' && ( <> setValue("tlsVerify", c)} + onCheckedChange={(c) => setValue('tlsVerify', c)} /> )}
- - - + + +
setValue("nvidiaAvailable", c)} + onCheckedChange={(c) => setValue('nvidiaAvailable', c)} /> {mutation.error && ( - - {(mutation.error as ApiError).message} - + {(mutation.error as ApiError).message} )}