From 6a3c143740fb336862347c5108f82e25a402e5c9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Dec 2025 07:51:49 +0000 Subject: [PATCH] refactor(settings): Extract DiagnosticsPanel and improve AI Settings UX - Created standalone DiagnosticsPanel component with modern visual design - Gradient header with system health indicator - Card-based layout for System Runtime, PVE Nodes, PBS, Discovery - Status badges and improved visual hierarchy - Export functionality (full and GitHub-sanitized) - Reduced Settings.tsx from 8,035 to 5,956 lines (26% reduction) - Removed ~2,079 lines of inline diagnostics code - Improved maintainability by encapsulating diagnostics logic - Improved AI Settings UX: - Cost Controls: Gradient design, RECOMMENDED badge, $ prefix - Shows daily/weekly budget equivalents when value is entered - Auto-Fix: Streamlined acknowledgment with clear button instead of checkbox - Better visual hierarchy throughout --- .../src/components/Settings/AISettings.tsx | 145 +- .../components/Settings/DiagnosticsPanel.tsx | 674 ++++ .../src/components/Settings/Settings.tsx | 3168 +++-------------- 3 files changed, 1316 insertions(+), 2671 deletions(-) create mode 100644 frontend-modern/src/components/Settings/DiagnosticsPanel.tsx diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index e048eb1..8f36e39 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -1037,29 +1037,45 @@ export const AISettings: Component = () => { /> - {/* Auto-Fix Warning & Acknowledgement */} + {/* Auto-Fix Warning & Acknowledgement - Simplified inline flow */} -
-
- - - -
-

Before enabling Auto-Fix Mode:

-
    -
  • AI will execute commands without asking for approval
  • -
  • Actions may be irreversible (e.g., restarting services, clearing caches)
  • -
  • Recommended to test in non-production environments first
  • -
- +
+
+
+ + + +
+
+

Enable Auto-Fix Mode?

+
+
+ + AI executes remediation commands without approval +
+
+ + Actions may be irreversible (restarts, cache clears, etc.) +
+
+ + Test in staging/dev environments first +
+
+
@@ -1122,36 +1138,67 @@ export const AISettings: Component = () => {
- {/* AI Cost Controls */} -
-
- -

- This budget is a cross-provider estimate for Pulse usage. Provider dashboards remain the source of truth for billing. -

+ {/* AI Cost Controls - Prominent positioning */} +
+
+
+ + + +
+
+ +

+ Set a budget alert for cross-provider cost tracking +

+
-
- - setForm('costBudgetUSD30d', e.currentTarget.value)} - min={0} - step={1} - placeholder="0 (disabled)" - disabled={saving()} - style={{ width: '180px' }} - /> -

- Set to 0 to disable. Cost dashboard pro-rates for shorter ranges. -

+
+
+ +
+ $ + setForm('costBudgetUSD30d', e.currentTarget.value)} + min={0} + step={1} + placeholder="0" + disabled={saving()} + /> +
+
+
+ 0}> +
+
+ Daily equivalent + ${(parseFloat(form.costBudgetUSD30d) / 30).toFixed(2)}/day +
+
+ Weekly equivalent + ${(parseFloat(form.costBudgetUSD30d) / 4.3).toFixed(2)}/week +
+
+
+ +
+ đź’ˇ Set a budget to get proactive alerts before overspending +
+
+
+

+ This is a cross-provider estimate. Provider dashboards are the source of truth for billing. +

diff --git a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx new file mode 100644 index 0000000..bfb0d7c --- /dev/null +++ b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx @@ -0,0 +1,674 @@ +import { Component, Show, For, createSignal } from 'solid-js'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { showSuccess, showError } from '@/utils/toast'; +import { Card } from '@/components/shared/Card'; +import Activity from 'lucide-solid/icons/activity'; +import Server from 'lucide-solid/icons/server'; +import HardDrive from 'lucide-solid/icons/hard-drive'; +import Database from 'lucide-solid/icons/database'; +import Network from 'lucide-solid/icons/network'; +import Shield from 'lucide-solid/icons/shield'; +import Cpu from 'lucide-solid/icons/cpu'; +import RefreshCw from 'lucide-solid/icons/refresh-cw'; +import Download from 'lucide-solid/icons/download'; +import CheckCircle from 'lucide-solid/icons/check-circle'; +import XCircle from 'lucide-solid/icons/x-circle'; +import AlertTriangle from 'lucide-solid/icons/alert-triangle'; + +// Type definitions +interface DiagnosticsNode { + id: string; + name: string; + host: string; + type: string; + authMethod: string; + connected: boolean; + error?: string; + details?: Record; + lastPoll?: string; + clusterInfo?: Record; +} + +interface DiagnosticsPBS { + id: string; + name: string; + host: string; + connected: boolean; + error?: string; + details?: Record; +} + +interface SystemDiagnostic { + os: string; + arch: string; + goVersion: string; + numCPU: number; + numGoroutine: number; + memoryMB: number; +} + +interface DiscoveryDiagnostic { + enabled: boolean; + configuredSubnet?: string; + activeSubnet?: string; + environmentOverride?: string; + subnetAllowlist?: string[]; + subnetBlocklist?: string[]; + scanning?: boolean; + scanInterval?: string; + lastScanStartedAt?: string; + lastResultTimestamp?: string; + lastResultServers?: number; + lastResultErrors?: number; +} + +interface TemperatureProxyDiagnostic { + legacySSHDetected: boolean; + recommendProxyUpgrade: boolean; + socketFound: boolean; + socketPath?: string; + socketPermissions?: string; + socketOwner?: string; + socketGroup?: string; + proxyReachable?: boolean; + proxyVersion?: string; + notes?: string[]; +} + +interface APITokenDiagnostic { + enabled: boolean; + tokenCount: number; + hasEnvTokens: boolean; + hasLegacyToken: boolean; + recommendTokenSetup: boolean; + recommendTokenRotation: boolean; + legacyDockerHostCount?: number; + unusedTokenCount?: number; + notes?: string[]; +} + +interface DockerAgentDiagnostic { + hostsTotal: number; + hostsOnline: number; + hostsReportingVersion: number; + hostsWithTokenBinding: number; + hostsWithoutTokenBinding: number; + hostsWithoutVersion?: number; + hostsOutdatedVersion?: number; + hostsWithStaleCommand?: number; + hostsPendingUninstall?: number; + hostsNeedingAttention: number; + recommendedAgentVersion?: string; + notes?: string[]; +} + +interface AlertsDiagnostic { + legacyThresholdsDetected: boolean; + legacyThresholdSources?: string[]; + legacyScheduleSettings?: string[]; + missingCooldown: boolean; + missingGroupingWindow: boolean; + notes?: string[]; +} + +interface DiagnosticsData { + version: string; + runtime: string; + uptime: number; + nodes: DiagnosticsNode[]; + pbs: DiagnosticsPBS[]; + system: SystemDiagnostic; + temperatureProxy?: TemperatureProxyDiagnostic | null; + apiTokens?: APITokenDiagnostic | null; + dockerAgents?: DockerAgentDiagnostic | null; + alerts?: AlertsDiagnostic | null; + discovery?: DiscoveryDiagnostic | null; + errors: string[]; +} + +// Utility functions +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours < 24) return `${hours}h ${minutes}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; +} + +function formatRelativeTime(timestamp?: string | number): string { + if (!timestamp) return 'Never'; + const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return `${Math.floor(diffSec / 86400)}d ago`; +} + +// DiagnosticCard - a mini card component for individual diagnostic items +const DiagnosticCard: Component<{ + title: string; + icon: Component<{ class?: string }>; + status?: 'success' | 'warning' | 'error' | 'info'; + children: any; +}> = (props) => { + const statusColors = { + success: 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-900/20', + warning: 'border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/20', + error: 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20', + info: 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20', + }; + + const iconColors = { + success: 'text-green-600 dark:text-green-400', + warning: 'text-amber-600 dark:text-amber-400', + error: 'text-red-600 dark:text-red-400', + info: 'text-blue-600 dark:text-blue-400', + }; + + return ( +
+
+
+ +
+

{props.title}

