import { Component, Show, createSignal, createEffect } from 'solid-js'; import { Portal } from 'solid-js/web'; import type { NodeConfig } from '@/types/nodes'; import type { SecurityStatus } from '@/types/config'; import { copyToClipboard } from '@/utils/clipboard'; import { showSuccess, showError } from '@/utils/toast'; import { NodesAPI } from '@/api/nodes'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { formField, formHelpText, controlClass, labelClass, formCheckbox, } from '@/components/shared/Form'; interface NodeModalProps { isOpen: boolean; resetKey?: number; onClose: () => void; nodeType: 'pve' | 'pbs' | 'pmg'; editingNode?: NodeConfig; onSave: (nodeData: Partial) => void; showBackToDiscovery?: boolean; onBackToDiscovery?: () => void; securityStatus?: Partial; } export const NodeModal: Component = (props) => { const [testResult, setTestResult] = createSignal<{ status: string; message: string; isCluster?: boolean; } | null>(null); const [isTesting, setIsTesting] = createSignal(false); // Function to get clean form data const getCleanFormData = (nodeType: 'pve' | 'pbs' | 'pmg' = props.nodeType) => ({ name: '', host: '', authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'), setupMode: 'auto' as 'auto' | 'manual', user: '', password: '', tokenName: '', tokenValue: '', fingerprint: '', verifySSL: true, monitorPhysicalDisks: false, monitorMailStats: true, monitorQueues: true, monitorQuarantine: true, monitorDomainStats: false, }); const [formData, setFormData] = createSignal(getCleanFormData()); const [quickSetupCommand, setQuickSetupCommand] = createSignal(''); // Track previous state to detect changes let previousResetKey: number | undefined = undefined; let previousNodeType: string | undefined = undefined; // Reset form when conditions change createEffect(() => { const key = props.resetKey; const nodeType = props.nodeType; const isOpen = props.isOpen; const editingNode = props.editingNode; // Force reset if resetKey changed if (key !== undefined && key !== previousResetKey) { previousResetKey = key; setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); setTestResult(null); return; } // Force reset if node type changed if (nodeType !== previousNodeType && previousNodeType !== undefined) { previousNodeType = nodeType; setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); setTestResult(null); return; } previousNodeType = nodeType; // Reset when opening for new node if (isOpen && !editingNode) { setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); setTestResult(null); } }); // Generate setup URL when Quick Setup tab is shown // Skip auto-generation to avoid race conditions - generate on-demand when copy is clicked // Update form when editing node changes createEffect(() => { // Only populate form if we have an editing node AND it matches the current node type // This prevents PVE data from being used when adding a PBS node if (props.editingNode && props.editingNode.type === props.nodeType) { const node = props.editingNode; let username = ('user' in node ? node.user : '') || ''; let tokenName = node.tokenName || ''; const usesToken = node.type !== 'pve' && tokenName && tokenName.includes('!') && !node.hasPassword; if (usesToken) { const parts = tokenName.split('!'); username = parts[0]; } const pmgConfig = node.type === 'pmg' ? (node as NodeConfig & { monitorMailStats?: boolean; monitorQueues?: boolean; monitorQuarantine?: boolean; monitorDomainStats?: boolean; }) : undefined; setFormData({ name: node.name || '', host: node.host || '', authType: node.hasPassword ? 'password' : 'token', setupMode: 'auto', user: username, password: '', tokenName: tokenName, tokenValue: '', fingerprint: ('fingerprint' in node ? node.fingerprint : '') || '', verifySSL: node.verifySSL ?? true, monitorPhysicalDisks: node.type === 'pve' ? (node as NodeConfig & { monitorPhysicalDisks?: boolean }).monitorPhysicalDisks ?? false : false, monitorMailStats: pmgConfig?.monitorMailStats ?? true, monitorQueues: pmgConfig?.monitorQueues ?? true, monitorQuarantine: pmgConfig?.monitorQuarantine ?? true, monitorDomainStats: pmgConfig?.monitorDomainStats ?? false, }); } }); const handleSubmit = (e: Event) => { e.preventDefault(); const data = formData(); // Prepare data based on auth type const nodeData: Partial = { type: props.nodeType, name: data.name || '', // Will be auto-generated by backend if empty host: data.host, fingerprint: data.fingerprint, verifySSL: data.verifySSL, }; if (data.authType === 'password') { nodeData.user = data.user; if (data.password) { nodeData.password = data.password; } } else { // For token auth, tokenName should already contain the full token ID nodeData.tokenName = data.tokenName; if (data.tokenValue) { nodeData.tokenValue = data.tokenValue; } } // Add monitor settings based on type if (props.nodeType === 'pve') { Object.assign(nodeData, { monitorVMs: true, monitorContainers: true, monitorStorage: true, monitorBackups: true, monitorPhysicalDisks: data.monitorPhysicalDisks, }); } else if (props.nodeType === 'pbs') { Object.assign(nodeData, { monitorDatastores: true, monitorSyncJobs: true, monitorVerifyJobs: true, monitorPruneJobs: true, monitorGarbageJobs: true, }); } else { Object.assign(nodeData, { monitorMailStats: data.monitorMailStats, monitorQueues: data.monitorQueues, monitorQuarantine: data.monitorQuarantine, monitorDomainStats: data.monitorDomainStats, }); } props.onSave(nodeData); }; const updateField = (field: string, value: string | boolean) => { setFormData((prev) => ({ ...prev, [field]: value })); if (field === 'host') { setQuickSetupCommand(''); } if (field === 'setupMode' && value !== 'auto') { setQuickSetupCommand(''); } }; const handleTestConnection = async () => { const data = formData(); // If editing an existing node and no new credentials provided, use stored credentials if (props.editingNode) { const hasNewPassword = data.authType === 'password' && data.password; const hasNewToken = data.authType === 'token' && data.tokenValue; if (!hasNewPassword && !hasNewToken) { // Use the existing node test endpoint which uses stored credentials setIsTesting(true); setTestResult(null); try { const result = await NodesAPI.testExistingNode(props.editingNode.id); setTestResult({ status: 'success', message: result.message || 'Connection successful', }); } catch (error) { console.error('Test existing node error:', error); let errorMessage = 'Connection failed'; if (error instanceof Error) { // Remove "API request failed: XXX " prefix if present errorMessage = error.message.replace(/^API request failed: \d{3}\s*/, ''); } setTestResult({ status: 'error', message: errorMessage, }); } finally { setIsTesting(false); } return; } } // Validate required fields for new nodes or when new credentials are provided if (!data.host) { setTestResult({ status: 'error', message: 'Host is required' }); return; } if (data.authType === 'password' && (!data.user || !data.password)) { setTestResult({ status: 'error', message: 'Username and password are required' }); return; } if (data.authType === 'token' && (!data.tokenName || !data.tokenValue)) { setTestResult({ status: 'error', message: 'Token ID and token value are required' }); return; } // Prepare test data const testData: Partial = { type: props.nodeType, name: data.name || '', // Will be auto-generated by backend if empty host: data.host, fingerprint: data.fingerprint, verifySSL: data.verifySSL, }; if (data.authType === 'password') { testData.user = data.user; testData.password = data.password; } else { // For token auth, tokenName contains the full token ID testData.tokenName = data.tokenName; testData.tokenValue = data.tokenValue; } setIsTesting(true); setTestResult(null); try { const result = await NodesAPI.testConnection(testData as NodeConfig); setTestResult({ status: 'success', message: result.message || 'Connection successful', isCluster: result.isCluster, }); } catch (error) { console.error('Test connection error:', error); let errorMessage = 'Connection failed'; if (error instanceof Error) { // Remove "API request failed: XXX " prefix if present errorMessage = error.message.replace(/^API request failed: \d{3}\s*/, ''); } setTestResult({ status: 'error', message: errorMessage, }); } finally { setIsTesting(false); } }; const nodeProductName = () => { switch (props.nodeType) { case 'pve': return 'Proxmox VE'; case 'pbs': return 'Proxmox Backup Server'; default: return 'Proxmox Mail Gateway'; } }; return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}
{/* Body */}
{/* Basic Information */}
updateField('name', e.currentTarget.value)} placeholder="Will auto-detect from hostname" class={controlClass()} />
updateField('host', e.currentTarget.value)} placeholder={ props.nodeType === 'pve' ? 'https://proxmox.example.com:8006' : props.nodeType === 'pbs' ? 'https://backup.example.com:8007' : 'https://mail-gateway.example.com:8006' } required class={controlClass()} />

PBS requires HTTPS (not HTTP). Default port is 8007.

PMG API listens on HTTPS. Default port is 8006.

{/* Authentication */}
{/* Auth Type Selector */}

Proxmox Mail Gateway does not support API tokens. Use a service account with password authentication (for example root@pam or a dedicated{' '} api@pmg user).

{/* Password Auth Fields */}
updateField('user', e.currentTarget.value)} placeholder={ props.nodeType === 'pve' ? 'root@pam' : props.nodeType === 'pbs' ? 'admin@pbs' : 'root@pam' } required={formData().authType === 'password'} class={controlClass()} />

Must include realm (e.g., admin@pbs).

Include realm (e.g., root@pam or api@pmg).

updateField('password', e.currentTarget.value)} placeholder={ props.editingNode ? 'Leave blank to keep existing' : 'Password' } required={formData().authType === 'password' && !props.editingNode} class={controlClass()} />
{/* Token Auth Fields */}
{/* Token Creation Guide */}
Quick Token Setup
{/* Tab buttons */}
{/* Quick Setup Tab */}

The command below creates the monitoring user, applies read-only access, and adds the storage permissions Pulse needs to display backups.

Just copy and run this one command on your Proxmox VE server:

{/* One-line command */}
0} fallback={ {formData().host ? 'Click the button above to copy the setup command' : '⚠️ Please enter the Host URL above first'} } > {quickSetupCommand()}