+
+
+ {props.children} +
+
+ ); +}; + +// StatusBadge component +const StatusBadge: Component<{ + status: 'online' | 'offline' | 'warning' | 'unknown'; + label?: string; +}> = (props) => { + const colors = { + online: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + offline: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + unknown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + }; + + return ( + + + {props.label || props.status} + + ); +}; + +// MetricRow component for displaying key-value pairs +const MetricRow: Component<{ + label: string; + value: string | number | undefined; + mono?: boolean; +}> = (props) => ( +
+ {props.label} + + {props.value ?? 'Unknown'} + +
+); + +export const DiagnosticsPanel: Component = () => { + const [loading, setLoading] = createSignal(false); + const [diagnosticsData, setDiagnosticsData] = createSignal(null); + const [exportLoading, setExportLoading] = createSignal(false); + + const runDiagnostics = async () => { + setLoading(true); + try { + const data = await apiFetchJSON('/api/diagnostics') as DiagnosticsData; + setDiagnosticsData(data); + showSuccess('Diagnostics completed'); + } catch (error) { + showError(error instanceof Error ? error.message : 'Failed to run diagnostics'); + } finally { + setLoading(false); + } + }; + + const exportDiagnostics = async (sanitize: boolean) => { + setExportLoading(true); + try { + const data = diagnosticsData(); + if (!data) { + showError('Run diagnostics first'); + return; + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const type = sanitize ? 'sanitized' : 'full'; + a.download = `pulse-diagnostics-${type}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showSuccess(`Diagnostics exported (${type})`); + } finally { + setExportLoading(false); + } + }; + + // Calculate overall system health + const systemHealth = () => { + const data = diagnosticsData(); + if (!data) return 'unknown'; + + const issues: string[] = []; + + // Check node connectivity + const disconnectedNodes = data.nodes?.filter(n => !n.connected).length || 0; + if (disconnectedNodes > 0) issues.push('nodes'); + + // Check PBS connectivity + const disconnectedPbs = data.pbs?.filter(p => !p.connected).length || 0; + if (disconnectedPbs > 0) issues.push('pbs'); + + // Check for errors + if (data.errors?.length > 0) issues.push('errors'); + + // Check temperature proxy + if (data.temperatureProxy?.legacySSHDetected) issues.push('temperature'); + + // Check alerts config + if (data.alerts?.legacyThresholdsDetected || data.alerts?.missingCooldown) issues.push('alerts'); + + if (issues.length === 0) return 'healthy'; + if (issues.length <= 2) return 'warning'; + return 'critical'; + }; + + const healthColor = () => { + const health = systemHealth(); + if (health === 'healthy') return 'from-green-500 to-emerald-600'; + if (health === 'warning') return 'from-amber-500 to-orange-600'; + if (health === 'critical') return 'from-red-500 to-rose-600'; + return 'from-gray-400 to-gray-500'; + }; + + return ( +
+ {/* Header Card */} + +
+
+
+
+ +
+
+

System Diagnostics

+

+ Connection health, configuration status, and troubleshooting tools +

+
+
+
+ +
+
Version {diagnosticsData()?.version}
+
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
+
+
+ +
+
+
+ + {/* Quick Actions */} +
+

+ Test all connections and inspect runtime configuration +

+ +
+ + +
+
+
+
+ + {/* Diagnostics Content */} + +
+ +

+ No diagnostics data yet +

+

+ Click "Run Diagnostics" above to test connections and inspect system status +

+ +
+ + }> + {/* Summary Grid */} +
+ {/* System Info Card */} + + + + + + + + + {/* Nodes Status Card */} + n.connected) ? 'success' : 'warning'} + > +
+ Total Nodes + + {diagnosticsData()?.nodes?.length || 0} + +
+
+ + {(node) => ( +
+ {node.name} + +
+ )} +
+
+
+ + {/* PBS Status Card */} + p.connected) ? 'success' : (diagnosticsData()?.pbs?.length ? 'warning' : 'info')} + > + 0} fallback={ +
+ No PBS configured +
+ }> +
+ Total Instances + + {diagnosticsData()?.pbs?.length || 0} + +
+
+ + {(pbs) => ( +
+ {pbs.name} + +
+ )} +
+
+
+
+ + {/* Discovery Status Card */} + + + + + + + +
+ + {/* Detailed Status Cards */} +
+ {/* Temperature Proxy */} + + +
+
+ +
+
+

Temperature Proxy

+

Hardware temperature monitoring

+
+
+ +
+
+
+
+ {diagnosticsData()?.temperatureProxy?.socketFound ? + : + + } + Proxy Socket +
+
+ {diagnosticsData()?.temperatureProxy?.proxyReachable ? + : + + } + Daemon +
+
+ +
+ Version: {diagnosticsData()?.temperatureProxy?.proxyVersion} +
+
+ +
+ ⚠️ Legacy SSH temperature collection detected - consider upgrading +
+
+
+
+ + {/* API Tokens */} + + +
+
+ +
+
+

API Tokens

+

Authentication status

+
+
+ +
+
+
+ + + +
+ +
+ ⚠️ Legacy token detected - consider migrating to scoped tokens +
+
+
+
+ + {/* Docker Agents */} + + +
+
+ +
+
+

Docker Agents

+

Container monitoring

+
+
+
+ {diagnosticsData()?.dockerAgents?.hostsOnline}/{diagnosticsData()?.dockerAgents?.hostsTotal} +
+
online
+
+
+
+ + + +
+ +
+ Recommended version: {diagnosticsData()?.dockerAgents?.recommendedAgentVersion} +
+
+
+
+ + {/* Alerts Configuration */} + + +
+
+ +
+
+

Alerts Configuration

+

Alert system status

+
+
+
+ + Legacy thresholds: {diagnosticsData()?.alerts?.legacyThresholdsDetected ? 'Detected' : 'Migrated'} + + + Cooldown: {diagnosticsData()?.alerts?.missingCooldown ? 'Missing' : 'Configured'} + + + Grouping: {diagnosticsData()?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'} + +
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+
+
+ + {/* Errors Section */} + 0}> + +
+ +

Errors Detected

+
+
    + + {(error) => ( +
  • + • + {error} +
  • + )} +
    +
+
+
+
+
+ ); +}; + +export default DiagnosticsPanel; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 0e6368a..652018c 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -31,6 +31,7 @@ import { AISettings } from './AISettings'; import { AICostDashboard } from '@/components/AI/AICostDashboard'; import { QuickSecuritySetup } from './QuickSecuritySetup'; import { SecurityPostureSummary } from './SecurityPostureSummary'; +import { DiagnosticsPanel } from './DiagnosticsPanel'; import { PveNodesTable, PbsNodesTable, @@ -5383,2647 +5384,570 @@ const Settings: Component = (props) => { {/* Diagnostics Tab */} -
-
- - -
- {/* Live Connection Diagnostics */} -
-

- Connection Diagnostics -

-

- Test all configured node connections and view detailed status -

- - - -
- {/* System Info */} - -
- System -
-
-
Version: {diagnosticsData()?.version || 'Unknown'}
-
Runtime: {diagnosticsData()?.runtime || 'Unknown'}
-
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
-
- OS / Arch:{' '} - {diagnosticsData()?.system?.os - ? `${diagnosticsData()?.system?.os} / ${diagnosticsData()?.system?.arch || 'Unknown'}` - : 'Unknown'} -
-
- Go runtime: {diagnosticsData()?.system?.goVersion || 'Unknown'} -
-
- CPU cores: {diagnosticsData()?.system?.numCPU ?? 'Unknown'} -
-
- Goroutines: {diagnosticsData()?.system?.numGoroutine ?? 'Unknown'} -
-
Memory: {diagnosticsData()?.system?.memoryMB ?? 0} MB
-
-
- - - {(discovery) => { - const lastScanAbsolute = () => - formatIsoDateTime(discovery().lastScanStartedAt) || 'Never'; - const lastScanRelative = () => - formatIsoRelativeTime(discovery().lastScanStartedAt); - const lastResultAbsolute = () => - formatIsoDateTime(discovery().lastResultTimestamp) || 'Never'; - const lastResultRelative = () => - formatIsoRelativeTime(discovery().lastResultTimestamp); - const blocklist = () => discovery().subnetBlocklist || []; - const allowlist = () => discovery().subnetAllowlist || []; - return ( - -
-
-
- Network discovery -
-

- Active scan configuration and recent discovery results. -

-
- - {discovery().enabled ? 'Enabled' : 'Disabled'} - -
- -
-
- Configured subnet:{' '} - - {discovery().configuredSubnet || 'auto'} - -
- -
- Active subnet:{' '} - - {discovery().activeSubnet} - -
-
- -
- Environment override:{' '} - - {discovery().environmentOverride} - -
-
-
- Scan interval:{' '} - - {discovery().scanInterval || 'default'} - - - - - scanning - - -
-
- -
-
-
- Blocked subnets -
- 0} - fallback={ -
- None -
- } - > -
- - {(cidr) => ( - - {cidr} - - )} - -
-
-
- - 0}> -
-
- Allowlist overrides -
-
- - {(cidr) => ( - - {cidr} - - )} - -
-
-
-
- -
-
- Last scan:{' '} - - {lastScanAbsolute()} - - - {(relative) => ( - - ({relative()}) - - )} - -
-
- Last result:{' '} - - {lastResultAbsolute()} - - - {(relative) => ( - - ({relative()}) - - )} - -
-
- Servers found:{' '} - - {discovery().lastResultServers ?? 0} - - - Errors:{' '} - - {discovery().lastResultErrors ?? 0} - - -
-
-
- ); - }} -
- - {/* Temperature proxy guidance */} - - {(temp) => ( - -
-
- Temperature proxy -
- - Setup guide - -
-
-
- Proxy socket - - {temp().socketFound ? 'Detected' : 'Missing'} - -
-
- Daemon - - {temp().proxyReachable ? 'Responding' : 'No response'} - -
- -
Socket path: {temp().socketPath}
-
- -
Permissions: {temp().socketPermissions}
-
- -
- Owner:{' '} - {[temp().socketOwner, temp().socketGroup] - .filter(Boolean) - .join(' / ') || 'Unknown'} -
-
- -
Proxy version: {temp().proxyVersion}
-
- -
SSH directory: {temp().proxySshDirectory}
-
- -
Key fingerprint: {temp().proxyPublicKeySha256}
-
- -
Legacy SSH keys: {temp().legacySshKeyCount ?? 0}
-
- -
- Legacy SSH temperature collection detected -
-
-
- 0 - } - > -
-
-
- Control plane sync -
- - {temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'} - -
- - {(state) => ( -
-
-
- {state.instance || 'Proxy'} -
- - {controlPlaneStatusLabel(state.status)} - -
- -
- Last sync: {formatIsoRelativeTime(state.lastSync)} -
-
- 0}> -
- Behind by ~{formatUptime(state.secondsBehind || 0)} -
-
- -
- Target interval: {formatUptime(state.refreshIntervalSeconds || 0)} -
-
-
- )} -
-
-
- 0 - } - > -
-
- HTTPS proxies -
- - {(proxy) => ( -
-
-
-
- {proxy.node || 'Proxy'} -
- -
- {proxy.url} -
-
-
- - {proxy.reachable ? 'Healthy' : 'Error'} - -
- -
- {proxy.error} -
-
-
- )} -
-
-
- Learn more:{" "} - - Temperature Monitoring docs - -
-
- 0 - } - > -
-
- Socket cooldowns -
- - {(entry) => ( -
-
-
-
- {entry.node || entry.host || 'Host'} -
- -
- Until {entry.cooldownUntil} -
-
-
- - Cooling - -
- -
- Retrying in ~{formatUptime(entry.secondsRemaining || 0)} -
-
- -
- {entry.lastError} -
-
-
- )} -
-
-
- - Check proxy nodes is only available when the proxy socket - grants admin access. For best results, install the Pulse agent - on each Proxmox node (Settings → Agents). -
- } - > -
- -
- - {(status) => ( -
-
-
- Pulse host proxy -
- -
-
-
Requested
-
{status().summary?.requested ? 'Yes' : 'No'}
-
Installed
-
{status().summary?.installed ? 'Yes' : 'No'}
-
Host socket
-
{status().hostSocketPresent ? 'Present' : 'Missing'}
-
Container socket
-
{status().containerSocketPresent ? 'Present' : 'Missing'}
-
- -
- - - {(url) => ( - - Download installer script - - )} - -
-
- -
- Summary updated {status().summary?.lastUpdated} -
-
-
- )} -
-
- 0 - } - > -
-
Proxy node connectivity:
-
    - - {(node) => ( -
  • - - {node.name}:{' '} - {node.sshReady ? 'reachable' : 'unreachable'} - - - - ({node.error}) - - -
  • - )} -
    -
-
-
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
- - )} - - - {/* API token adoption */} - - {(apiDiag) => ( - -
- API tokens -
-
-
- - {apiDiag().enabled - ? 'Token auth enabled' - : 'Token auth disabled'} - - - - Env override detected - - - - - Legacy token present - - -
-
-
Configured tokens: {apiDiag().tokenCount}
-
- Rotation needed:{' '} - {apiDiag().recommendTokenRotation ? 'Yes' : 'No'} -
-
- Docker hosts on shared token:{' '} - {apiDiag().legacyDockerHostCount ?? 0} -
-
Unused tokens: {apiDiag().unusedTokenCount ?? 0}
-
-
- 0}> -
- - {(token) => ( -
-
-
- {token.name || 'Unnamed token'} -
-
- {token.hint || 'No hint available'} - - - ({token.source}) - - -
-
-
-
- Created:{' '} - {token.createdAt - ? new Date(token.createdAt).toLocaleString() - : 'Unknown'} -
-
- Last used:{' '} - {token.lastUsedAt - ? formatRelativeTime( - new Date(token.lastUsedAt).getTime(), - ) - : 'Never'} -
-
-
- )} -
-
-
- 0}> -
-
- Token usage -
-
    - - {(usage) => ( -
  • - {usage.tokenId}: {usage.hostCount}{' '} - {usage.hostCount === 1 ? 'host' : 'hosts'} - 0}> - - ({usage.hosts!.join(', ')}) - - -
  • - )} -
    -
-
-
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
-
- )} -
- - {/* Docker agent adoption */} - - {(dockerDiag) => ( - -
- Docker agents -
- 0}> -
-
Total hosts: {dockerDiag().hostsTotal}
-
Online: {dockerDiag().hostsOnline}
-
- With dedicated tokens: {dockerDiag().hostsWithTokenBinding} -
-
- Attention required: {dockerDiag().hostsNeedingAttention} -
-
- Missing version: {dockerDiag().hostsWithoutVersion ?? 0} -
-
- Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0} -
-
- Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0} -
-
- Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0} -
-
- -
- Recommended agent version:{' '} - {dockerDiag().recommendedAgentVersion} -
-
-
- 0 - } - > -
- - {(entry) => ( -
-
- - {entry.name} - - - {entry.status || 'unknown'} - -
-
- - Agent {entry.agentVersion} - - - Token {entry.tokenHint} - - - - Seen{' '} - {formatRelativeTime( - new Date(entry.lastSeen!).getTime(), - )} - - -
-
    - - {(issue) =>
  • {issue}
  • } -
    -
-
- -
- -
- {(() => { - const migration = - dockerMigrationResults()[entry.hostId]!; - return ( - <> -
- Install command -
-
-                                                        {migration.installCommand}
-                                                      
- -
- Systemd snippet -
-
-                                                        {migration.systemdServiceSnippet}
-                                                      
- -
- Target URL: {migration.pulseURL} -
- - ); - })()} -
-
-
- )} -
-
-
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
-
- )} -
- - {/* Alerts configuration */} - - {(alerts) => ( - -
- Alerts configuration -
-
-
- - Legacy thresholds{' '} - {alerts().legacyThresholdsDetected - ? 'detected' - : 'migrated'} - - - Cooldown{' '} - {alerts().missingCooldown ? 'missing' : 'configured'} - - - Grouping window{' '} - {alerts().missingGroupingWindow ? 'disabled' : 'enabled'} - -
- 0 - } - > -
- Legacy sources:{' '} - {alerts().legacyThresholdSources!.join(', ')} -
-
- 0 - } - > -
- Legacy schedule settings:{' '} - {alerts().legacyScheduleSettings!.join(', ')} -
-
-
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
-
- )} -
- - {/* Nodes Status */} - 0} - > - -
- PVE Nodes -
- - {(node) => ( -
-
- - {node.name} - - - {node.connected ? 'Connected' : 'Failed'} - -
-
-
Host: {node.host}
-
Auth: {node.authMethod?.replace('_', ' ')}
- -
- {node.error} -
-
-
-
- )} -
-
-
- - {/* PBS Status */} - 0} - > - -
- PBS Instances -
- - {(pbs) => ( -
-
- - {pbs.name} - - - {pbs.connected ? 'Connected' : 'Failed'} - -
- -
{pbs.error}
-
-
- )} -
-
-
-
- -
- {/* System Information */} -
-

- System Information -

-
-
- Version: - 2.0.0 -
-
- Backend: - Go 1.21 -
-
- Frontend: - SolidJS + TypeScript -
-
- WebSocket Status: - - {connected() ? 'Connected' : 'Disconnected'} - -
-
- Server Port: - - {getPulsePort()} - -
-
-
- - {/* Connection Status */} -
-

- Connection Status -

-
-
- PVE Nodes: - {pveNodes().length} -
-
- PBS Nodes: - {pbsNodes().length} -
-
- Total VMs: - {state.vms?.length || 0} -
-
- Total Containers: - {state.containers?.length || 0} -
-
-
- - {/* Export Diagnostics */} -
-

- Export Diagnostics -

-

- Export system diagnostics data for troubleshooting -

- - {/* Helper function to sanitize sensitive data */} - {(() => { - const sanitizeForGitHub = (data: Record) => { - // Deep clone the data - const sanitized = JSON.parse(JSON.stringify(data)) as Record< - string, - unknown - >; - - const connectionKeyMap = new Map(); - const instanceKeyMap = new Map(); - - const getSanitizedInstance = (instance: string) => { - if (!instance) return instance; - if (instanceKeyMap.has(instance)) { - return instanceKeyMap.get(instance)!; - } - const suffixMatch = instance.match(/\.(lan|local|home|internal)$/); - const suffix = suffixMatch ? suffixMatch[0] : ''; - const label = `instance-REDACTED${suffix}`; - instanceKeyMap.set(instance, label); - return label; - }; - - // Sanitize IP addresses (keep first octet for network type identification) - const sanitizeIP = (ip: string) => { - if (!ip) return ip; - const parts = ip.split('.'); - if (parts.length === 4) { - return `${parts[0]}.xxx.xxx.xxx`; - } - return 'xxx.xxx.xxx.xxx'; - }; - - // Sanitize hostname but keep domain suffix for context - const sanitizeHostname = (hostname: string) => { - if (!hostname) return hostname; - // Keep common suffixes like .lan, .local, .home - const suffixMatch = hostname.match(/\.(lan|local|home|internal)$/); - const suffix = suffixMatch ? suffixMatch[0] : ''; - return `node-REDACTED${suffix}`; - }; - const sanitizeOptionalHostname = (value?: string) => - value && value.trim() ? sanitizeHostname(value) : undefined; - - const sanitizeText = (text: string | undefined) => { - if (!text) return text; - return text - .replace(/https?:\/\/[^"'\s]+/g, 'https://REDACTED') - .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, 'xxx.xxx.xxx.xxx'); - }; - - const sanitizeNotesArray = (notes: unknown) => { - if (!Array.isArray(notes)) return notes; - return notes.map((note) => { - if (typeof note !== 'string') return note; - const sanitizedNote = sanitizeText(note); - return sanitizedNote ?? note; - }); - }; - - const sanitizeNodeSnapshots = ( - snapshots: Array>, - ) => - snapshots.map((snapshot, index: number) => { - const sanitizedSnapshot = { ...snapshot }; - const originalNode = - typeof sanitizedSnapshot.node === 'string' - ? (sanitizedSnapshot.node as string) - : ''; - if (originalNode) { - sanitizedSnapshot.node = - connectionKeyMap.get(originalNode) || - sanitizeHostname(originalNode); - } - - const originalInstance = - typeof sanitizedSnapshot.instance === 'string' - ? (sanitizedSnapshot.instance as string) - : ''; - if (originalInstance) { - sanitizedSnapshot.instance = - getSanitizedInstance(originalInstance); - } - - if ( - typeof sanitizedSnapshot.id === 'string' && - sanitizedSnapshot.id - ) { - sanitizedSnapshot.id = `node-snapshot-${index}`; - } - - return sanitizedSnapshot; - }); - - const sanitizeGuestSnapshots = ( - snapshots: Array>, - ) => - snapshots.map((snapshot, index: number) => { - const sanitizedSnapshot = { ...snapshot }; - const originalNode = - typeof sanitizedSnapshot.node === 'string' - ? (sanitizedSnapshot.node as string) - : ''; - if (originalNode) { - sanitizedSnapshot.node = - connectionKeyMap.get(originalNode) || - sanitizeHostname(originalNode); - } - - const originalInstance = - typeof sanitizedSnapshot.instance === 'string' - ? (sanitizedSnapshot.instance as string) - : ''; - if (originalInstance) { - sanitizedSnapshot.instance = - getSanitizedInstance(originalInstance); - } - - if (typeof sanitizedSnapshot.name === 'string') { - sanitizedSnapshot.name = 'vm-REDACTED'; - } - - if (typeof sanitizedSnapshot.vmid === 'number') { - sanitizedSnapshot.vmid = index + 1; - } else if (typeof sanitizedSnapshot.vmid === 'string') { - sanitizedSnapshot.vmid = `vm-${index + 1}`; - } - - if (Array.isArray(sanitizedSnapshot.notes)) { - sanitizedSnapshot.notes = sanitizeNotesArray( - sanitizedSnapshot.notes, - ); - } - - return sanitizedSnapshot; - }); - - // Sanitize nodes - if (sanitized.nodes) { - sanitized.nodes = ( - sanitized.nodes as Array> - ).map((node, index: number) => { - const nodeType = - typeof node.type === 'string' ? (node.type as string) : 'node'; - const nodeName = - typeof node.name === 'string' ? (node.name as string) : ''; - const nodeHost = - typeof node.host === 'string' ? (node.host as string) : ''; - const tokenName = - typeof node.tokenName === 'string' - ? (node.tokenName as string) - : undefined; - const clusterName = - typeof node.clusterName === 'string' - ? (node.clusterName as string) - : undefined; - const clusterEndpoints = Array.isArray(node.clusterEndpoints) - ? (node.clusterEndpoints as Array>).map( - (ep, epIndex: number) => ({ - ...ep, - NodeName: `node-${epIndex + 1}`, - Host: `node-${epIndex + 1}`, - IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : ''), - }), - ) - : node.clusterEndpoints; - - const sanitizedId = `${nodeType}-${index}`; - connectionKeyMap.set(nodeName, sanitizedId); - - // Sanitize nested physical disks data if present - const physicalDisks = node.physicalDisks as - | Record - | undefined; - if (physicalDisks && Array.isArray(physicalDisks.nodeResults)) { - physicalDisks.nodeResults = ( - physicalDisks.nodeResults as Array> - ).map((result) => ({ - ...result, - nodeName: sanitizeHostname( - typeof result.nodeName === 'string' ? result.nodeName : '', - ), - apiResponse: - sanitizeText( - typeof result.apiResponse === 'string' - ? result.apiResponse - : undefined, - ) ?? result.apiResponse, - error: - sanitizeText( - typeof result.error === 'string' ? result.error : undefined, - ) ?? result.error, - })); - } - - // Sanitize nested VM disk check data if present - const vmDiskCheck = node.vmDiskCheck as - | Record - | undefined; - if (vmDiskCheck) { - if (typeof vmDiskCheck.testVMName === 'string') { - vmDiskCheck.testVMName = 'vm-REDACTED'; - } - if (typeof vmDiskCheck.testResult === 'string') { - vmDiskCheck.testResult = sanitizeText(vmDiskCheck.testResult); - } - if (Array.isArray(vmDiskCheck.problematicVMs)) { - vmDiskCheck.problematicVMs = ( - vmDiskCheck.problematicVMs as Array> - ).map((problem) => ({ - ...problem, - name: 'vm-REDACTED', - issue: - sanitizeText( - typeof problem.issue === 'string' - ? problem.issue - : undefined, - ) ?? problem.issue, - })); - } - if (Array.isArray(vmDiskCheck.recommendations)) { - vmDiskCheck.recommendations = ( - vmDiskCheck.recommendations as Array - ).map((rec) => sanitizeText(rec) ?? rec); - } - } - - return { - ...node, - id: sanitizedId, - name: sanitizeHostname(nodeName), - host: nodeHost - ? nodeHost.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') - : nodeHost, - tokenName: tokenName ? 'token-REDACTED' : tokenName, - clusterName: clusterName ? 'cluster-REDACTED' : clusterName, - clusterEndpoints, - physicalDisks, - vmDiskCheck, - }; - }); - } - - if (Array.isArray(sanitized.nodeSnapshots)) { - sanitized.nodeSnapshots = sanitizeNodeSnapshots( - sanitized.nodeSnapshots as Array>, - ); - } - - if (Array.isArray(sanitized.guestSnapshots)) { - sanitized.guestSnapshots = sanitizeGuestSnapshots( - sanitized.guestSnapshots as Array>, - ); - } - - if ( - sanitized.temperatureProxy && - typeof sanitized.temperatureProxy === 'object' - ) { - const proxyDiag = sanitized.temperatureProxy as Record< - string, - unknown - >; - if (typeof proxyDiag.socketPath === 'string') { - proxyDiag.socketPath = proxyDiag.socketPath.includes( - 'pulse-sensor-proxy', - ) - ? '/mnt/pulse-proxy/pulse-sensor-proxy.sock' - : 'proxy-socket'; - } - if (Array.isArray(proxyDiag.notes)) { - proxyDiag.notes = sanitizeNotesArray(proxyDiag.notes); - } - if (Array.isArray(proxyDiag.httpProxies)) { - proxyDiag.httpProxies = ( - proxyDiag.httpProxies as Array> - ).map((entry, index: number) => { - const sanitizedEntry: TemperatureProxyHTTPStatus = { - node: sanitizeHostname( - typeof entry.node === 'string' - ? (entry.node as string) - : `http-proxy-${index + 1}`, - ), - reachable: Boolean(entry.reachable), - }; - if (typeof entry.url === 'string') { - sanitizedEntry.url = - sanitizeText(entry.url as string) ?? (entry.url as string); - } - if (typeof entry.error === 'string') { - sanitizedEntry.error = - sanitizeText(entry.error as string) ?? (entry.error as string); - } - return sanitizedEntry; - }); - } - if (Array.isArray(proxyDiag.socketHostCooldowns)) { - proxyDiag.socketHostCooldowns = ( - proxyDiag.socketHostCooldowns as Array> - ).map((entry) => { - const originalNode = - typeof entry.node === 'string' ? (entry.node as string) : undefined; - const originalHost = - typeof entry.host === 'string' ? (entry.host as string) : undefined; - return { - node: sanitizeOptionalHostname(originalNode), - host: sanitizeOptionalHostname(originalHost), - cooldownUntil: - typeof entry.cooldownUntil === 'string' - ? (entry.cooldownUntil as string) - : undefined, - secondsRemaining: - typeof entry.secondsRemaining === 'number' - ? (entry.secondsRemaining as number) - : undefined, - lastError: - typeof entry.lastError === 'string' - ? sanitizeText(entry.lastError as string) ?? (entry.lastError as string) - : undefined, - }; - }); - } - } - - if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') { - const apiTokens = sanitized.apiTokens as Record; - const tokenIdMap = new Map(); - if (Array.isArray(apiTokens.tokens)) { - apiTokens.tokens = ( - apiTokens.tokens as Array> - ).map((token, tokenIndex: number) => { - const sanitizedToken = { ...token } as Record; - const originalId = - typeof token.id === 'string' ? (token.id as string) : ''; - const sanitizedId = `token-${tokenIndex + 1}`; - if (originalId) { - tokenIdMap.set(originalId, sanitizedId); - } - sanitizedToken.id = sanitizedId; - sanitizedToken.name = sanitizedId; - if (typeof sanitizedToken.hint === 'string') { - sanitizedToken.hint = 'token-REDACTED'; - } - return sanitizedToken; - }); - } - if (Array.isArray(apiTokens.usage)) { - apiTokens.usage = ( - apiTokens.usage as Array> - ).map((usage, usageIndex: number) => { - const sanitizedUsage = { ...usage } as Record; - const originalTokenId = - typeof usage.tokenId === 'string' - ? (usage.tokenId as string) - : ''; - const mappedId = - tokenIdMap.get(originalTokenId) ?? `token-${usageIndex + 1}`; - sanitizedUsage.tokenId = mappedId; - if (Array.isArray(sanitizedUsage.hosts)) { - sanitizedUsage.hosts = ( - sanitizedUsage.hosts as Array - ).map((host, hostIndex) => - sanitizeHostname( - typeof host === 'string' ? host : `host-${hostIndex + 1}`, - ), - ); - } - return sanitizedUsage; - }); - } - if (Array.isArray(apiTokens.notes)) { - apiTokens.notes = sanitizeNotesArray(apiTokens.notes); - } - } - - if ( - sanitized.dockerAgents && - typeof sanitized.dockerAgents === 'object' - ) { - const dockerDiag = sanitized.dockerAgents as Record; - if (Array.isArray(dockerDiag.attention)) { - dockerDiag.attention = ( - dockerDiag.attention as Array> - ).map((entry, index: number) => { - const sanitizedEntry = { ...entry }; - sanitizedEntry.hostId = `docker-host-${index + 1}`; - sanitizedEntry.name = `docker-host-${index + 1}`; - if (typeof sanitizedEntry.tokenHint === 'string') { - sanitizedEntry.tokenHint = 'token-REDACTED'; - } - if (Array.isArray(sanitizedEntry.issues)) { - sanitizedEntry.issues = sanitizeNotesArray( - sanitizedEntry.issues, - ); - } - return sanitizedEntry; - }); - } - if (Array.isArray(dockerDiag.notes)) { - dockerDiag.notes = sanitizeNotesArray(dockerDiag.notes); - } - } - - if (sanitized.alerts && typeof sanitized.alerts === 'object') { - const alerts = sanitized.alerts as Record; - if (Array.isArray(alerts.legacyThresholdSources)) { - alerts.legacyThresholdSources = ( - alerts.legacyThresholdSources as string[] - ).map((source) => sanitizeText(source) ?? source); - } - if (Array.isArray(alerts.legacyScheduleSettings)) { - alerts.legacyScheduleSettings = ( - alerts.legacyScheduleSettings as string[] - ).map((setting) => sanitizeText(setting) ?? setting); - } - if (Array.isArray(alerts.notes)) { - alerts.notes = sanitizeNotesArray(alerts.notes); - } - } - - // Sanitize backend diagnostics (if present) - if ( - sanitized.backendDiagnostics && - typeof sanitized.backendDiagnostics === 'object' - ) { - const backend = sanitized.backendDiagnostics as Record< - string, - unknown - >; - - if (Array.isArray(backend.nodeSnapshots)) { - backend.nodeSnapshots = sanitizeNodeSnapshots( - backend.nodeSnapshots as Array>, - ); - } - - if (Array.isArray(backend.guestSnapshots)) { - backend.guestSnapshots = sanitizeGuestSnapshots( - backend.guestSnapshots as Array>, - ); - } - - if (Array.isArray(backend.nodes)) { - backend.nodes = backend.nodes.map((rawNode, index: number) => { - const node = rawNode as Record; - const originalName = - typeof node.name === 'string' ? node.name : ''; - const sanitizedId = `diagnostic-node-${index}`; - connectionKeyMap.set(originalName, sanitizedId); - - const vmDiskCheck = node.vmDiskCheck as - | Record - | undefined; - const physicalDisks = node.physicalDisks as - | Record - | undefined; - - if (vmDiskCheck) { - if (typeof vmDiskCheck.testVMName === 'string') { - vmDiskCheck.testVMName = 'vm-REDACTED'; - } - if (typeof vmDiskCheck.testResult === 'string') { - const sanitizedResult = sanitizeText( - vmDiskCheck.testResult as string, - ); - vmDiskCheck.testResult = - sanitizedResult ?? vmDiskCheck.testResult; - } - if (Array.isArray(vmDiskCheck.problematicVMs)) { - vmDiskCheck.problematicVMs = ( - vmDiskCheck.problematicVMs as Array> - ).map((problem) => { - const issueText = sanitizeText( - typeof problem.issue === 'string' - ? problem.issue - : undefined, - ); - return { - ...problem, - name: 'vm-REDACTED', - issue: issueText ?? problem.issue, - }; - }); - } - if (Array.isArray(vmDiskCheck.recommendations)) { - vmDiskCheck.recommendations = ( - vmDiskCheck.recommendations as Array - ).map((rec) => sanitizeText(rec) ?? rec); - } - } - - if (physicalDisks) { - if (typeof physicalDisks.testResult === 'string') { - const sanitizedResult = sanitizeText( - physicalDisks.testResult as string, - ); - physicalDisks.testResult = - sanitizedResult ?? physicalDisks.testResult; - } - if (Array.isArray(physicalDisks.nodeResults)) { - physicalDisks.nodeResults = ( - physicalDisks.nodeResults as Array> - ).map((result) => ({ - ...result, - nodeName: sanitizeHostname( - typeof result.nodeName === 'string' - ? result.nodeName - : '', - ), - apiResponse: - sanitizeText( - typeof result.apiResponse === 'string' - ? result.apiResponse - : undefined, - ) ?? result.apiResponse, - error: - sanitizeText( - typeof result.error === 'string' - ? result.error - : undefined, - ) ?? result.error, - })); - } - } - - return { - ...node, - id: sanitizedId, - name: sanitizeHostname(originalName), - host: - typeof node.host === 'string' - ? (node.host as string).replace( - /https?:\/\/[^:\/]+/, - 'https://REDACTED', - ) - : node.host, - error: - sanitizeText( - typeof node.error === 'string' ? node.error : undefined, - ) ?? node.error, - vmDiskCheck, - physicalDisks, - }; - }); - } - - if (Array.isArray(backend.pbs)) { - backend.pbs = backend.pbs.map((rawPbs, index: number) => { - const pbsNode = rawPbs as Record; - const originalName = - typeof pbsNode.name === 'string' ? pbsNode.name : ''; - const sanitizedId = `diagnostic-pbs-${index}`; - connectionKeyMap.set(originalName, sanitizedId); - - return { - ...pbsNode, - id: sanitizedId, - name: sanitizeHostname(originalName), - host: - typeof pbsNode.host === 'string' - ? (pbsNode.host as string).replace( - /https?:\/\/[^:\/]+/, - 'https://REDACTED', - ) - : pbsNode.host, - error: - sanitizeText( - typeof pbsNode.error === 'string' - ? pbsNode.error - : undefined, - ) ?? pbsNode.error, - }; - }); - } - } - - // Sanitize storage - if (sanitized.storage) { - sanitized.storage = ( - sanitized.storage as Array> - ).map((s, index: number) => { - const storageNode = typeof s.node === 'string' ? s.node : ''; - return { - ...s, - id: `storage-${index}`, - node: sanitizeHostname(storageNode), - name: `storage-${index}`, - }; - }); - } - - // Sanitize backups - const backups = sanitized.backups as - | Record - | undefined; - if (backups) { - // Sanitize PVE backup tasks - if (Array.isArray(backups.pveBackupTasks)) { - backups.pveBackupTasks = ( - backups.pveBackupTasks as Array> - ).map((b, index: number) => { - const backupNode = typeof b.node === 'string' ? b.node : ''; - const backupVmid = - typeof b.vmid === 'number' ? b.vmid : undefined; - return { - ...b, - node: sanitizeHostname(backupNode), - storage: `storage-${index}`, - vmid: - backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid, - }; - }); - } - - // Sanitize PVE storage backups - if (Array.isArray(backups.pveStorageBackups)) { - backups.pveStorageBackups = ( - backups.pveStorageBackups as Array> - ).map((b, index: number) => { - const backupNode = typeof b.node === 'string' ? b.node : ''; - const backupVmid = - typeof b.vmid === 'number' ? b.vmid : undefined; - const volid = typeof b.volid === 'string' ? b.volid : undefined; - return { - ...b, - node: sanitizeHostname(backupNode), - storage: `storage-${index}`, - vmid: - backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid, - volid: volid ? 'vol-REDACTED' : volid, - }; - }); - } - - // Sanitize PBS backups - if (Array.isArray(backups.pbsBackups)) { - backups.pbsBackups = ( - backups.pbsBackups as Array> - ).map((b, index: number) => { - const backupId = - typeof b.backupId === 'string' ? b.backupId : undefined; - const vmName = - typeof b.vmName === 'string' ? b.vmName : undefined; - return { - ...b, - datastore: `datastore-${index}`, - backupId: backupId ? `backup-${index}` : backupId, - vmName: vmName ? 'vm-REDACTED' : vmName, - }; - }); - } - } - - // Sanitize active alerts - const activeAlerts = sanitized.activeAlerts as - | Array> - | undefined; - if (activeAlerts) { - sanitized.activeAlerts = activeAlerts.map((alert) => { - const alertNode = typeof alert.node === 'string' ? alert.node : ''; - const details = - typeof alert.details === 'string' ? alert.details : undefined; - return { - ...alert, - node: sanitizeHostname(alertNode), - details: details - ? details.replace( - /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, - 'xxx.xxx.xxx.xxx', - ) - : details, - }; - }); - } - - // Sanitize websocket URL - const websocketInfo = sanitized.websocket as - | Record - | undefined; - if (websocketInfo && typeof websocketInfo.url === 'string') { - websocketInfo.url = websocketInfo.url.replace( - /\/\/[^\/]+/, - '//REDACTED', - ); - } - - if ( - sanitized.connectionHealth && - typeof sanitized.connectionHealth === 'object' - ) { - const newConnectionHealth: Record = {}; - let index = 1; - Object.entries( - sanitized.connectionHealth as Record, - ).forEach(([key, value]) => { - const mappedKey = connectionKeyMap.get(key) || `resource-${index}`; - newConnectionHealth[mappedKey] = value; - index += 1; - }); - sanitized.connectionHealth = newConnectionHealth; - } - - // Add sanitization notice - sanitized._notice = - 'This diagnostic data has been sanitized for sharing on GitHub. IP addresses, hostnames, and tokens have been redacted.'; - - return sanitized; - }; - - const exportDiagnostics = (sanitize: boolean) => { - let diagnostics: Record = { - timestamp: new Date().toISOString(), - version: '2.1.0', - pulseVersion: state.stats?.version || 'unknown', - environment: { - userAgent: navigator.userAgent, - platform: navigator.platform, - language: navigator.language, - screenResolution: `${window.screen.width}x${window.screen.height}`, - windowSize: `${window.innerWidth}x${window.innerHeight}`, - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - websocket: { - connected: connected(), - url: getPulseWebSocketUrl(), - }, - // Include backend diagnostics if available - backendDiagnostics: diagnosticsData() || null, - nodes: - nodes()?.map((n) => ({ - ...n, - status: - state.nodes?.find((sn) => sn.id === n.id)?.status || 'unknown', - online: - state.nodes?.find((sn) => sn.id === n.id)?.status === 'online', - })) || [], - state: { - nodesCount: state.nodes?.length || 0, - nodesOnline: - state.nodes?.filter((n) => n.status === 'online').length || 0, - nodesOffline: - state.nodes?.filter((n) => n.status !== 'online').length || 0, - vmsCount: state.vms?.length || 0, - containersCount: state.containers?.length || 0, - storageCount: state.storage?.length || 0, - physicalDisksCount: state.physicalDisks?.length || 0, - pbsCount: state.pbs?.length || 0, - pbsBackupsCount: pbsBackupsState()?.length || 0, - pveBackups: { - backupTasksCount: pveBackupsState()?.backupTasks?.length || 0, - storageBackupsCount: - pveBackupsState()?.storageBackups?.length || 0, - guestSnapshotsCount: - pveBackupsState()?.guestSnapshots?.length || 0, - }, - }, - // Node status details - nodeStatus: - state.nodes?.map((n) => ({ - id: n.id, - name: n.name, - status: n.status, - online: n.status === 'online', - cpu: n.cpu, - memory: n.memory, - uptime: n.uptime, - version: n.pveVersion ?? n.kernelVersion, - })) || [], - storage: - state.storage?.map((s) => ({ - id: s.id, - node: s.node, - name: s.name, - type: s.type, - status: s.status, - enabled: s.enabled, - content: s.content, - shared: s.shared, - used: s.used, - total: s.total, - zfsPool: s.zfsPool - ? { - state: s.zfsPool.state, - readErrors: s.zfsPool.readErrors, - writeErrors: s.zfsPool.writeErrors, - checksumErrors: s.zfsPool.checksumErrors, - deviceCount: s.zfsPool.devices?.length || 0, - } - : undefined, - hasBackups: - (pveBackupsState()?.storageBackups ?? []).filter( - (b) => b.storage === s.name, - ).length > 0, - })) || [], - // Physical disks - critical for troubleshooting - physicalDisks: - state.physicalDisks?.map((d) => ({ - node: d.node, - device: d.device || d.devPath, - model: d.model, - size: d.size, - type: d.type, - health: d.health, - wearout: d.wearout, - rpm: d.rpm, - smart: d.smart ?? null, - })) || [], - backups: { - pveBackupTasks: pveBackupsState()?.backupTasks?.slice(0, 10) || [], - pveStorageBackups: - pveBackupsState()?.storageBackups?.slice(0, 10) || [], - pbsBackups: pbsBackupsState()?.slice(0, 10) || [], - }, - connectionHealth: state.connectionHealth || {}, - performance: { - lastPollDuration: state.performance?.lastPollDuration || 0, - totalApiCalls: state.performance?.totalApiCalls || 0, - failedApiCalls: state.performance?.failedApiCalls || 0, - apiCallDuration: state.performance?.apiCallDuration || {}, - }, - activeAlerts: state.activeAlerts?.slice(0, 20) || [], - settings: {}, - }; - - if (sanitize) { - diagnostics = sanitizeForGitHub(diagnostics); - - // Rebuild nodeStatus from sanitized nodes to avoid leaking real names - // Both arrays are built in the same order, so we can match by index - if ( - Array.isArray(diagnostics.nodes) && - Array.isArray(diagnostics.nodeStatus) - ) { - const sanitizedNodes = diagnostics.nodes as Array< - Record - >; - const originalNodeStatus = diagnostics.nodeStatus as Array< - Record - >; - - diagnostics.nodeStatus = sanitizedNodes.map( - (sanitizedNode, index) => { - // Get corresponding runtime data using same index - const originalStatus = originalNodeStatus[index]; - - // Use sanitized node name/id but preserve runtime metrics - return { - id: sanitizedNode.id, - name: sanitizedNode.name, // Already sanitized - status: originalStatus?.status || 'unknown', - online: originalStatus?.online || false, - cpu: originalStatus?.cpu, - memory: originalStatus?.memory, - uptime: originalStatus?.uptime, - version: originalStatus?.version, - }; - }, - ); - } - - // Sanitize storage entries that have nodes arrays or instance fields - if (Array.isArray(diagnostics.storage)) { - diagnostics.storage = ( - diagnostics.storage as Array> - ).map((storageItem, index) => { - const sanitizedItem = { ...storageItem }; - - // Sanitize storage name field (original Proxmox storage name) - // This field often contains node names (e.g., "pbs-delly") - if ( - typeof sanitizedItem.storage === 'string' && - sanitizedItem.storage - ) { - sanitizedItem.storage = `storage-${index}`; - } - - // Sanitize nodes array if present (cluster storage has this) - if (Array.isArray(sanitizedItem.nodes)) { - sanitizedItem.nodes = ( - sanitizedItem.nodes as Array - ).map(() => 'node-REDACTED'); - } - - // Sanitize nodeIds array if present - if (Array.isArray(sanitizedItem.nodeIds)) { - sanitizedItem.nodeIds = ( - sanitizedItem.nodeIds as Array - ).map(() => 'node-REDACTED'); - } - - // Sanitize instance field if present - if ( - typeof sanitizedItem.instance === 'string' && - sanitizedItem.instance - ) { - const instanceMatch = (sanitizedItem.instance as string).match( - /\.(lan|local|home|internal)$/, - ); - const suffix = instanceMatch ? instanceMatch[0] : ''; - sanitizedItem.instance = `instance-REDACTED${suffix}`; - } - - return sanitizedItem; - }); - } - - // Sanitize activeAlerts resourceName field - if (Array.isArray(diagnostics.activeAlerts)) { - diagnostics.activeAlerts = ( - diagnostics.activeAlerts as Array> - ).map((alert, index) => { - const sanitizedAlert = { ...alert }; - - // Sanitize resourceName if present - if ( - typeof sanitizedAlert.resourceName === 'string' && - sanitizedAlert.resourceName - ) { - const hostnameMatch = ( - sanitizedAlert.resourceName as string - ).match(/\.(lan|local|home|internal)$/); - const suffix = hostnameMatch ? hostnameMatch[0] : ''; - sanitizedAlert.resourceName = `resource-REDACTED${suffix}`; - } - - // Sanitize node field if present - if ( - typeof sanitizedAlert.node === 'string' && - sanitizedAlert.node - ) { - sanitizedAlert.node = 'node-REDACTED'; - } - - // Sanitize instance field if present - if ( - typeof sanitizedAlert.instance === 'string' && - sanitizedAlert.instance - ) { - const instanceMatch = (sanitizedAlert.instance as string).match( - /\.(lan|local|home|internal)$/, - ); - const suffix = instanceMatch ? instanceMatch[0] : ''; - sanitizedAlert.instance = `instance-REDACTED${suffix}`; - } - - // Sanitize resourceId (e.g., "delly.lan-delly" → "alert-resource-0") - if ( - typeof sanitizedAlert.resourceId === 'string' && - sanitizedAlert.resourceId - ) { - sanitizedAlert.resourceId = `alert-resource-${index}`; - } - - // Sanitize id field (e.g., "delly.lan-delly-temperature" → "alert-0-temperature") - if (typeof sanitizedAlert.id === 'string' && sanitizedAlert.id) { - // Extract the alert type from the end (e.g., "temperature") - const idParts = (sanitizedAlert.id as string).split('-'); - const alertType = idParts[idParts.length - 1]; - sanitizedAlert.id = `alert-${index}-${alertType}`; - } - - return sanitizedAlert; - }); - } - } - - const blob = new Blob([JSON.stringify(diagnostics, null, 2)], { - type: 'application/json', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const type = sanitize ? 'sanitized' : 'full'; - a.download = `pulse-diagnostics-${type}-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - return ( -
- -
- Run diagnostics first for more comprehensive export data -
-
-
- - -
-
- ); - })()} - -

- Export Full: Complete data for private troubleshooting -
- Export for GitHub: Sanitized data safe for public sharing -

-
+ + +
+
+ +
+ + {/* Delete Node Modal */ } + < Show when = { showDeleteNodeModal() } > +
+ + +
+

+ Removing this {nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the Pulse + footprint on the host — the proxy service, SSH key, API token, and bind mount are + all cleaned up automatically. +

+
+

What happens next

+
    +
  • Pulse removes the node entry and clears related alerts.
  • +
  • + {nodePendingDeleteHost() ? ( + <> + The host {nodePendingDeleteHost()} loses + the proxy service, SSH key, and API token. + + ) : ( + 'The host loses the proxy service, SSH key, and API token.' + )} +
  • +
  • + If the host comes back later, rerunning the setup script reinstalls everything + with a fresh key. +
  • + +
  • + Backup user tokens on the PBS are removed, so jobs referencing them will no + longer authenticate until the node is re-added. +
  • +
    + +
  • + Mail gateway tokens are removed as part of the cleanup; re-enroll to restore + outbound telemetry. +
  • +
    +
+
+
+ +
+ + +
+
+
+ + + {/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */ } + < Show when = { isNodeModalVisible('pve') } > + { + setShowNodeModal(false); + setEditingNode(null); + // Increment resetKey to force form reset on next open + setModalResetKey((prev) => prev + 1); + }} + nodeType="pve" + editingNode={editingNode()?.type === 'pve' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pve' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + // Update existing node (only if it has a valid ID) + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + + // Update local state + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + // Update hasPassword/hasToken based on whether credentials were provided + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + showSuccess('Node updated successfully'); + } else { + // Add new node + await NodesAPI.addNode(nodeData as NodeConfig); + + // Reload nodes to get the new ID + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + // Use the hasPassword/hasToken from the API if available, otherwise check local fields + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + showSuccess('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + showError(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + + {/* PBS Node Modal - Separate instance to prevent contamination */ } + < Show when = { isNodeModalVisible('pbs') } > + { + setShowNodeModal(false); + setEditingNode(null); + // Increment resetKey to force form reset on next open + setModalResetKey((prev) => prev + 1); + }} + nodeType="pbs" + editingNode={editingNode()?.type === 'pbs' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pbs' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + // Update existing node (only if it has a valid ID) + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + + // Update local state + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + showSuccess('Node updated successfully'); + } else { + // Add new node + await NodesAPI.addNode(nodeData as NodeConfig); + + // Reload the nodes list to get the latest state + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + // Use the hasPassword/hasToken from the API if available, otherwise check local fields + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + showSuccess('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + showError(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + + {/* PMG Node Modal */ } + < Show when = { isNodeModalVisible('pmg') } > + { + setShowNodeModal(false); + setEditingNode(null); + setModalResetKey((prev) => prev + 1); + }} + nodeType="pmg" + editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pmg' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + showSuccess('Node updated successfully'); + } else { + await NodesAPI.addNode(nodeData as NodeConfig); + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + showSuccess('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + showError(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + {/* Export Dialog */ } + < Show when = { showExportDialog() } > +
+ + + +
+ {/* Password Choice Section - Only show if auth is enabled */} + +
+
+
- + + + +
+
+ + + {/* Show password input based on selection */} +
+ + setExportPassphrase(e.currentTarget.value)} + placeholder={ + securityStatus()?.hasAuthentication + ? useCustomPassphrase() + ? 'Enter a strong passphrase' + : 'Enter your Pulse login password' + : 'Enter a strong passphrase for encryption' + } + class={controlClass()} + /> + +

+ You'll need this passphrase to restore the backup. +

+
+ +

+ You'll use this same password when restoring the backup +

+
+
+ +
+
+ + + +
+ Important: The backup contains node credentials but NOT + authentication settings. Each Pulse instance should configure its own login + credentials for security. Remember your{' '} + {useCustomPassphrase() || !securityStatus()?.hasAuthentication + ? 'passphrase' + : 'password'}{' '} + for restoring. +
-
-
- {/* Delete Node Modal */} - -
- - -
-

- Removing this {nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the Pulse - footprint on the host — the proxy service, SSH key, API token, and bind mount are - all cleaned up automatically. -

-
-

What happens next

-
    -
  • Pulse removes the node entry and clears related alerts.
  • -
  • - {nodePendingDeleteHost() ? ( - <> - The host {nodePendingDeleteHost()} loses - the proxy service, SSH key, and API token. - - ) : ( - 'The host loses the proxy service, SSH key, and API token.' - )} -
  • -
  • - If the host comes back later, rerunning the setup script reinstalls everything - with a fresh key. -
  • - -
  • - Backup user tokens on the PBS are removed, so jobs referencing them will no - longer authenticate until the node is re-added. -
  • -
    - -
  • - Mail gateway tokens are removed as part of the cleanup; re-enroll to restore - outbound telemetry. -
  • -
    -
-
-
- -
- - -
-
-
-
- - {/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */} - - { - setShowNodeModal(false); - setEditingNode(null); - // Increment resetKey to force form reset on next open - setModalResetKey((prev) => prev + 1); - }} - nodeType="pve" - editingNode={editingNode()?.type === 'pve' ? (editingNode() ?? undefined) : undefined} - securityStatus={securityStatus() ?? undefined} - temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( - editingNode()?.type === 'pve' ? editingNode() : null, - )} - temperatureMonitoringLocked={temperatureMonitoringLocked()} - savingTemperatureSetting={savingTemperatureSetting()} - onToggleTemperatureMonitoring={ - editingNode()?.id - ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) - : handleTemperatureMonitoringChange - } - onSave={async (nodeData) => { - try { - if (editingNode() && editingNode()!.id) { - // Update existing node (only if it has a valid ID) - await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); - - // Update local state - setNodes( - nodes().map((n) => - n.id === editingNode()!.id - ? { - ...n, - ...nodeData, - // Update hasPassword/hasToken based on whether credentials were provided - hasPassword: nodeData.password ? true : n.hasPassword, - hasToken: nodeData.tokenValue ? true : n.hasToken, - status: 'pending', - } - : n, - ), - ); - showSuccess('Node updated successfully'); - } else { - // Add new node - await NodesAPI.addNode(nodeData as NodeConfig); - - // Reload nodes to get the new ID - const nodesList = await NodesAPI.getNodes(); - const nodesWithStatus = nodesList.map((node) => ({ - ...node, - // Use the hasPassword/hasToken from the API if available, otherwise check local fields - hasPassword: node.hasPassword ?? !!node.password, - hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || ('pending' as const), - })); - setNodes(nodesWithStatus); - showSuccess('Node added successfully'); +
+ + +
+
+ +
+ - setShowNodeModal(false); - setEditingNode(null); - } catch (error) { - showError(error instanceof Error ? error.message : 'Operation failed'); - } - }} - /> - + {/* API Token Modal */ } + < Show when = { showApiTokenModal() } > +
+ + - {/* PBS Node Modal - Separate instance to prevent contamination */} - - { - setShowNodeModal(false); - setEditingNode(null); - // Increment resetKey to force form reset on next open - setModalResetKey((prev) => prev + 1); - }} - nodeType="pbs" - editingNode={editingNode()?.type === 'pbs' ? (editingNode() ?? undefined) : undefined} - securityStatus={securityStatus() ?? undefined} - temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( - editingNode()?.type === 'pbs' ? editingNode() : null, - )} - temperatureMonitoringLocked={temperatureMonitoringLocked()} - savingTemperatureSetting={savingTemperatureSetting()} - onToggleTemperatureMonitoring={ - editingNode()?.id - ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) - : handleTemperatureMonitoringChange - } - onSave={async (nodeData) => { - try { - if (editingNode() && editingNode()!.id) { - // Update existing node (only if it has a valid ID) - await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); +
+

+ This Pulse instance requires an API token for export/import operations. Please enter + the API token configured on the server. +

- // Update local state - setNodes( - nodes().map((n) => - n.id === editingNode()!.id - ? { - ...n, - ...nodeData, - hasPassword: nodeData.password ? true : n.hasPassword, - hasToken: nodeData.tokenValue ? true : n.hasToken, - status: 'pending', - } - : n, - ), - ); - showSuccess('Node updated successfully'); +
+ + setApiTokenInput(e.currentTarget.value)} + placeholder="Enter API token" + class={controlClass()} + /> +
+ +
+

The API token is set as an environment variable:

+ API_TOKENS=token-for-export,token-for-automation +
+
+ +
+ + - -
-
- + }} + disabled={!apiTokenInput()} + class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" + > + Authenticate +
-
+ + + - {/* API Token Modal */} - -
- - + {/* Import Dialog */ } + < Show when = { showImportDialog() } > +
+ + -
-

- This Pulse instance requires an API token for export/import operations. Please enter - the API token configured on the server. -

+
+
+ + { + const file = e.currentTarget.files?.[0]; + if (file) setImportFile(file); + }} + class={controlClass('cursor-pointer')} + /> +
-
- - setApiTokenInput(e.currentTarget.value)} - placeholder="Enter API token" - class={controlClass()} - /> -
+
+ + setImportPassphrase(e.currentTarget.value)} + placeholder="Enter the password used when creating this backup" + class={controlClass()} + /> +

+ This is usually your Pulse login password, unless you used a custom passphrase +

+
-
-

The API token is set as an environment variable:

- API_TOKENS=token-for-export,token-for-automation -
-
+
+

+ Warning: Importing will replace all current configuration. This + action cannot be undone. +

+
-
- - -
- +
+ + +
- +
+
+ - {/* Import Dialog */} - -
- - - -
-
- - { - const file = e.currentTarget.files?.[0]; - if (file) setImportFile(file); - }} - class={controlClass('cursor-pointer')} - /> -
- -
- - setImportPassphrase(e.currentTarget.value)} - placeholder="Enter the password used when creating this backup" - class={controlClass()} - /> -

- This is usually your Pulse login password, unless you used a custom passphrase -

-
- -
-

- Warning: Importing will replace all current configuration. This - action cannot be undone. -

-
- -
- - -
-
-
-
-
- - { - setShowPasswordModal(false); - // Refresh security status after password change - loadSecurityStatus(); - }} - /> + { + setShowPasswordModal(false); + // Refresh security status after password change + loadSecurityStatus(); + }} + /> ); };