If the command doesn't work:

Your Proxmox server may not be able to reach Pulse. Use the alternative method below.

{/* Alternative: Download script */}
Alternative: Download script manually
1. Click to download the script
2. Upload to your server via SCP/SFTP
3. Run:{' '} bash pulse-setup.sh

What this does:

  • Creates monitoring user{' '} pulse-monitor@pam
  • Generates secure API token
  • Sets up monitoring permissions (PVEAuditor + guest agent access + backup visibility)
  • Automatically registers node with Pulse

✨ Fully automatic - no manual token copying needed!

{/* Manual Setup Tab */}

Run these commands one by one on your Proxmox VE server:

{/* Step 1: Create user */}

1. Create monitoring user:

pveum user add pulse-monitor@pam --comment "Pulse monitoring service"
{/* Step 2: Generate token */}

2. Generate API token (save the output!):

pveum user token add pulse-monitor@pam pulse-token --privsep 0

⚠️ Copy the token value immediately - it won't be shown again!

{/* Step 3: Set permissions */}

3. Set up monitoring permissions:

{ '# Apply monitoring permissions - use built-in PVEAuditor role\npveum aclmod / -user pulse-monitor@pam -role PVEAuditor\n\n# Gather additional privileges for VM metrics\nEXTRA_PRIVS=()\n\n# Sys.Audit (Ceph, cluster status)\nif pveum role list 2>/dev/null | grep -q "Sys.Audit"; then\n EXTRA_PRIVS+=(\"Sys.Audit\")\nelse\n if pveum role add PulseTmpSysAudit -privs Sys.Audit 2>/dev/null; then\n EXTRA_PRIVS+=(\"Sys.Audit\")\n pveum role delete PulseTmpSysAudit 2>/dev/null\n fi\nfi\n\n# VM guest agent / monitor privileges\nVM_PRIV=\"\"\nif pveum role list 2>/dev/null | grep -q "VM.Monitor"; then\n VM_PRIV=\"VM.Monitor\"\nelif pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then\n VM_PRIV=\"VM.GuestAgent.Audit\"\nelse\n if pveum role add PulseTmpVMMonitor -privs VM.Monitor 2>/dev/null; then\n VM_PRIV=\"VM.Monitor\"\n pveum role delete PulseTmpVMMonitor 2>/dev/null\n elif pveum role add PulseTmpGuestAudit -privs VM.GuestAgent.Audit 2>/dev/null; then\n VM_PRIV=\"VM.GuestAgent.Audit\"\n pveum role delete PulseTmpGuestAudit 2>/dev/null\n fi\nfi\n\nif [ -n \"$VM_PRIV\" ]; then\n EXTRA_PRIVS+=(\"$VM_PRIV\")\nfi\n\nif [ ${#EXTRA_PRIVS[@]} -gt 0 ]; then\n PRIV_STRING=\"${EXTRA_PRIVS[*]}\"\n pveum role delete PulseMonitor 2>/dev/null\n pveum role add PulseMonitor -privs \"$PRIV_STRING\"\n pveum aclmod / -user pulse-monitor@pam -role PulseMonitor\nfi' }
pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin

ℹ️ PVEAuditor gives read-only API access. PulseMonitor adds Sys.Audit plus either VM.Monitor (PVE 8) or VM.GuestAgent.Audit (PVE 9+) for disk and guest metrics. PVEDatastoreAdmin on /storage adds backup visibility.

{/* Step 4: Use in Pulse */}

4. Add to Pulse with:

  • Token ID: pulse-monitor@pam!pulse-token
  • Token Value: [The value from step 2]
  • Host URL:{' '} {formData().host || 'https://your-server:8006'}
{/* Tab buttons for PBS */}
{/* Quick Setup Tab for PBS */}

Just copy and run this one command on your Proxmox Backup Server:

{/* One-line command */}
0} fallback={ {formData().host ? 'Click the button above to copy the setup command' : '⚠️ Please enter the Host URL above first'} } > {quickSetupCommand()}

If the command doesn't work:

Your PBS server may not be able to reach Pulse. Use the alternative method below.

{/* Alternative: Download script */}
Alternative: Download script manually
1. Click to download the script
2. Upload to your PBS via SCP/SFTP
3. Run:{' '} bash pulse-pbs-setup.sh

What this does:

  • Creates monitoring user{' '} pulse-monitor@pbs
  • Generates secure API token
  • Sets up Audit permissions (read-only access to backups + system stats)
  • Automatically registers server with Pulse

✨ Fully automatic - no manual token copying needed!

{/* Manual Setup Tab for PBS */}

Run these commands one by one on your Proxmox Backup Server:

{/* Step 1: Create user */}

1. Create monitoring user:

proxmox-backup-manager user create pulse-monitor@pbs
{/* Step 2: Generate token */}

2. Generate API token (save the output!):

proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token

⚠️ Copy the token value immediately - it won't be shown again!

{/* Step 3: Set permissions */}

3. Set up read-only permissions (includes system stats):

proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'
{/* Step 4: Use in Pulse */}

4. Add to Pulse with:

  • Token ID: pulse-monitor@pbs!pulse-token
  • Token Value: [The value from step 2]
  • Host URL:{' '} {formData().host || 'https://your-server:8007'}
{/* Permission Info Box */}

About PBS Permissions:

  • Basic (DatastoreAudit): View backups only
  • Enhanced (Audit on /): View backups + CPU/memory/uptime stats
  • → We use Enhanced for better monitoring visibility

Generate a dedicated API token in Configuration → API Tokens on your Mail Gateway. We recommend creating a service user such as pulse-monitor@pmg with Auditor privileges.

  1. Click Add and choose the service user (or create one if needed).
  2. Enable Privilege Separation and assign the Auditor role.
  3. Copy the generated Token ID (e.g. pulse-monitor@pmg!pulse-edge) and the secret value into the fields below.

Pulse only requires read-only access. Avoid granting administrator permissions to the token.

{/* Token Input Fields */}
updateField('tokenName', e.currentTarget.value)} placeholder={ props.nodeType === 'pve' ? 'pulse-monitor@pam!pulse-token' : 'pulse-monitor@pbs!pulse-token' } required={formData().authType === 'token'} class={controlClass('font-mono')} />

Full token ID from Proxmox (user@realm!tokenname).

updateField('tokenValue', e.currentTarget.value)} placeholder={ props.editingNode ? 'Leave blank to keep existing' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' } required={formData().authType === 'token' && !props.editingNode} class={controlClass('font-mono')} />

The secret value shown when creating the token.

{/* SSL Settings */}
updateField('fingerprint', e.currentTarget.value)} placeholder="AA:BB:CC:DD:EE:FF:..." class={controlClass('font-mono')} />

Useful when connecting to servers with self-signed certificates.

{/* Monitoring Overview */}

{props.nodeType === 'pmg' ? 'Pulse captures mail flow analytics, rejection causes, and quarantine visibility without additional scripts.' : 'Pulse automatically tracks all supported resources for this node — virtual machines, containers, storage usage, backups, and PBS job activity — so you always get full visibility without extra configuration.'}

{/* Physical Disk Monitoring - PVE only */}

Control which PMG data sets Pulse ingests. Disable individual collectors if you want to limit API usage.

{/* Test Result */} {(() => { const result = testResult(); console.log('Test result display:', { status: result?.status, message: result?.message, }); return null; })()}

{testResult()?.message}

✨ Cluster detected! All cluster nodes will be automatically added.

{/* Footer */}
); };