Pulse/frontend-modern/src/components/Settings/Settings.tsx

6783 lines
328 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component, createSignal, onMount, For, Show, createEffect, onCleanup, on } from 'solid-js';
import type { JSX } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { NodeModal } from './NodeModal';
import { APITokenManager } from './APITokenManager';
import { ChangePasswordModal } from './ChangePasswordModal';
import { GuestURLs } from './GuestURLs';
import { DockerAgents } from './DockerAgents';
import { OIDCPanel } from './OIDCPanel';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary';
import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Mail from 'lucide-solid/icons/mail';
import Link from 'lucide-solid/icons/link';
import Container from 'lucide-solid/icons/container';
import SettingsIcon from 'lucide-solid/icons/settings';
import Shield from 'lucide-solid/icons/shield';
import Activity from 'lucide-solid/icons/activity';
import type { NodeConfig } from '@/types/nodes';
import type { UpdateInfo, VersionInfo } from '@/api/updates';
import type { APITokenRecord } from '@/api/security';
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
import { eventBus } from '@/stores/events';
import { notificationStore } from '@/stores/notifications';
import { showTokenReveal } from '@/stores/tokenReveal';
import { updateStore } from '@/stores/updates';
const COMMON_DISCOVERY_SUBNETS = [
'192.168.1.0/24',
'192.168.0.0/24',
'10.0.0.0/24',
'172.16.0.0/24',
'192.168.10.0/24',
];
// Type definitions
interface DiscoveredServer {
ip: string;
port: number;
type: 'pve' | 'pbs' | 'pmg';
version: string;
hostname?: string;
release?: string;
}
type RawDiscoveredServer = {
ip?: string;
port?: number;
type?: string;
version?: string;
hostname?: string;
name?: string;
release?: string;
};
interface ClusterEndpoint {
Host?: string;
IP?: string;
}
interface DiagnosticsNode {
id: string;
name: string;
host: string;
type: string;
authMethod: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
lastPoll?: string;
clusterInfo?: Record<string, unknown>;
}
interface DiagnosticsPBS {
id: string;
name: string;
host: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
}
interface SystemDiagnostic {
os: string;
arch: string;
goVersion: string;
numCPU: number;
numGoroutine: number;
memoryMB: number;
}
interface TemperatureProxyDiagnostic {
legacySSHDetected: boolean;
recommendProxyUpgrade: boolean;
socketFound: boolean;
socketPath?: string;
socketPermissions?: string;
socketOwner?: string;
socketGroup?: string;
proxyReachable?: boolean;
proxyVersion?: string;
proxyPublicKeySha256?: string;
proxySshDirectory?: string;
legacySshKeyCount?: number;
notes?: string[];
}
interface APITokenSummary {
id: string;
name: string;
hint?: string;
createdAt?: string;
lastUsedAt?: string;
source?: string;
}
interface APITokenUsage {
tokenId: string;
hostCount: number;
hosts?: string[];
}
interface APITokenDiagnostic {
enabled: boolean;
tokenCount: number;
hasEnvTokens: boolean;
hasLegacyToken: boolean;
recommendTokenSetup: boolean;
recommendTokenRotation: boolean;
legacyDockerHostCount?: number;
unusedTokenCount?: number;
notes?: string[];
tokens?: APITokenSummary[];
usage?: APITokenUsage[];
}
interface DockerAgentAttention {
hostId: string;
name: string;
status: string;
agentVersion?: string;
tokenHint?: string;
lastSeen?: string;
issues: 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;
attention?: DockerAgentAttention[];
notes?: string[];
}
interface AlertsDiagnostic {
legacyThresholdsDetected: boolean;
legacyThresholdSources?: string[];
legacyScheduleSettings?: string[];
missingCooldown: boolean;
missingGroupingWindow: boolean;
notes?: string[];
}
interface ProxyRegisterNode {
name: string;
sshReady: boolean;
error?: string;
}
interface DockerMigrationResult {
token: string;
installCommand: string;
systemdServiceSnippet: string;
pulseURL: string;
record: APITokenRecord;
}
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;
errors: string[];
}
interface DiscoveryScanStatus {
scanning: boolean;
subnet?: string;
lastScanStartedAt?: number;
lastResultAt?: number;
errors?: string[];
}
type SettingsTab =
| 'pve'
| 'pbs'
| 'pmg'
| 'docker'
| 'system'
| 'urls'
| 'security'
| 'diagnostics'
| 'updates';
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
pve: {
title: 'Proxmox VE',
description: 'Connect clusters, manage nodes, and control discovery for Proxmox VE.',
},
pbs: {
title: 'Proxmox Backup Server',
description: 'Add and maintain Proxmox Backup Server endpoints used for snapshot and archive monitoring.',
},
pmg: {
title: 'Proxmox Mail Gateway',
description: 'Monitor mail flow, spam trends, and quarantine health from your PMG nodes.',
},
docker: {
title: 'Docker Agents',
description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.',
},
system: {
title: 'System Settings',
description: 'Adjust Pulse core behaviour, discovery preferences, and software update cadence.',
},
urls: {
title: 'Guest URLs',
description: 'Define per-guest deep links to console, dashboards, or third-party tooling.',
},
security: {
title: 'Security & Access',
description: 'Manage authentication, API tokens, exports, and other security posture tooling.',
},
diagnostics: {
title: 'Diagnostics',
description: 'Inspect discovery scans, connection health, and runtime metrics for troubleshooting.',
},
updates: {
title: 'Update History',
description: 'Review past software updates, rollback events, and upgrade audit logs.',
},
};
const BACKUP_INTERVAL_OPTIONS = [
{ label: 'Default (~90 seconds)', value: 0 },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 60 * 60 },
{ label: '6 hours', value: 6 * 60 * 60 },
{ label: '12 hours', value: 12 * 60 * 60 },
{ label: '24 hours', value: 24 * 60 * 60 },
];
const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days
// Node with UI-specific fields
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
hasToken?: boolean;
status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
};
interface SettingsProps {
darkMode: () => boolean;
toggleDarkMode: () => void;
}
const Settings: Component<SettingsProps> = (props) => {
const { state, connected } = useWebSocket();
const navigate = useNavigate();
const location = useLocation();
const deriveTabFromPath = (path: string): SettingsTab => {
if (path.includes('/settings/pbs')) return 'pbs';
if (path.includes('/settings/pmg')) return 'pmg';
if (path.includes('/settings/docker')) return 'docker';
if (path.includes('/settings/system')) return 'system';
if (path.includes('/settings/security')) return 'security';
if (path.includes('/settings/diagnostics')) return 'diagnostics';
if (path.includes('/settings/urls')) return 'urls';
if (path.includes('/settings/updates')) return 'updates';
return 'pve';
};
const [currentTab, setCurrentTab] = createSignal<SettingsTab>(deriveTabFromPath(location.pathname));
const activeTab = () => currentTab();
const setActiveTab = (tab: SettingsTab) => {
if (currentTab() !== tab) {
setCurrentTab(tab);
}
const targetPath = `/settings/${tab}`;
if (location.pathname !== targetPath) {
navigate(targetPath);
}
};
const headerMeta = () =>
SETTINGS_HEADER_META[activeTab()] ?? {
title: 'Settings',
description: 'Manage Pulse configuration.',
};
const pveBackupsState = () => state.backups?.pve ?? state.pveBackups;
const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups;
// Keep tab state in sync with URL and handle /settings redirect without flicker
createEffect(
on(
() => location.pathname,
(path) => {
if (path === '/settings' || path === '/settings/') {
if (currentTab() !== 'pve') {
setCurrentTab('pve');
}
return;
}
const resolved = deriveTabFromPath(path);
if (resolved !== currentTab()) {
setCurrentTab(resolved);
}
},
),
);
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true);
const [nodes, setNodes] = createSignal<NodeConfigWithStatus[]>([]);
const [discoveredNodes, setDiscoveredNodes] = createSignal<DiscoveredServer[]>([]);
const [showNodeModal, setShowNodeModal] = createSignal(false);
const [editingNode, setEditingNode] = createSignal<NodeConfigWithStatus | null>(null);
const [currentNodeType, setCurrentNodeType] = createSignal<'pve' | 'pbs' | 'pmg'>('pve');
const [modalResetKey, setModalResetKey] = createSignal(0);
const [showPasswordModal, setShowPasswordModal] = createSignal(false);
const [showDeleteNodeModal, setShowDeleteNodeModal] = createSignal(false);
const [nodePendingDelete, setNodePendingDelete] = createSignal<NodeConfigWithStatus | null>(null);
const [deleteNodeLoading, setDeleteNodeLoading] = createSignal(false);
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({
scanning: false,
});
// System settings
// PBS polling interval removed - fixed at 10 seconds
const [allowedOrigins, setAllowedOrigins] = createSignal('*');
const [discoveryEnabled, setDiscoveryEnabled] = createSignal(false);
const [discoverySubnet, setDiscoverySubnet] = createSignal('auto');
const [discoveryMode, setDiscoveryMode] = createSignal<'auto' | 'custom'>('auto');
const [discoverySubnetDraft, setDiscoverySubnetDraft] = createSignal('');
const [lastCustomSubnet, setLastCustomSubnet] = createSignal('');
const [discoverySubnetError, setDiscoverySubnetError] = createSignal<string | undefined>();
const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false);
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
let discoverySubnetInputRef: HTMLInputElement | undefined;
const parseSubnetList = (value: string) => {
const seen = new Set<string>();
return value
.split(',')
.map((token) => token.trim())
.filter((token) => {
if (!token || token.toLowerCase() === 'auto' || seen.has(token)) {
return false;
}
seen.add(token);
return true;
});
};
const normalizeSubnetList = (value: string) => parseSubnetList(value).join(', ');
const currentDraftSubnetValue = () => {
if (discoveryMode() === 'custom') {
return discoverySubnetDraft();
}
const draft = discoverySubnetDraft();
if (draft.trim() !== '') {
return draft;
}
const saved = discoverySubnet();
return saved.toLowerCase() === 'auto' ? '' : saved;
};
const isValidCIDR = (value: string) => {
const subnets = parseSubnetList(value);
if (subnets.length === 0) {
return false;
}
return subnets.every((token) => {
const [network, prefix] = token.split('/');
if (!network || typeof prefix === 'undefined') {
return false;
}
const prefixNumber = Number(prefix);
if (!Number.isInteger(prefixNumber) || prefixNumber < 0 || prefixNumber > 32) {
return false;
}
const octets = network.split('.');
if (octets.length !== 4) {
return false;
}
return octets.every((octet) => {
if (octet === '') return false;
if (!/^\d+$/.test(octet)) return false;
const valueNumber = Number(octet);
return valueNumber >= 0 && valueNumber <= 255;
});
});
};
const applySavedDiscoverySubnet = (subnet?: string | null) => {
const raw = typeof subnet === 'string' ? subnet.trim() : '';
if (raw === '' || raw.toLowerCase() === 'auto') {
setDiscoverySubnet('auto');
setDiscoveryMode('auto');
setDiscoverySubnetDraft('');
} else {
setDiscoveryMode('custom');
const normalizedValue = normalizeSubnetList(raw);
setDiscoverySubnet(normalizedValue);
setDiscoverySubnetDraft(normalizedValue);
setLastCustomSubnet(normalizedValue);
setDiscoverySubnetError(undefined);
return;
}
setDiscoverySubnetError(undefined);
};
// Connection timeout removed - backend-only setting
// Iframe embedding settings
const [allowEmbedding, setAllowEmbedding] = createSignal(false);
const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal('');
// Update settings
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
const [checkingForUpdates, setCheckingForUpdates] = createSignal(false);
const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable');
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
const backupPollingEnvLocked = () =>
Boolean(envOverrides()['ENABLE_BACKUP_POLLING'] || envOverrides()['BACKUP_POLLING_INTERVAL']);
const backupIntervalSelectValue = () => {
const seconds = backupPollingInterval();
return BACKUP_INTERVAL_OPTIONS.some((option) => option.value === seconds)
? String(seconds)
: 'custom';
};
const backupIntervalSummary = () => {
if (!backupPollingEnabled()) {
return 'Backup polling is disabled.';
}
const seconds = backupPollingInterval();
if (seconds <= 0) {
return 'Pulse checks backups and snapshots at the default cadence (~every 90 seconds).';
}
if (seconds % 86400 === 0) {
const days = seconds / 86400;
return `Pulse checks backups every ${days === 1 ? 'day' : `${days} days`}.`;
}
if (seconds % 3600 === 0) {
const hours = seconds / 3600;
return `Pulse checks backups every ${hours === 1 ? 'hour' : `${hours} hours`}.`;
}
const minutes = Math.max(1, Math.round(seconds / 60));
return `Pulse checks backups every ${minutes === 1 ? 'minute' : `${minutes} minutes`}.`;
};
// Diagnostics
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
const [runningDiagnostics, setRunningDiagnostics] = createSignal(false);
const [proxyActionLoading, setProxyActionLoading] = createSignal<'register-nodes' | null>(null);
const [proxyRegisterSummary, setProxyRegisterSummary] = createSignal<ProxyRegisterNode[] | null>(null);
const [dockerActionLoading, setDockerActionLoading] = createSignal<string | null>(null);
const [dockerMigrationResults, setDockerMigrationResults] = createSignal<Record<string, DockerMigrationResult>>({});
// Security
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatusInfo | null>(null);
const [securityStatusLoading, setSecurityStatusLoading] = createSignal(true);
const [exportPassphrase, setExportPassphrase] = createSignal('');
const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false);
const [importPassphrase, setImportPassphrase] = createSignal('');
const [importFile, setImportFile] = createSignal<File | null>(null);
const [showExportDialog, setShowExportDialog] = createSignal(false);
const [showImportDialog, setShowImportDialog] = createSignal(false);
const [showApiTokenModal, setShowApiTokenModal] = createSignal(false);
const [apiTokenInput, setApiTokenInput] = createSignal('');
const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>(
null,
);
const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false);
const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false);
const formatRelativeTime = (timestamp?: number) => {
if (!timestamp) {
return '';
}
const delta = Date.now() - timestamp;
if (delta < 0) {
return 'just now';
}
const seconds = Math.round(delta / 1000);
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return new Date(timestamp).toLocaleString();
};
const formatUptime = (seconds: number) => {
if (!seconds || seconds <= 0) {
return 'Unknown';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${Math.floor(seconds)}s`;
};
const runDiagnostics = async () => {
setRunningDiagnostics(true);
try {
const response = await fetch('/api/diagnostics');
const diag = await response.json();
setDiagnosticsData(diag);
} catch (err) {
console.error('Failed to fetch diagnostics:', err);
showError('Failed to run diagnostics');
} finally {
setRunningDiagnostics(false);
}
};
const handleRegisterProxyNodes = async () => {
if (proxyActionLoading()) return;
setProxyActionLoading('register-nodes');
try {
const response = await fetch('/api/diagnostics/temperature-proxy/register-nodes', {
method: 'POST',
});
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success !== true) {
const message = data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes';
showError(message);
return;
}
const nodes = Array.isArray(data.nodes)
? (data.nodes as Array<Record<string, unknown>>).map((node) => ({
name: typeof node.name === 'string' ? node.name : 'unknown',
sshReady: Boolean(node.ssh_ready),
error: typeof node.error === 'string' ? node.error : undefined,
}))
: [];
setProxyRegisterSummary(nodes);
showSuccess('Queried proxy node registration state');
await runDiagnostics();
} catch (err) {
console.error('Failed to query proxy node registration state:', err);
showError('Failed to query proxy nodes');
} finally {
setProxyActionLoading(null);
}
};
const handleDockerPrepareToken = async (hostId: string) => {
if (dockerActionLoading()) return;
setDockerActionLoading(hostId);
try {
const response = await fetch('/api/diagnostics/docker/prepare-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostId }),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data || data.success !== true) {
const message = data && typeof data.message === 'string' ? data.message : 'Failed to prepare Docker token';
showError(message);
return;
}
const recordPayload = data.record as APITokenRecord | undefined;
if (!recordPayload || typeof recordPayload.id !== 'string') {
showError('Server did not return a valid token record');
return;
}
const tokenRecord: APITokenRecord = {
id: recordPayload.id,
name: recordPayload.name,
prefix: recordPayload.prefix,
suffix: recordPayload.suffix,
createdAt: recordPayload.createdAt,
lastUsedAt: recordPayload.lastUsedAt,
};
const migrationResult: DockerMigrationResult = {
token: data.token as string,
installCommand: data.installCommand as string,
systemdServiceSnippet: data.systemdServiceSnippet as string,
pulseURL: data.pulseURL as string,
record: tokenRecord,
};
setDockerMigrationResults((prev) => ({
...prev,
[hostId]: migrationResult,
}));
showTokenReveal({
token: migrationResult.token,
record: tokenRecord,
source: 'docker',
note: 'Copy this token into the install command shown in Diagnostics.',
});
const hostName = data.host && typeof data.host.name === 'string' ? data.host.name : hostId;
showSuccess(`Generated dedicated token for ${hostName}`);
await runDiagnostics();
} catch (err) {
console.error('Failed to prepare Docker token', err);
showError('Failed to prepare Docker token');
} finally {
setDockerActionLoading(null);
}
};
const handleCopy = async (text: string, successMessage: string) => {
const success = await copyToClipboard(text);
if (success) {
showSuccess(successMessage);
} else {
showError('Failed to copy to clipboard');
}
};
const tabGroups: {
id: 'proxmox' | 'docker' | 'administration';
label: string;
items: { id: SettingsTab; label: string; icon: JSX.Element }[];
}[] = [
{
id: 'proxmox',
label: 'Proxmox',
items: [
{ id: 'pve', label: 'Proxmox VE nodes', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pbs', label: 'Proxmox Backup Server', icon: <HardDrive class="w-4 h-4" strokeWidth={2} /> },
{ id: 'pmg', label: 'Proxmox Mail Gateway', icon: <Mail class="w-4 h-4" strokeWidth={2} /> },
{ id: 'urls', label: 'Guest URLs', icon: <Link class="w-4 h-4" strokeWidth={2} /> },
],
},
{
id: 'docker',
label: 'Docker',
items: [{ id: 'docker', label: 'Docker hosts', icon: <Container class="w-4 h-4" strokeWidth={2} /> }],
},
{
id: 'administration',
label: 'Administration',
items: [
{ id: 'system', label: 'System', icon: <SettingsIcon class="w-4 h-4" strokeWidth={2} /> },
{ id: 'security', label: 'Security', icon: <Shield class="w-4 h-4" strokeWidth={2} /> },
{ id: 'diagnostics', label: 'Diagnostics', icon: <Activity class="w-4 h-4" strokeWidth={2} /> },
],
},
];
const flatTabs = tabGroups.flatMap((group) => group.items);
// Function to load nodes
const loadNodes = async () => {
try {
const nodesList = await NodesAPI.getNodes();
// Merge temperature data from WebSocket state (if available)
// state is a store object, not a function
const stateNodes = state.nodes;
const nodesWithStatus = nodesList.map((node) => {
// Find matching node in state to get temperature data
// State uses a unified 'nodes' array for all node types
// Match nodes by ID or by name (handling .lan suffix variations)
const stateNode = stateNodes?.find((n) => {
// Try exact ID match first
if (n.id === node.id) return true;
// Try exact name match
if (n.name === node.name) return true;
// Try name with/without .lan suffix
const nodeNameBase = node.name.replace(/\.lan$/, '');
const stateNameBase = n.name.replace(/\.lan$/, '');
if (nodeNameBase === stateNameBase) return true;
// Also check if state node ID contains the config node name
if (n.id.includes(node.name) || node.name.includes(n.name)) return true;
return false;
});
const mergedNode = {
...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),
// Merge temperature data from state
temperature: stateNode?.temperature || node.temperature,
};
return mergedNode;
});
setNodes(nodesWithStatus);
} catch (error) {
console.error('Failed to load nodes:', error);
// If we get a 429 or network error, retry after a delay
if (
error instanceof Error &&
(error.message.includes('429') || error.message.includes('fetch'))
) {
console.log('Retrying node load after delay...');
setTimeout(() => loadNodes(), 3000);
}
}
};
// Function to load discovered nodes
const loadSecurityStatus = async () => {
setSecurityStatusLoading(true);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/status');
if (response.ok) {
const status = await response.json();
console.log('Security status loaded:', status);
setSecurityStatus(status);
} else {
console.error('Failed to fetch security status:', response.status);
}
} catch (err) {
console.error('Failed to fetch security status:', err);
} finally {
setSecurityStatusLoading(false);
}
};
const updateDiscoveredNodesFromServers = (
servers: RawDiscoveredServer[] | undefined | null,
options: { merge?: boolean } = {},
) => {
const { merge = false } = options;
if (!servers || servers.length === 0) {
if (!merge) {
setDiscoveredNodes([]);
}
return;
}
// Prepare sets of configured hosts and cluster member IPs to filter duplicates
const configuredHosts = new Set<string>();
const clusterMemberIPs = new Set<string>();
nodes().forEach((n) => {
const cleanedHost = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
configuredHosts.add(cleanedHost.toLowerCase());
if (
n.type === 'pve' &&
'isCluster' in n &&
n.isCluster &&
'clusterEndpoints' in n &&
n.clusterEndpoints
) {
n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => {
if (endpoint.IP) {
clusterMemberIPs.add(endpoint.IP.toLowerCase());
}
if (endpoint.Host) {
clusterMemberIPs.add(endpoint.Host.toLowerCase());
}
});
}
});
const recognizedTypes = ['pve', 'pbs', 'pmg'] as const;
type RecognizedType = (typeof recognizedTypes)[number];
const isRecognizedType = (value: string): value is RecognizedType =>
(recognizedTypes as readonly string[]).includes(value);
const normalized = servers
.map((server): DiscoveredServer | null => {
const ip = (server.ip || '').trim();
let type = (server.type || '').toLowerCase();
const hostname = (server.hostname || server.name || '').trim();
const version = (server.version || '').trim();
const release = (server.release || '').trim();
if (!isRecognizedType(type)) {
const metadata = `${hostname} ${version} ${release}`.toLowerCase();
if (metadata.includes('pmg') || metadata.includes('mail gateway')) {
type = 'pmg';
} else if (metadata.includes('pbs') || metadata.includes('backup server')) {
type = 'pbs';
} else if (metadata.includes('pve') || metadata.includes('virtual environment')) {
type = 'pve';
}
}
if (!ip || !isRecognizedType(type)) {
return null;
}
const port =
typeof server.port === 'number'
? server.port
: type === 'pbs'
? 8007
: 8006;
return {
ip,
port,
type,
version: version || 'Unknown',
hostname: hostname || undefined,
release: release || undefined,
};
})
.filter((server): server is DiscoveredServer => server !== null);
const filtered = normalized.filter((server) => {
const serverIP = server.ip.toLowerCase();
const serverHostname = server.hostname?.toLowerCase();
if (
configuredHosts.has(serverIP) ||
(serverHostname && configuredHosts.has(serverHostname))
) {
return false;
}
if (
clusterMemberIPs.has(serverIP) ||
(serverHostname && clusterMemberIPs.has(serverHostname))
) {
return false;
}
return true;
});
if (merge) {
setDiscoveredNodes((prev) => {
const existingMap = new Map(prev.map((item) => [`${item.ip}:${item.port}`, item]));
filtered.forEach((server) => {
existingMap.set(`${server.ip}:${server.port}`, server);
});
return Array.from(existingMap.values());
});
} else {
setDiscoveredNodes(filtered);
}
setDiscoveryScanStatus((prev) => ({
...prev,
lastResultAt: Date.now(),
}));
};
const loadDiscoveredNodes = async () => {
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/discover');
if (response.ok) {
const data = await response.json();
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
setDiscoveryScanStatus((prev) => ({
...prev,
lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(),
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
} else {
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus((prev) => ({
...prev,
lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : prev.lastResultAt,
errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
}
}
} catch (error) {
console.error('Failed to load discovered nodes:', error);
}
};
const triggerDiscoveryScan = async (options: { quiet?: boolean } = {}) => {
const { quiet = false } = options;
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: true,
subnet: discoverySubnet() || prev.subnet,
lastScanStartedAt: Date.now(),
errors: undefined,
}));
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subnet: discoverySubnet() || 'auto' }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Discovery request failed');
}
if (!quiet) {
notificationStore.info('Discovery scan started', 2000);
}
} catch (error) {
console.error('Failed to start discovery scan:', error);
notificationStore.error('Failed to start discovery scan');
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: false,
}));
}
};
const handleDiscoveryEnabledChange = async (enabled: boolean): Promise<boolean> => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
return false;
}
const previousEnabled = discoveryEnabled();
const previousSubnet = discoverySubnet();
let subnetToSend = discoverySubnet();
if (enabled) {
if (discoveryMode() === 'custom') {
const trimmedDraft = discoverySubnetDraft().trim();
if (!trimmedDraft) {
setDiscoverySubnetError('Enter at least one subnet before enabling discovery');
notificationStore.error('Enter at least one subnet before enabling discovery');
return false;
}
if (!isValidCIDR(trimmedDraft)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
notificationStore.error('Enter valid CIDR subnet values before enabling discovery');
return false;
}
const normalizedDraft = normalizeSubnetList(trimmedDraft);
setDiscoverySubnetDraft(normalizedDraft);
setDiscoverySubnetError(undefined);
subnetToSend = normalizedDraft;
} else {
subnetToSend = 'auto';
setDiscoverySubnetError(undefined);
}
}
setDiscoveryEnabled(enabled);
setSavingDiscoverySettings(true);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: enabled,
discoverySubnet: subnetToSend,
});
applySavedDiscoverySubnet(subnetToSend);
if (enabled && subnetToSend !== 'auto') {
setLastCustomSubnet(subnetToSend);
}
if (enabled) {
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery enabled — scanning network...', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: false,
}));
}
return true;
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(previousEnabled);
applySavedDiscoverySubnet(previousSubnet);
return false;
} finally {
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
};
const commitDiscoverySubnet = async (rawValue: string): Promise<boolean> => {
if (envOverrides().discoverySubnet) {
return false;
}
const value = rawValue.trim();
if (!value) {
setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)');
return false;
}
if (!isValidCIDR(value)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
return false;
}
const normalizedValue = normalizeSubnetList(value);
if (!normalizedValue) {
setDiscoverySubnetError('Enter at least one valid subnet in CIDR format');
return false;
}
const previousSubnet = discoverySubnet();
const previousNormalized =
previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet);
if (normalizedValue === previousNormalized) {
setDiscoverySubnetDraft(normalizedValue);
setDiscoverySubnetError(undefined);
setLastCustomSubnet(normalizedValue);
return true;
}
setSavingDiscoverySettings(true);
try {
setDiscoverySubnetError(undefined);
await SettingsAPI.updateSystemSettings({
discoveryEnabled: discoveryEnabled(),
discoverySubnet: normalizedValue,
});
setLastCustomSubnet(normalizedValue);
applySavedDiscoverySubnet(normalizedValue);
if (discoveryEnabled()) {
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery subnet updated — scanning network...', 2000);
} else {
notificationStore.success('Discovery subnet saved', 2000);
}
return true;
} catch (error) {
console.error('Failed to update discovery subnet:', error);
notificationStore.error('Failed to update discovery subnet');
applySavedDiscoverySubnet(previousSubnet);
setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet));
return false;
} finally {
setDiscoverySubnetError(undefined);
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
};
const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => {
if (envOverrides().discoverySubnet || savingDiscoverySettings()) {
return;
}
if (mode === discoveryMode()) {
return;
}
if (mode === 'auto') {
const previousSubnet = discoverySubnet();
setDiscoveryMode('auto');
setDiscoverySubnetDraft('');
setDiscoverySubnetError(undefined);
setSavingDiscoverySettings(true);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: discoveryEnabled(),
discoverySubnet: 'auto',
});
applySavedDiscoverySubnet('auto');
if (discoveryEnabled()) {
await triggerDiscoveryScan({ quiet: true });
}
notificationStore.info(
'Auto discovery scans each network phase. Large networks may take longer.',
4000,
);
} catch (error) {
console.error('Failed to update discovery subnet:', error);
notificationStore.error('Failed to update discovery subnet');
applySavedDiscoverySubnet(previousSubnet);
} finally {
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
return;
}
setDiscoveryMode('custom');
const rawDraft =
discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || '';
const normalizedDraft = normalizeSubnetList(rawDraft);
setDiscoverySubnetDraft(normalizedDraft);
setDiscoverySubnetError(undefined);
queueMicrotask(() => {
discoverySubnetInputRef?.focus();
discoverySubnetInputRef?.select();
});
};
// Load nodes and system settings on mount
onMount(async () => {
// Subscribe to events
const unsubscribeAutoRegister = eventBus.on('node_auto_registered', () => {
// Close any open modals
setShowNodeModal(false);
setEditingNode(null);
// Reload nodes
loadNodes();
loadDiscoveredNodes();
});
const unsubscribeRefresh = eventBus.on('refresh_nodes', () => {
loadNodes();
});
const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => {
if (!data) {
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: false,
}));
return;
}
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], {
merge: !!data.immediate,
});
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: data.scanning ?? prev.scanning,
lastResultAt: data.timestamp ?? Date.now(),
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
} else if (!data.immediate) {
// Ensure we clear stale results when the update explicitly reports no servers
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: data.scanning ?? prev.scanning,
lastResultAt: data.timestamp ?? prev.lastResultAt,
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
} else {
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: data.scanning ?? prev.scanning,
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
}
});
const unsubscribeDiscoveryStatus = eventBus.on('discovery_status', (data) => {
if (!data) {
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: false,
}));
return;
}
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: !!data.scanning,
subnet: data.subnet || prev.subnet,
lastScanStartedAt: data.scanning ? (data.timestamp ?? Date.now()) : prev.lastScanStartedAt,
lastResultAt: !data.scanning && data.timestamp ? data.timestamp : prev.lastResultAt,
}));
if (typeof data.subnet === 'string' && data.subnet !== discoverySubnet()) {
applySavedDiscoverySubnet(data.subnet);
}
});
// Poll for node updates when modal is open
let pollInterval: ReturnType<typeof setInterval> | undefined;
createEffect(() => {
// Clear any existing interval first
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = undefined;
}
if (showNodeModal()) {
// Start polling every 3 seconds when modal is open
pollInterval = setInterval(() => {
loadNodes();
loadDiscoveredNodes();
}, 3000);
}
});
// Poll for discovered nodes every 30 seconds
const discoveryInterval = setInterval(() => {
loadDiscoveredNodes();
}, 30000);
// Clean up on unmount
onCleanup(() => {
unsubscribeAutoRegister();
unsubscribeRefresh();
unsubscribeDiscovery();
unsubscribeDiscoveryStatus();
if (pollInterval) {
clearInterval(pollInterval);
}
clearInterval(discoveryInterval);
});
try {
// Load data with small delays to prevent rate limit bursts
// Load security status first as it's lightweight
await loadSecurityStatus();
// Small delay to prevent burst
await new Promise((resolve) => setTimeout(resolve, 50));
// Load nodes
await loadNodes();
// Another small delay
await new Promise((resolve) => setTimeout(resolve, 50));
// Load discovered nodes
await loadDiscoveredNodes();
// Load system settings
try {
const systemResponse = await fetch('/api/config/system');
if (systemResponse.ok) {
const systemSettings = await systemResponse.json();
// PBS polling interval is now fixed at 10 seconds
setAllowedOrigins(systemSettings.allowedOrigins || '*');
// Connection timeout is backend-only
// Load discovery settings
// Backend defaults to false, so we should respect that
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); // Default to false if undefined
applySavedDiscoverySubnet(systemSettings.discoverySubnet);
// Load embedding settings
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
// Backup polling controls
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
} else {
setBackupPollingEnabled(true);
}
const intervalSeconds =
typeof systemSettings.backupPollingInterval === 'number'
? Math.max(0, Math.floor(systemSettings.backupPollingInterval))
: 0;
setBackupPollingInterval(intervalSeconds);
if (intervalSeconds > 0) {
setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60)));
}
// Load auto-update settings
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00');
if (systemSettings.updateChannel) {
setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc');
}
// Track environment variable overrides
if (systemSettings.envOverrides) {
setEnvOverrides(systemSettings.envOverrides);
}
} else {
// Fallback to old endpoint
await SettingsAPI.getSettings();
}
} catch (error) {
console.error('Failed to load settings:', error);
}
// Load version information
try {
const version = await UpdatesAPI.getVersion();
setVersionInfo(version);
// Also set it in the store so it's available globally
updateStore.checkForUpdates(); // This will load version info too
if (version.channel) {
setUpdateChannel(version.channel as 'stable' | 'rc');
}
} catch (error) {
console.error('Failed to load version:', error);
}
} catch (error) {
console.error('Failed to load configuration:', error);
} finally {
// Mark initial load as complete even if there were errors
setInitialLoadComplete(true);
}
});
// Re-merge temperature data from WebSocket state when it updates
createEffect(
on(
() => state.nodes,
(stateNodes) => {
const currentNodes = nodes();
// Only run if we have nodes loaded and state has data
if (stateNodes && stateNodes.length > 0 && currentNodes.length > 0) {
const updatedNodes = currentNodes.map((node) => {
// Match nodes by ID or by name (handling .lan suffix variations)
const stateNode = stateNodes.find((n) => {
// Try exact ID match first
if (n.id === node.id) return true;
// Try exact name match
if (n.name === node.name) return true;
// Try name with/without .lan suffix
const nodeNameBase = node.name.replace(/\.lan$/, '');
const stateNameBase = n.name.replace(/\.lan$/, '');
if (nodeNameBase === stateNameBase) return true;
// Also check if state node ID contains the config node name
if (n.id.includes(node.name) || node.name.includes(n.name)) return true;
return false;
});
// Merge temperature data from state if available
if (stateNode?.temperature) {
return { ...node, temperature: stateNode.temperature };
}
return node;
});
setNodes(updatedNodes);
}
},
),
);
const saveSettings = async () => {
try {
if (activeTab() === 'system') {
// Save system settings using typed API
await SettingsAPI.updateSystemSettings({
// PBS polling interval is now fixed at 10 seconds
allowedOrigins: allowedOrigins(),
// Connection timeout is backend-only
// Discovery settings are saved immediately on toggle
updateChannel: updateChannel(),
autoUpdateEnabled: autoUpdateEnabled(),
autoUpdateCheckInterval: autoUpdateCheckInterval(),
autoUpdateTime: autoUpdateTime(),
backupPollingEnabled: backupPollingEnabled(),
backupPollingInterval: backupPollingInterval(),
allowEmbedding: allowEmbedding(),
allowedEmbedOrigins: allowedEmbedOrigins(),
});
}
showSuccess('Settings saved successfully. Service restart may be required for port changes.');
setHasUnsavedChanges(false);
// Reload the page after a short delay to ensure the new settings are applied
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to save settings');
}
};
const nodePendingDeleteLabel = () => {
const node = nodePendingDelete();
if (!node) return '';
return node.displayName || node.name || node.host || node.id;
};
const nodePendingDeleteHost = () => nodePendingDelete()?.host || '';
const nodePendingDeleteType = () => nodePendingDelete()?.type || '';
const nodePendingDeleteTypeLabel = () => {
switch (nodePendingDeleteType()) {
case 'pve':
return 'Proxmox VE node';
case 'pbs':
return 'Proxmox Backup Server';
case 'pmg':
return 'Proxmox Mail Gateway';
default:
return 'Pulse node';
}
};
const requestDeleteNode = (node: NodeConfigWithStatus) => {
setNodePendingDelete(node);
setShowDeleteNodeModal(true);
};
const cancelDeleteNode = () => {
if (deleteNodeLoading()) return;
setShowDeleteNodeModal(false);
setNodePendingDelete(null);
};
const deleteNode = async () => {
const pending = nodePendingDelete();
if (!pending) return;
setDeleteNodeLoading(true);
try {
await NodesAPI.deleteNode(pending.id);
setNodes(nodes().filter((n) => n.id !== pending.id));
const label = pending.displayName || pending.name || pending.host || pending.id;
showSuccess(`${label} removed successfully`);
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to delete node');
} finally {
setDeleteNodeLoading(false);
setShowDeleteNodeModal(false);
setNodePendingDelete(null);
}
};
const testNodeConnection = async (nodeId: string) => {
try {
const node = nodes().find((n) => n.id === nodeId);
if (!node) {
throw new Error('Node not found');
}
// Use the existing node test endpoint which uses stored credentials
const result = await NodesAPI.testExistingNode(nodeId);
if (result.status === 'success') {
showSuccess(result.message || 'Connection successful');
} else {
throw new Error(result.message || 'Connection failed');
}
} catch (error) {
showError(error instanceof Error ? error.message : 'Connection test failed');
}
};
const checkForUpdates = async () => {
setCheckingForUpdates(true);
try {
// Force check with current channel selection
await updateStore.checkForUpdates(true);
const info = updateStore.updateInfo();
setUpdateInfo(info);
// If update was dismissed, clear it so user can see it again
if (info?.available && updateStore.isDismissed()) {
updateStore.clearDismissed();
}
if (!info?.available) {
showSuccess('You are running the latest version');
}
} catch (error) {
showError('Failed to check for updates');
console.error('Update check error:', error);
} finally {
setCheckingForUpdates(false);
}
};
const handleExport = async () => {
if (!exportPassphrase()) {
const hasAuth = securityStatus()?.hasAuthentication;
showError(
hasAuth
? useCustomPassphrase()
? 'Please enter a passphrase'
: 'Please enter your password'
: 'Please enter a passphrase',
);
return;
}
// Just require a passphrase to be entered
const hasAuth = securityStatus()?.hasAuthentication;
if ((!hasAuth || useCustomPassphrase()) && !exportPassphrase()) {
showError('Please enter a passphrase');
return;
}
// Only check for API token if user is not authenticated via password
// If user is logged in with password, session auth is sufficient
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (
!hasPasswordAuth &&
securityStatus()?.apiTokenConfigured &&
!localStorage.getItem('apiToken')
) {
setApiTokenModalSource('export');
setShowApiTokenModal(true);
return;
}
try {
// Get CSRF token from cookie
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('pulse_csrf='))
?.split('=')[1];
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// Add CSRF token if available
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
// Add API token if configured
const apiToken = localStorage.getItem('apiToken');
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const response = await fetch('/api/config/export', {
method: 'POST',
headers,
credentials: 'include', // Include cookies for session auth
body: JSON.stringify({ passphrase: exportPassphrase() }),
});
if (!response.ok) {
const errorText = await response.text();
// Handle authentication errors
if (response.status === 401 || response.status === 403) {
// Check if we're using API token auth (not password auth)
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth) {
// Clear invalid token if we had one
const hadToken = localStorage.getItem('apiToken');
if (hadToken) {
localStorage.removeItem('apiToken');
showError('Invalid or expired API token. Please re-enter.');
setApiTokenModalSource('export');
setShowApiTokenModal(true);
return;
}
if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) {
setApiTokenModalSource('export');
setShowApiTokenModal(true);
return;
}
}
throw new Error('Export requires authentication');
}
throw new Error(errorText || 'Export failed');
}
const data = await response.json();
// Create and download file
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;
a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess('Configuration exported successfully');
setShowExportDialog(false);
setExportPassphrase('');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to export configuration';
showError(errorMessage);
console.error('Export error:', error);
}
};
const handleImport = async () => {
if (!importPassphrase()) {
showError('Please enter the password');
return;
}
if (!importFile()) {
showError('Please select a file to import');
return;
}
// Only check for API token if user is not authenticated via password
// If user is logged in with password, session auth is sufficient
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (
!hasPasswordAuth &&
securityStatus()?.apiTokenConfigured &&
!localStorage.getItem('apiToken')
) {
setApiTokenModalSource('import');
setShowApiTokenModal(true);
return;
}
try {
const fileContent = await importFile()!.text();
let exportData;
try {
exportData = JSON.parse(fileContent);
} catch (parseError) {
showError('Invalid JSON file format');
console.error('JSON parse error:', parseError);
return;
}
// Get CSRF token from cookie
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('pulse_csrf='))
?.split('=')[1];
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// Add CSRF token if available
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
// Add API token if configured
const apiToken = localStorage.getItem('apiToken');
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const response = await fetch('/api/config/import', {
method: 'POST',
headers,
credentials: 'include', // Include cookies for session auth
body: JSON.stringify({
passphrase: importPassphrase(),
data: exportData.data,
}),
});
if (!response.ok) {
const errorText = await response.text();
// Handle authentication errors
if (response.status === 401 || response.status === 403) {
// Check if we're using API token auth (not password auth)
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth) {
// Clear invalid token if we had one
const hadToken = localStorage.getItem('apiToken');
if (hadToken) {
localStorage.removeItem('apiToken');
showError('Invalid or expired API token. Please re-enter.');
setApiTokenModalSource('import');
setShowApiTokenModal(true);
return;
}
if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) {
setApiTokenModalSource('import');
setShowApiTokenModal(true);
return;
}
}
throw new Error('Import requires authentication');
}
throw new Error(errorText || 'Import failed');
}
showSuccess('Configuration imported successfully. Reloading...');
setShowImportDialog(false);
setImportPassphrase('');
setImportFile(null);
// Reload page to apply new configuration
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
showError('Failed to import configuration');
console.error('Import error:', error);
}
};
return (
<>
<div class="space-y-4">
{/* Header with better styling */}
<Card padding="md">
<SectionHeader
title={headerMeta().title}
description={headerMeta().description}
size="lg"
/>
</Card>
{/* Save notification bar - only show when there are unsaved changes */}
<Show
when={
hasUnsavedChanges() &&
(activeTab() === 'pve' || activeTab() === 'pbs' || activeTab() === 'system')
}
>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span class="text-sm font-medium">You have unsaved changes</span>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button
type="button"
class="flex-1 sm:flex-initial px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={saveSettings}
>
Save Changes
</button>
<button
type="button"
class="flex-1 sm:flex-initial px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
onClick={() => {
window.location.reload();
}}
>
Discard
</button>
</div>
</div>
</div>
</Show>
<Card padding="none" class="relative lg:flex">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
onMouseEnter={() => setSidebarCollapsed(false)}
onMouseLeave={() => setSidebarCollapsed(true)}
aria-label="Settings navigation"
aria-expanded={!sidebarCollapsed()}
>
<div class={`sticky top-24 ${sidebarCollapsed() ? 'px-2' : 'px-5'} py-6 space-y-6 transition-all duration-300`}>
<div id="settings-sidebar-menu" class="space-y-6">
<For each={tabGroups}>
{(group) => (
<div class="space-y-2">
<Show when={!sidebarCollapsed()}>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.label}
</p>
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
activeTab() === item.id
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
}`}
onClick={() => setActiveTab(item.id)}
title={sidebarCollapsed() ? item.label : undefined}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</div>
<div class="flex-1">
<Show when={flatTabs.length > 0}>
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
<div class="p-1">
<div
class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5 w-full overflow-x-auto scrollbar-hide"
style="-webkit-overflow-scrolling: touch;"
>
<For each={flatTabs}>
{(tab) => (
<button
type="button"
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
activeTab() === tab.id
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<div class="p-3 sm:p-6">
{/* PVE Nodes Tab */}
<Show when={activeTab() === 'pve'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
</div>
</Show>
<Show when={initialLoadComplete()}>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox VE nodes" size="md" class="flex-1" />
<div class="flex flex-wrap gap-2 items-center justify-end">
{/* Discovery toggle */}
<div
class="flex items-center gap-2 sm:gap-3"
title="Enable automatic discovery of Proxmox servers on your network"
>
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
Discovery
</span>
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await handleDiscoveryEnabledChange(
e.currentTarget.checked,
);
if (!success) {
e.currentTarget.checked = discoveryEnabled();
}
}}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={discoveryEnabled()}>
<button
type="button"
onClick={async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await triggerDiscoveryScan({ quiet: true });
} finally {
await loadDiscoveredNodes();
}
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
<button
type="button"
onClick={() => {
setEditingNode(null);
setCurrentNodeType('pve');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">Add PVE Node</span>
</button>
</div>
</div>
<div class="grid gap-4">
<For each={nodes().filter((n) => n.type === 'pve')}>
{(node) => (
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div
class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${(() => {
// Find the corresponding node in the WebSocket state
const stateNode = state.nodes.find(
(n) => n.instance === node.name,
);
// Check if the node has an unhealthy connection or is offline
if (
stateNode?.connectionHealth === 'unhealthy' ||
stateNode?.connectionHealth === 'error' ||
stateNode?.status === 'offline' ||
stateNode?.status === 'disconnected'
) {
return 'bg-red-500';
}
// Check if connection is degraded (partial cluster connectivity)
if (stateNode?.connectionHealth === 'degraded') {
return 'bg-yellow-500';
}
// Check if we have a healthy connection
if (
stateNode &&
(stateNode.status === 'online' ||
stateNode.connectionHealth === 'healthy')
) {
return 'bg-green-500';
}
// Fall back to the last known config status if live data hasn't arrived yet
if (node.status === 'connected') {
return 'bg-green-500';
}
if (
node.status === 'error' ||
node.status === 'offline' ||
node.status === 'disconnected'
) {
return 'bg-red-500';
}
if (node.status === 'pending') {
return 'bg-amber-500 animate-pulse';
}
return 'bg-gray-400';
})()}`}
></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{node.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{node.user
? `User: ${node.user}`
: `Token: ${node.tokenName}`}
</span>
{node.type === 'pve' &&
'monitorVMs' in node &&
node.monitorVMs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
VMs
</span>
)}
{node.type === 'pve' &&
'monitorContainers' in node &&
node.monitorContainers && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Containers
</span>
)}
{node.type === 'pve' &&
'monitorStorage' in node &&
node.monitorStorage && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Storage
</span>
)}
{node.type === 'pve' &&
'monitorBackups' in node &&
node.monitorBackups && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Backups
</span>
)}
{node.type === 'pve' &&
'monitorPhysicalDisks' in node &&
node.monitorPhysicalDisks && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Physical Disks
</span>
)}
{node.type === 'pve' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
<Show
when={
node.type === 'pve' && 'isCluster' in node && node.isCluster
}
>
<div class="mt-3 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="text-gray-600 dark:text-gray-400"
>
<circle
cx="12"
cy="12"
r="3"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<circle
cx="4"
cy="12"
r="2"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<circle
cx="20"
cy="12"
r="2"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<line
x1="7"
y1="12"
x2="9"
y2="12"
stroke="currentColor"
stroke-width="2"
/>
<line
x1="15"
y1="12"
x2="17"
y2="12"
stroke="currentColor"
stroke-width="2"
/>
</svg>
<span class="font-semibold text-gray-700 dark:text-gray-300">
{'clusterName' in node ? node.clusterName : 'Unknown'}{' '}
Cluster
</span>
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded-full ml-auto">
{'clusterEndpoints' in node && node.clusterEndpoints
? node.clusterEndpoints.length
: 0}{' '}
nodes
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For
each={
'clusterEndpoints' in node ? node.clusterEndpoints : []
}
>
{(endpoint) => (
<div class="flex items-center gap-2 text-xs bg-white dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700">
<div
class={`w-2 h-2 rounded-full flex-shrink-0 ${endpoint.Online ? 'bg-green-500' : 'bg-gray-400'}`}
></div>
<span class="font-medium text-gray-700 dark:text-gray-300">
{endpoint.NodeName}
</span>
<span class="text-gray-500 dark:text-gray-500 ml-auto">
{endpoint.IP}
</span>
</div>
)}
</For>
</div>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Automatic failover enabled between cluster nodes
</p>
</div>
</Show>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(node);
setCurrentNodeType(node.type as 'pve' | 'pbs' | 'pmg');
setShowNodeModal(true);
}}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
)}
</For>
{nodes().filter((n) => n.type === 'pve').length === 0 &&
discoveredNodes().filter((n) => n.type === 'pve').length === 0 && (
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PVE nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring</p>
</div>
)}
{/* Discovered PVE nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Scanning your network for Proxmox VE servers</span>
</span>
</Show>
<Show
when={
!discoveryScanStatus().scanning &&
(discoveryScanStatus().lastResultAt ||
discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{formatRelativeTime(
discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt,
)}
</span>
</Show>
</div>
<Show
when={
discoveryScanStatus().errors && discoveryScanStatus().errors!.length
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
<Show
when={
discoveryMode() === 'auto' &&
(discoveryScanStatus().errors || []).some((err) =>
/timed out|timeout/i.test(err),
)
}
>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet
for faster, targeted scans.
</p>
</Show>
</div>
</Show>
<Show
when={
discoveryScanStatus().scanning &&
discoveredNodes().filter((n) => n.type === 'pve').length === 0
}
>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>
Waiting for responses this can take up to a minute depending on your
network size.
</span>
</div>
</Show>
<For each={discoveredNodes().filter((n) => n.type === 'pve')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pve',
name: server.hostname || `pve-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
monitorPhysicalDisks: false,
status: 'pending',
} as NodeConfigWithStatus);
setCurrentNodeType('pve');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Proxmox VE at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="text-gray-400 mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
{/* PBS Nodes Tab */}
<Show when={activeTab() === 'pbs'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
</div>
</Show>
<Show when={initialLoadComplete()}>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox Backup Server nodes" size="md" class="flex-1" />
<div class="flex flex-wrap gap-2 items-center justify-end">
{/* Discovery toggle */}
<div
class="flex items-center gap-2 sm:gap-3"
title="Enable automatic discovery of PBS servers on your network"
>
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
Discovery
</span>
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await handleDiscoveryEnabledChange(
e.currentTarget.checked,
);
if (!success) {
e.currentTarget.checked = discoveryEnabled();
}
}}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={discoveryEnabled()}>
<button
type="button"
onClick={async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await triggerDiscoveryScan({ quiet: true });
} finally {
await loadDiscoveredNodes();
}
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
<button
type="button"
onClick={() => {
setEditingNode(null);
setCurrentNodeType('pbs');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">Add PBS Node</span>
</button>
</div>
</div>
<div class="grid gap-4">
<For each={nodes().filter((n) => n.type === 'pbs')}>
{(node) => (
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div
class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${(() => {
// Find the corresponding PBS instance in the WebSocket state
const statePBS = state.pbs.find((p) => p.name === node.name);
// Check if the PBS has an unhealthy connection or is offline
if (
statePBS?.connectionHealth === 'unhealthy' ||
statePBS?.connectionHealth === 'error' ||
statePBS?.status === 'offline' ||
statePBS?.status === 'disconnected'
) {
return 'bg-red-500';
}
// Check if connection is degraded (not commonly used for PBS but keeping consistent)
if (statePBS?.connectionHealth === 'degraded') {
return 'bg-yellow-500';
}
// Check if we have a healthy connection
if (
statePBS &&
(statePBS.status === 'online' ||
statePBS.connectionHealth === 'healthy')
) {
return 'bg-green-500';
}
// Fall back to the last known config status if live data hasn't arrived yet
if (node.status === 'connected') {
return 'bg-green-500';
}
if (
node.status === 'error' ||
node.status === 'offline' ||
node.status === 'disconnected'
) {
return 'bg-red-500';
}
if (node.status === 'pending') {
return 'bg-amber-500 animate-pulse';
}
return 'bg-gray-400';
})()}`}
></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{node.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{node.user
? `User: ${node.user}`
: `Token: ${node.tokenName}`}
</span>
{node.type === 'pbs' &&
'monitorDatastores' in node &&
node.monitorDatastores && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Datastores
</span>
)}
{node.type === 'pbs' &&
'monitorSyncJobs' in node &&
node.monitorSyncJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Sync Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorVerifyJobs' in node &&
node.monitorVerifyJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Verify Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorPruneJobs' in node &&
node.monitorPruneJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Prune Jobs
</span>
)}
{node.type === 'pbs' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(node);
setCurrentNodeType(node.type as 'pve' | 'pbs' | 'pmg');
setShowNodeModal(true);
}}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
)}
</For>
{nodes().filter((n) => n.type === 'pbs').length === 0 &&
discoveredNodes().filter((n) => n.type === 'pbs').length === 0 && (
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PBS nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring</p>
</div>
)}
{/* Discovered PBS nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Scanning your network for Proxmox Backup Servers</span>
</span>
</Show>
<Show
when={
!discoveryScanStatus().scanning &&
(discoveryScanStatus().lastResultAt ||
discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{formatRelativeTime(
discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt,
)}
</span>
</Show>
</div>
<Show
when={
discoveryScanStatus().errors && discoveryScanStatus().errors!.length
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
<Show
when={
discoveryMode() === 'auto' &&
(discoveryScanStatus().errors || []).some((err) =>
/timed out|timeout/i.test(err),
)
}
>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet
for faster, targeted scans.
</p>
</Show>
</div>
</Show>
<Show
when={
discoveryScanStatus().scanning &&
discoveredNodes().filter((n) => n.type === 'pbs').length === 0
}
>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>
Waiting for responses this can take up to a minute depending on your
network size.
</span>
</div>
</Show>
<For each={discoveredNodes().filter((n) => n.type === 'pbs')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pbs',
name: server.hostname || `pbs-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: true,
status: 'pending',
} as NodeConfigWithStatus);
setCurrentNodeType('pbs');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Backup Server at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="text-gray-400 mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
{/* PMG Nodes Tab */}
<Show when={activeTab() === 'pmg'}>
<div class="space-y-4">
<Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8">
<span class="text-gray-500">Loading configuration...</span>
</div>
</Show>
<Show when={initialLoadComplete()}>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox Mail Gateway nodes" size="md" class="flex-1" />
<div class="flex flex-wrap gap-2 items-center justify-end">
{/* Discovery toggle */}
<div
class="flex items-center gap-2 sm:gap-3"
title="Enable automatic discovery of PMG servers on your network"
>
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
Discovery
</span>
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await handleDiscoveryEnabledChange(
e.currentTarget.checked,
);
if (!success) {
e.currentTarget.checked = discoveryEnabled();
}
}}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={discoveryEnabled()}>
<button
type="button"
onClick={async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await triggerDiscoveryScan({ quiet: true });
} finally {
await loadDiscoveredNodes();
}
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
<button
type="button"
onClick={() => {
setEditingNode(null);
setCurrentNodeType('pmg');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">Add PMG Node</span>
</button>
</div>
</div>
<div class="grid gap-4">
<For each={nodes().filter((n) => n.type === 'pmg')}>
{(node) => {
const pmgNode = node as NodeConfigWithStatus & {
monitorMailStats?: boolean;
monitorQueues?: boolean;
monitorQuarantine?: boolean;
monitorDomainStats?: boolean;
};
const statePMG = state.pmg.find((p) => p.name === pmgNode.name);
const statusClass = (() => {
if (
statePMG?.connectionHealth === 'unhealthy' ||
statePMG?.connectionHealth === 'error' ||
statePMG?.status === 'offline' ||
statePMG?.status === 'disconnected'
) {
return 'bg-red-500';
}
if (statePMG?.connectionHealth === 'degraded') {
return 'bg-yellow-500';
}
if (statePMG && (statePMG.status === 'online' || statePMG.connectionHealth === 'healthy')) {
return 'bg-green-500';
}
if (pmgNode.status === 'connected') {
return 'bg-green-500';
}
if (
pmgNode.status === 'error' ||
pmgNode.status === 'offline' ||
pmgNode.status === 'disconnected'
) {
return 'bg-red-500';
}
if (pmgNode.status === 'pending') {
return 'bg-amber-500 animate-pulse';
}
return 'bg-gray-400';
})();
return (
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${statusClass}`}></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{pmgNode.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{pmgNode.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{pmgNode.user ? `User: ${pmgNode.user}` : `Token: ${pmgNode.tokenName}`}
</span>
{pmgNode.monitorMailStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Mail stats
</span>
)}
{pmgNode.monitorQueues && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Queues
</span>
)}
{pmgNode.monitorQuarantine && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Quarantine
</span>
)}
{pmgNode.monitorDomainStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Domain stats
</span>
)}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(pmgNode.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(nodes().find((n) => n.id === pmgNode.id) ?? null);
setCurrentNodeType('pmg');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
}}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Edit node"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(pmgNode)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
);
}}
</For>
{nodes().filter((n) => n.type === 'pmg').length === 0 && (
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PMG nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring mail flow</p>
</div>
)}
{/* Discovered PMG nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path d="M12 3v6m0 6v6m6-9h-6m-6 0H3" />
</svg>
Scanning network...
</span>
</Show>
<Show
when={
!discoveryScanStatus().scanning &&
(discoveryScanStatus().lastResultAt ||
discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{formatRelativeTime(
discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt,
)}
</span>
</Show>
</div>
<Show
when={
discoveryScanStatus().errors && discoveryScanStatus().errors!.length
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
<Show
when={
discoveryMode() === 'auto' &&
(discoveryScanStatus().errors || []).some((err) =>
/timed out|timeout/i.test(err),
)
}
>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet
for faster, targeted scans.
</p>
</Show>
</div>
</Show>
<Show
when={
discoveryScanStatus().scanning &&
discoveredNodes().filter((n) => n.type === 'pmg').length === 0
}
>
<div class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<svg
class="h-8 w-8 mx-auto mb-2 animate-pulse text-purple-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<p class="text-sm">Scanning for PMG servers...</p>
</div>
</Show>
<For each={discoveredNodes().filter((n) => n.type === 'pmg')}>
{(server) => (
<div
class="bg-gradient-to-r from-purple-50 to-transparent dark:from-purple-900/20 dark:to-transparent border-l-4 border-purple-500 rounded-lg p-4 cursor-pointer hover:shadow-md transition-all"
onClick={() => {
setEditingNode(null);
setCurrentNodeType('pmg');
setModalResetKey((prev) => prev + 1);
setShowNodeModal(true);
setTimeout(() => {
const hostInput = document.querySelector(
'input[placeholder*="192.168"]',
) as HTMLInputElement;
if (hostInput) {
hostInput.value = server.ip;
hostInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 50);
}}
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 min-w-0">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-purple-500 flex-shrink-0 mt-0.5"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{server.hostname || `PMG at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="text-gray-400 mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
{/* Docker Tab */}
<Show when={activeTab() === 'docker'}>
<DockerAgents />
</Show>
{/* System Settings Tab */}
<Show when={activeTab() === 'system'}>
<div class="space-y-6">
<SectionHeader title="System configuration" size="md" class="mb-2" />
<Card
tone="info"
padding="md"
border={false}
class="border border-blue-200 dark:border-blue-800"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<ul class="space-y-1">
<li> Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)</li>
<li> Changes made here are saved to system.json immediately</li>
<li> Settings persist unless overridden by env vars</li>
</ul>
</div>
</div>
</Card>
<Card padding="lg" class="space-y-5">
<SectionHeader
title="Network discovery"
description="Control how Pulse scans for Proxmox services on your network."
size="sm"
align="left"
/>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Automatic scanning</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.
</p>
</div>
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await handleDiscoveryEnabledChange(e.currentTarget.checked);
if (!success) {
e.currentTarget.checked = discoveryEnabled();
}
}}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={discoveryEnabled()}>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white/40 p-3 dark:border-gray-600 dark:bg-gray-800/40">
<fieldset class="space-y-2">
<legend class="text-xs font-medium text-gray-700 dark:text-gray-300">
Scan scope
</legend>
<div class="space-y-2">
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
discoveryMode() === 'auto'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="auto"
checked={discoveryMode() === 'auto'}
onChange={async () => {
if (discoveryMode() !== 'auto') {
await handleDiscoveryModeChange('auto');
}
}}
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Auto (slower, full scan)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Scans container, local, and gateway networks. Large networks may time
out after two minutes.
</p>
</div>
</label>
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
discoveryMode() === 'custom'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="custom"
checked={discoveryMode() === 'custom'}
onChange={() => {
if (discoveryMode() !== 'custom') {
handleDiscoveryModeChange('custom');
}
}}
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Custom subnet (faster)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Limit discovery to one or more CIDR ranges to finish faster on large networks.
</p>
</div>
</label>
<Show when={discoveryMode() === 'custom'}>
<div class="flex flex-wrap items-center gap-2 pl-9 pr-2">
<span class="text-[0.68rem] uppercase tracking-wide text-gray-500 dark:text-gray-400">
Common networks:
</span>
<For each={COMMON_DISCOVERY_SUBNETS}>
{(preset) => {
const baseValue = currentDraftSubnetValue();
const currentSelections = parseSubnetList(baseValue);
const isActive = currentSelections.includes(preset);
return (
<button
type="button"
class={`rounded-full border px-2.5 py-1 text-[0.7rem] transition-colors ${
isActive
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:border-blue-300 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
}`}
onClick={async () => {
if (envOverrides().discoverySubnet) {
return;
}
let selections = [...currentSelections];
if (isActive) {
selections = selections.filter((item) => item !== preset);
} else {
selections.push(preset);
}
if (selections.length === 0) {
setDiscoverySubnetDraft('');
setLastCustomSubnet('');
setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
);
return;
}
const updatedValue = normalizeSubnetList(selections.join(', '));
setDiscoveryMode('custom');
setDiscoverySubnetDraft(updatedValue);
setLastCustomSubnet(updatedValue);
setDiscoverySubnetError(undefined);
await commitDiscoverySubnet(updatedValue);
}}
disabled={envOverrides().discoverySubnet}
classList={{
'cursor-not-allowed opacity-60': envOverrides().discoverySubnet,
}}
>
{preset}
</button>
);
}}
</For>
</div>
</Show>
</div>
</fieldset>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="discoverySubnetInput"
class="text-xs font-medium text-gray-700 dark:text-gray-300"
>
Discovery subnet
</label>
<span
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300 cursor-help"
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
<input
id="discoverySubnetInput"
ref={(el) => {
discoverySubnetInputRef = el;
}}
type="text"
value={discoverySubnetDraft()}
placeholder={
discoveryMode() === 'auto'
? 'auto (scan every network phase)'
: '192.168.1.0/24, 10.0.0.0/24'
}
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
envOverrides().discoverySubnet
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
}`}
disabled={envOverrides().discoverySubnet}
onInput={(e) => {
if (envOverrides().discoverySubnet) {
return;
}
const rawValue = e.currentTarget.value;
setDiscoverySubnetDraft(rawValue);
if (discoveryMode() !== 'custom') {
setDiscoveryMode('custom');
}
setLastCustomSubnet(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
setDiscoverySubnetError(undefined);
return;
}
if (!isValidCIDR(trimmed)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
} else {
setDiscoverySubnetError(undefined);
}
}}
onBlur={async (e) => {
if (envOverrides().discoverySubnet || discoveryMode() !== 'custom') {
return;
}
const rawValue = e.currentTarget.value;
setDiscoverySubnetDraft(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
);
return;
}
if (!isValidCIDR(trimmed)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
return;
}
setDiscoverySubnetError(undefined);
await commitDiscoverySubnet(rawValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
<Show when={discoverySubnetError()}>
<p class="text-xs text-red-600 dark:text-red-400">
{discoverySubnetError()}
</p>
</Show>
<Show when={!discoverySubnetError() && discoveryMode() === 'auto'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Auto scans every reachable network phase. Large networks may time out
switch to custom subnets to narrow the search.
</p>
</Show>
<Show when={!discoverySubnetError() && discoveryMode() === 'custom'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish faster and avoid timeouts.
</p>
</Show>
</div>
</div>
</Show>
<Show when={envOverrides().discoveryEnabled || envOverrides().discoverySubnet}>
<div class="rounded-lg border border-amber-200 bg-amber-100/80 p-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Discovery settings are locked by environment variables. Update the service
configuration and restart Pulse to change them here.
</div>
</Show>
</Card>
<Card padding="lg" class="space-y-3">
<SectionHeader
title="Appearance"
description="Switch between light and dark themes. Stored per device and synced to the server when authenticated."
size="sm"
align="left"
/>
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Dark mode</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle to match your environment. Pulse remembers this preference on each browser.
</p>
</div>
<Toggle
checked={props.darkMode()}
onChange={(event) => {
const desired = (event.currentTarget as HTMLInputElement).checked;
if (desired !== props.darkMode()) {
props.toggleDarkMode();
}
}}
/>
</div>
</Card>
<div class="grid gap-4 lg:grid-cols-5">
<Card padding="lg" class="space-y-6 lg:col-span-3">
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
</svg>
Network Settings
</h4>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
CORS Allowed Origins
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
For reverse proxy setups (* = allow all, empty = same-origin only)
</p>
<div class="relative">
<input
type="text"
value={allowedOrigins()}
onChange={(e) => {
if (!envOverrides().allowedOrigins) {
setAllowedOrigins(e.currentTarget.value);
setHasUnsavedChanges(true);
}
}}
disabled={envOverrides().allowedOrigins}
placeholder="* or https://example.com"
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
envOverrides().allowedOrigins
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}
/>
{envOverrides().allowedOrigins && (
<div class="mt-2 p-2 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-800 dark:text-amber-200">
<div class="flex items-center gap-1">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Overridden by ALLOWED_ORIGINS environment variable</span>
</div>
<div class="mt-1 text-amber-700 dark:text-amber-300">
Remove the env var and restart to enable UI configuration
</div>
</div>
)}
</div>
</div>
</section>
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="14" rx="2"></rect>
<path d="M7 20h10"></path>
</svg>
Embedding
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)
</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allowEmbedding"
checked={allowEmbedding()}
onChange={(e) => {
setAllowEmbedding(e.currentTarget.checked);
setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<label
for="allowEmbedding"
class="text-sm text-gray-700 dark:text-gray-300"
>
Allow iframe embedding
</label>
</div>
<Show when={allowEmbedding()}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Allowed Embed Origins (optional)
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Comma-separated list of origins that can embed Pulse (leave empty for
same-origin only)
</p>
<input
type="text"
value={allowedEmbedOrigins()}
onChange={(e) => {
setAllowedEmbedOrigins(e.currentTarget.value);
setHasUnsavedChanges(true);
}}
placeholder="https://my.domain, https://dashboard.example.com"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: If Pulse is at <code>pulse.my.domain</code> and your
dashboard is at <code>my.domain</code>, add{' '}
<code>https://my.domain</code> here.
</p>
</div>
</Show>
</div>
</section>
<Card
tone="warning"
padding="sm"
border={false}
class="border border-amber-200 dark:border-amber-800"
>
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use{' '}
<code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">
systemctl edit pulse
</code>
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
[Service]
<br />
Environment="FRONTEND_PORT=8080"
<br />
<span class="text-xs text-amber-600 dark:text-amber-400">
Then restart: sudo systemctl restart pulse
</span>
</p>
</Card>
</Card>
<Card padding="lg" class="space-y-6 lg:col-span-2">
<section class="space-y-4">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
Updates
</h4>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Current Version
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{versionInfo()?.version || 'Loading...'}
{versionInfo()?.isDevelopment && ' (Development)'}
{versionInfo()?.isDocker && ' - Docker'}
</p>
</div>
<button
type="button"
onClick={checkForUpdates}
disabled={
checkingForUpdates() ||
versionInfo()?.isDocker ||
versionInfo()?.isSourceBuild
}
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${
versionInfo()?.isDocker || versionInfo()?.isSourceBuild
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{checkingForUpdates() ? (
<>
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
Checking...
</>
) : (
<>Check for Updates</>
)}
</button>
</div>
<Show when={versionInfo()?.isDocker && !updateInfo()?.available}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Docker Installation:</strong> Updates are managed through
Docker. Pull the latest image to update.
</p>
</div>
</Show>
<Show when={versionInfo()?.isSourceBuild}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Built from source:</strong> Pull the latest code from git and
rebuild to update.
</p>
</div>
</Show>
<Show when={Boolean(updateInfo()?.warning)}>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p class="text-xs text-amber-800 dark:text-amber-200">
{updateInfo()?.warning}
</p>
</div>
</Show>
<Show when={updateInfo()?.available}>
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg space-y-3">
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">
Update Available: {updateInfo()?.latestVersion}
</p>
<p class="text-xs text-green-700 dark:text-green-300 mt-1">
Released:{' '}
{updateInfo()?.releaseDate
? new Date(updateInfo()!.releaseDate).toLocaleDateString()
: 'Unknown'}
</p>
</div>
<div class="p-2 bg-green-100 dark:bg-green-900/40 rounded space-y-2">
<p class="text-xs font-medium text-green-800 dark:text-green-200">
How to update:
</p>
<Show when={versionInfo()?.deploymentType === 'proxmoxve'}>
<p class="text-xs text-green-700 dark:text-green-300">
Type{' '}
<code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">
update
</code>{' '}
in the LXC console
</p>
</Show>
<Show when={versionInfo()?.deploymentType === 'docker'}>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>Run these commands:</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
docker pull rcourtman/pulse:latest
<br />
docker restart pulse
</code>
</div>
</Show>
<Show
when={
versionInfo()?.deploymentType === 'systemd' ||
versionInfo()?.deploymentType === 'manual'
}
>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>Run the install script:</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
curl -fsSL
https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh
| bash
</code>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'development'}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull latest changes and rebuild
</p>
</Show>
<Show
when={!versionInfo()?.deploymentType && versionInfo()?.isDocker}
>
<p class="text-xs text-green-700 dark:text-green-300">
Pull the latest Pulse Docker image and recreate your container.
</p>
</Show>
</div>
<Show when={updateInfo()?.releaseNotes}>
<details class="mt-1">
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">
Release Notes
</summary>
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
{updateInfo()?.releaseNotes}
</pre>
</details>
</Show>
</div>
</Show>
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Channel
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Choose between stable and release candidate versions
</p>
</div>
<select
value={updateChannel()}
onChange={(e) => {
setUpdateChannel(e.currentTarget.value as 'stable' | 'rc');
setHasUnsavedChanges(true);
}}
disabled={versionInfo()?.isDocker}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<option value="stable">Stable</option>
<option value="rc">Release Candidate</option>
</select>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Checks
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Automatically check for updates (installation is manual)
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={autoUpdateEnabled()}
onChange={(e) => {
setAutoUpdateEnabled(e.currentTarget.checked);
setHasUnsavedChanges(true);
}}
disabled={versionInfo()?.isDocker}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
<Show when={autoUpdateEnabled()}>
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Check Interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
How often to check for updates
</p>
</div>
<select
value={autoUpdateCheckInterval()}
onChange={(e) => {
setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="6">Every 6 hours</option>
<option value="12">Every 12 hours</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
</select>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Check Time
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Preferred time to check for updates
</p>
</div>
<input
type="time"
value={autoUpdateTime()}
onChange={(e) => {
setAutoUpdateTime(e.currentTarget.value);
setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
</div>
</Show>
</div>
</div>
</section>
</Card>
</div>
{/* Backup & Restore - Moved from Security tab */}
<Card
padding="lg"
border={false}
class="border border-gray-200 dark:border-gray-700"
>
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="9" stroke-width="2" />
<path d="M12 7v5l3 3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Backup polling
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Control how often Pulse queries Proxmox backup tasks, datastore contents, and guest snapshots.
Longer intervals reduce disk activity and API load.
</p>
<div class="space-y-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Enable backup polling
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Required for dashboard backup status, storage snapshots, and alerting.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
checked={backupPollingEnabled()}
disabled={backupPollingEnvLocked()}
onChange={(e) => {
setBackupPollingEnabled(e.currentTarget.checked);
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
<Show when={backupPollingEnabled()}>
<div class="space-y-3 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Polling interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{backupIntervalSummary()}
</p>
</div>
<select
value={backupIntervalSelectValue()}
disabled={backupPollingEnvLocked()}
onChange={(e) => {
const value = e.currentTarget.value;
if (value === 'custom') {
const minutes = Math.max(1, backupPollingCustomMinutes());
setBackupPollingInterval(minutes * 60);
} else {
const seconds = parseInt(value, 10);
if (!Number.isNaN(seconds)) {
setBackupPollingInterval(seconds);
if (seconds > 0) {
setBackupPollingCustomMinutes(Math.max(1, Math.round(seconds / 60)));
}
}
}
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<For each={BACKUP_INTERVAL_OPTIONS}>
{(option) => (
<option value={String(option.value)}>{option.label}</option>
)}
</For>
<option value="custom">Custom interval</option>
</select>
</div>
<Show when={backupIntervalSelectValue() === 'custom'}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Custom interval (minutes)
</label>
<div class="flex items-center gap-3">
<input
type="number"
min="1"
max={BACKUP_INTERVAL_MAX_MINUTES}
value={backupPollingCustomMinutes()}
disabled={backupPollingEnvLocked()}
onInput={(e) => {
const value = Number(e.currentTarget.value);
if (Number.isNaN(value)) {
return;
}
const clamped = Math.max(
1,
Math.min(BACKUP_INTERVAL_MAX_MINUTES, Math.floor(value)),
);
setBackupPollingCustomMinutes(clamped);
setBackupPollingInterval(clamped * 60);
if (!backupPollingEnvLocked()) {
setHasUnsavedChanges(true);
}
}}
class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
1 {BACKUP_INTERVAL_MAX_MINUTES} minutes (7 days max)
</span>
</div>
</div>
</Show>
</div>
</Show>
<Show when={backupPollingEnvLocked()}>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20 p-3 text-xs text-amber-700 dark:text-amber-200">
<svg
class="w-4 h-4 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="font-medium">Environment override detected</p>
<p class="mt-1">
The <code class="font-mono">ENABLE_BACKUP_POLLING</code> or{' '}
<code class="font-mono">BACKUP_POLLING_INTERVAL</code> environment
variables are set. Remove them and restart Pulse to manage backup polling here.
</p>
</div>
</div>
</Show>
</div>
</section>
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
Export Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Download an encrypted backup of all nodes and settings
</p>
<button
type="button"
onClick={() => {
// Default to custom passphrase if no auth is configured
setUseCustomPassphrase(!securityStatus()?.hasAuthentication);
setShowExportDialog(true);
}}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors inline-flex items-center gap-2"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Export Backup
</button>
</div>
</div>
</div>
{/* Import Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
Restore Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Upload a backup file to restore nodes and settings
</p>
<button
type="button"
onClick={() => setShowImportDialog(true)}
class="px-3 py-1.5 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700 transition-colors inline-flex items-center gap-2"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Restore Backup
</button>
</div>
</div>
</div>
</div>
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<div class="flex gap-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-xs text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">Important Notes</p>
<ul class="space-y-0.5 text-amber-600 dark:text-amber-400">
<li> Backups contain encrypted credentials and sensitive data</li>
<li> Use a strong passphrase to protect your backup</li>
<li> Store backup files securely and never share the passphrase</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
</Show>
{/* Security Tab */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
<Show when={!securityStatusLoading() && securityStatus()}>
<SecurityPostureSummary status={securityStatus()!} />
</Show>
<Show when={!securityStatusLoading() && securityStatus()?.hasProxyAuth}>
<Card
padding="sm"
class="border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20"
>
<div class="flex flex-col gap-2 text-xs text-blue-800 dark:text-blue-200">
<div class="flex items-center gap-2">
<svg
class="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-semibold text-blue-900 dark:text-blue-100">
Proxy authentication detected
</span>
</div>
<p>
Requests are validated by an upstream proxy. The current proxied user is
{securityStatus()?.proxyAuthUsername
? ` ${securityStatus()?.proxyAuthUsername}`
: ' available once a request is received'}
.{securityStatus()?.proxyAuthIsAdmin ? ' Admin privileges confirmed.' : ''}
<Show when={securityStatus()?.proxyAuthLogoutURL}>
{' '}
<a
class="underline font-medium"
href={securityStatus()?.proxyAuthLogoutURL}
>
Proxy logout
</a>
</Show>
</p>
<p>
Need configuration tips? Review the proxy auth guide in the docs.{' '}
<a
class="underline font-medium"
href="https://github.com/rcourtman/Pulse/blob/main/docs/PROXY_AUTH.md"
target="_blank"
rel="noreferrer"
>
Read proxy auth guide
</a>
</p>
</div>
</Card>
</Show>
{/* Show message when auth is disabled */}
<Show when={!securityStatus()?.hasAuthentication}>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3 flex-1">
<svg
class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-100">
Authentication disabled
</h4>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
DISABLE_AUTH is set, or auth isn't configured yet.
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowQuickSecuritySetup(!showQuickSecuritySetup())}
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-800 bg-amber-100/50 hover:bg-amber-100 transition-colors dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/40 whitespace-nowrap"
>
Setup
</button>
</div>
<Show when={showQuickSecuritySetup()}>
<div class="mt-3 pt-3 border-t border-amber-200 dark:border-amber-700">
<QuickSecuritySetup
onConfigured={() => {
setShowQuickSecuritySetup(false);
loadSecurityStatus();
}}
/>
</div>
</Show>
</div>
</Show>
{/* Authentication */}
<Show
when={
!securityStatusLoading() &&
(securityStatus()?.hasAuthentication || securityStatus()?.apiTokenConfigured)
}
>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-gray-50 to-gray-50 dark:from-gray-900/20 dark:to-gray-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-gray-100 dark:bg-gray-900/50 rounded-lg">
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<SectionHeader title="Authentication" size="sm" class="flex-1" />
</div>
</div>
{/* Content */}
<div class="p-6">
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowPasswordModal(true);
}}
class="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Change password
</button>
<button
type="button"
onClick={() => setShowQuickSecurityWizard(!showQuickSecurityWizard())}
class="px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Rotate credentials
</button>
<div class="flex-1"></div>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">User:</span>{' '}
{securityStatus()?.authUsername || 'Not configured'}
</div>
</div>
<Show when={showQuickSecurityWizard()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<QuickSecuritySetup
mode="rotate"
defaultUsername={securityStatus()?.authUsername || 'admin'}
onConfigured={() => {
setShowQuickSecurityWizard(false);
loadSecurityStatus();
}}
/>
</div>
</Show>
</div>
</Card>
</Show>
{/* Show pending restart message if configured but not loaded */}
<Show when={securityStatus()?.configuredButPendingRestart}>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-100">
Security Configured - Restart Required
</h4>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Security settings have been configured but the service needs to be
restarted to activate them.
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
After restarting, you'll need to log in with your saved credentials.
</p>
<div class="mt-4 bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">
How to restart Pulse:
</p>
<Show when={versionInfo()?.deploymentType === 'proxmoxve'}>
<div class="space-y-2">
<p class="text-xs text-gray-700 dark:text-gray-300">
Type{' '}
<code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
update
</code>{' '}
in your ProxmoxVE console
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 italic">
Or restart manually with:{' '}
<code class="text-xs">systemctl restart pulse</code>
</p>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'docker'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart your Docker container:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
docker restart pulse
</code>
</div>
</Show>
<Show
when={
versionInfo()?.deploymentType === 'systemd' ||
versionInfo()?.deploymentType === 'manual'
}
>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart the service:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse
</code>
</div>
</Show>
<Show when={versionInfo()?.deploymentType === 'development'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart the development server:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse-hot-dev
</code>
</div>
</Show>
<Show when={!versionInfo()?.deploymentType}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart Pulse using your deployment method
</p>
</div>
</Show>
</div>
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-xs text-green-700 dark:text-green-300">
💡 <strong>Tip:</strong> Make sure you've saved your credentials before
restarting!
</p>
</div>
</div>
</div>
</div>
</Show>
<OIDCPanel onConfigUpdated={loadSecurityStatus} />
{/* Security setup now handled by first-run wizard */}
{/* API Token - Show always to allow API access even when auth is disabled */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<SectionHeader
title="API token"
description="For automation and integrations"
size="sm"
class="flex-1"
/>
</div>
</div>
{/* Content */}
<div class="p-6">
<APITokenManager
currentTokenHint={securityStatus()?.apiTokenHint}
onTokensChanged={() => {
void loadSecurityStatus();
}}
refreshing={securityStatusLoading()}
/>
</div>
</Card>
{/* Advanced - Only show if auth is enabled */}
{/* Advanced Options section removed - was only used for Registration Tokens */}
</div>
</Show>
{/* Diagnostics Tab */}
<Show when={activeTab() === 'diagnostics'}>
<div class="space-y-6">
<div>
<SectionHeader title="System diagnostics" size="md" class="mb-4" />
<div class="space-y-4">
{/* Live Connection Diagnostics */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
Connection Diagnostics
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
Test all configured node connections and view detailed status
</p>
<button
type="button"
onClick={() => {
void runDiagnostics();
}}
disabled={runningDiagnostics()}
class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{runningDiagnostics() ? 'Running...' : 'Run Diagnostics'}
</button>
<Show when={diagnosticsData()}>
<div class="mt-4 space-y-3">
{/* System Info */}
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
System
</h5>
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div>Version: {diagnosticsData()?.version || 'Unknown'}</div>
<div>Runtime: {diagnosticsData()?.runtime || 'Unknown'}</div>
<div>
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
</div>
<div>
OS / Arch:{' '}
{diagnosticsData()?.system?.os
? `${diagnosticsData()?.system?.os} / ${diagnosticsData()?.system?.arch || 'Unknown'}`
: 'Unknown'}
</div>
<div>Go runtime: {diagnosticsData()?.system?.goVersion || 'Unknown'}</div>
<div>
CPU cores: {diagnosticsData()?.system?.numCPU ?? 'Unknown'}
</div>
<div>
Goroutines: {diagnosticsData()?.system?.numGoroutine ?? 'Unknown'}
</div>
<div>
Memory: {diagnosticsData()?.system?.memoryMB ?? 0}{' '}
MB
</div>
</div>
</Card>
{/* Temperature proxy guidance */}
<Show when={diagnosticsData()?.temperatureProxy}>
{(temp) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Temperature proxy
</h5>
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div class="flex items-center justify-between">
<span>Proxy socket</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().socketFound ? 'bg-green-500' : 'bg-red-500'
}`}
>
{temp().socketFound ? 'Detected' : 'Missing'}
</span>
</div>
<div class="flex items-center justify-between">
<span>Daemon</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
}`}
>
{temp().proxyReachable ? 'Responding' : 'No response'}
</span>
</div>
<Show when={temp().socketPath}>
<div>Socket path: {temp().socketPath}</div>
</Show>
<Show when={temp().socketPermissions}>
<div>Permissions: {temp().socketPermissions}</div>
</Show>
<Show when={temp().socketOwner || temp().socketGroup}>
<div>
Owner:{' '}
{[temp().socketOwner, temp().socketGroup]
.filter(Boolean)
.join(' / ') || 'Unknown'}
</div>
</Show>
<Show when={temp().proxyVersion}>
<div>Proxy version: {temp().proxyVersion}</div>
</Show>
<Show when={temp().proxySshDirectory}>
<div>SSH directory: {temp().proxySshDirectory}</div>
</Show>
<Show when={temp().proxyPublicKeySha256}>
<div>Key fingerprint: {temp().proxyPublicKeySha256}</div>
</Show>
<Show when={typeof temp().legacySshKeyCount === 'number'}>
<div>
Legacy SSH keys:{' '}
{temp().legacySshKeyCount ?? 0}
</div>
</Show>
<Show when={temp().legacySSHDetected}>
<div class="text-red-500">
Legacy SSH temperature collection detected
</div>
</Show>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (!proxyActionLoading()) {
void handleRegisterProxyNodes();
}
}}
disabled={proxyActionLoading() !== null}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{proxyActionLoading() === 'register-nodes'
? 'Checking nodes...'
: 'Check proxy nodes'}
</button>
</div>
<Show when={proxyRegisterSummary() && proxyRegisterSummary()!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>Proxy node connectivity:</div>
<ul class="list-disc pl-4 space-y-0.5">
<For each={proxyRegisterSummary() || []}>
{(node) => (
<li>
<span class={node.sshReady ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{node.name}: {node.sshReady ? 'reachable' : 'unreachable'}
</span>
<Show when={node.error}>
<span class="ml-1 text-gray-500 dark:text-gray-400">
({node.error})
</span>
</Show>
</li>
)}
</For>
</ul>
</div>
</Show>
<Show when={temp().notes && temp().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={temp().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* API token adoption */}
<Show when={diagnosticsData()?.apiTokens}>
{(apiDiag) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
API tokens
</h5>
<div class="text-xs space-y-2 text-gray-600 dark:text-gray-400">
<div class="flex flex-wrap gap-2">
<span
class={`px-2 py-0.5 rounded text-xs ${
apiDiag().enabled ? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
}`}
>
{apiDiag().enabled ? 'Token auth enabled' : 'Token auth disabled'}
</span>
<Show when={apiDiag().hasEnvTokens}>
<span class="px-2 py-0.5 rounded text-xs bg-orange-100 text-orange-700 dark:bg-orange-700/40 dark:text-orange-100">
Env override detected
</span>
</Show>
<Show when={apiDiag().hasLegacyToken}>
<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100">
Legacy token present
</span>
</Show>
</div>
<div class="grid grid-cols-2 gap-2">
<div>Configured tokens: {apiDiag().tokenCount}</div>
<div>
Rotation needed:{' '}
{apiDiag().recommendTokenRotation ? 'Yes' : 'No'}
</div>
<div>
Docker hosts on shared token:{' '}
{apiDiag().legacyDockerHostCount ?? 0}
</div>
<div>
Unused tokens:{' '}
{apiDiag().unusedTokenCount ?? 0}
</div>
</div>
</div>
<Show when={apiDiag().tokens && apiDiag().tokens!.length > 0}>
<div class="mt-3 border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
<For each={apiDiag().tokens || []}>
{(token) => (
<div class="p-2 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap justify-between gap-2">
<div class="flex-1 min-w-[140px]">
<div class="font-medium text-gray-700 dark:text-gray-200">
{token.name || 'Unnamed token'}
</div>
<div class="text-2xs text-gray-500 dark:text-gray-400">
{token.hint || 'No hint available'}
<Show when={token.source}>
<span class="ml-2 uppercase tracking-wide">
({token.source})
</span>
</Show>
</div>
</div>
<div class="text-right min-w-[160px]">
<div>
Created:{' '}
{token.createdAt
? new Date(token.createdAt).toLocaleString()
: 'Unknown'}
</div>
<div>
Last used:{' '}
{token.lastUsedAt
? formatRelativeTime(
new Date(token.lastUsedAt).getTime(),
)
: 'Never'}
</div>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={apiDiag().usage && apiDiag().usage!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div class="font-semibold text-gray-700 dark:text-gray-200">Token usage</div>
<ul class="list-disc pl-4 space-y-1">
<For each={apiDiag().usage || []}>
{(usage) => (
<li>
{usage.tokenId}: {usage.hostCount}{' '}
{usage.hostCount === 1 ? 'host' : 'hosts'}
<Show when={usage.hosts && usage.hosts!.length > 0}>
<span class="ml-1 text-gray-500 dark:text-gray-400">
({usage.hosts!.join(', ')})
</span>
</Show>
</li>
)}
</For>
</ul>
</div>
</Show>
<Show when={apiDiag().notes && apiDiag().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={apiDiag().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Docker agent adoption */}
<Show when={diagnosticsData()?.dockerAgents}>
{(dockerDiag) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Docker agents
</h5>
<Show when={dockerDiag().hostsTotal > 0}>
<div class="text-xs text-gray-600 dark:text-gray-400 grid grid-cols-2 gap-2">
<div>Total hosts: {dockerDiag().hostsTotal}</div>
<div>Online: {dockerDiag().hostsOnline}</div>
<div>
With dedicated tokens: {dockerDiag().hostsWithTokenBinding}
</div>
<div>
Attention required: {dockerDiag().hostsNeedingAttention}
</div>
<div>
Missing version: {dockerDiag().hostsWithoutVersion ?? 0}
</div>
<div>
Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0}
</div>
<div>
Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0}
</div>
<div>
Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0}
</div>
</div>
<Show when={dockerDiag().recommendedAgentVersion}>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Recommended agent version: {dockerDiag().recommendedAgentVersion}
</div>
</Show>
</Show>
<Show when={dockerDiag().attention && dockerDiag().attention!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<For each={dockerDiag().attention || []}>
{(entry) => (
<div class="p-2 space-y-1">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700 dark:text-gray-200">
{entry.name}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
entry.status === 'online'
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
: 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100'
}`}
>
{entry.status || 'unknown'}
</span>
</div>
<div class="text-2xs text-gray-500 dark:text-gray-400 space-x-2">
<Show when={entry.agentVersion}>
<span>Agent {entry.agentVersion}</span>
</Show>
<Show when={entry.tokenHint}>
<span>Token {entry.tokenHint}</span>
</Show>
<Show when={entry.lastSeen}>
<span>
Seen{' '}
{formatRelativeTime(
new Date(entry.lastSeen!).getTime(),
)}
</span>
</Show>
</div>
<ul class="list-disc pl-4 space-y-1 text-gray-600 dark:text-gray-400">
<For each={entry.issues}>
{(issue) => <li>{issue}</li>}
</For>
</ul>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (dockerActionLoading() !== entry.hostId) {
void handleDockerPrepareToken(entry.hostId);
}
}}
disabled={dockerActionLoading() === entry.hostId}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{dockerActionLoading() === entry.hostId
? 'Preparing token...'
: 'Generate dedicated token'}
</button>
</div>
<Show when={dockerMigrationResults()[entry.hostId]}>
<div class="mt-3 w-full space-y-2 bg-gray-100 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700 rounded p-3 text-xs text-gray-700 dark:text-gray-200">
{(() => {
const migration = dockerMigrationResults()[entry.hostId]!;
return (
<>
<div class="font-semibold text-gray-800 dark:text-gray-100">
Install command
</div>
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
{migration.installCommand}
</pre>
<button
type="button"
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
onClick={() => void handleCopy(migration.installCommand, 'Install command copied')}
>
Copy install command
</button>
<div class="font-semibold text-gray-800 dark:text-gray-100 mt-2">
Systemd snippet
</div>
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
{migration.systemdServiceSnippet}
</pre>
<button
type="button"
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
onClick={() => void handleCopy(migration.systemdServiceSnippet, 'Service snippet copied')}
>
Copy service snippet
</button>
<div class="text-gray-600 dark:text-gray-400">
Target URL: {migration.pulseURL}
</div>
</>
);
})()}
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={dockerDiag().notes && dockerDiag().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={dockerDiag().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Alerts configuration */}
<Show when={diagnosticsData()?.alerts}>
{(alerts) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Alerts configuration
</h5>
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex flex-wrap gap-2">
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().legacyThresholdsDetected
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Legacy thresholds {alerts().legacyThresholdsDetected ? 'detected' : 'migrated'}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().missingCooldown
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Cooldown {alerts().missingCooldown ? 'missing' : 'configured'}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().missingGroupingWindow
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Grouping window {alerts().missingGroupingWindow ? 'disabled' : 'enabled'}
</span>
</div>
<Show when={alerts().legacyThresholdSources && alerts().legacyThresholdSources!.length > 0}>
<div>
Legacy sources: {alerts().legacyThresholdSources!.join(', ')}
</div>
</Show>
<Show when={alerts().legacyScheduleSettings && alerts().legacyScheduleSettings!.length > 0}>
<div>
Legacy schedule settings: {alerts().legacyScheduleSettings!.join(', ')}
</div>
</Show>
</div>
<Show when={alerts().notes && alerts().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={alerts().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Nodes Status */}
<Show
when={diagnosticsData()?.nodes && diagnosticsData()!.nodes.length > 0}
>
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
PVE Nodes
</h5>
<For each={diagnosticsData()?.nodes || []}>
{(node) => (
<div class="text-xs border-t dark:border-gray-700 pt-2 mt-2 first:border-0 first:pt-0 first:mt-0">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-700 dark:text-gray-300">
{node.name}
</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
node.connected ? 'bg-green-500' : 'bg-red-500'
}`}
>
{node.connected ? 'Connected' : 'Failed'}
</span>
</div>
<div class="text-gray-600 dark:text-gray-400">
<div>Host: {node.host}</div>
<div>Auth: {node.authMethod?.replace('_', ' ')}</div>
<Show when={node.error}>
<div class="text-red-500 mt-1 break-words">
{node.error}
</div>
</Show>
</div>
</div>
)}
</For>
</Card>
</Show>
{/* PBS Status */}
<Show when={diagnosticsData()?.pbs && diagnosticsData()!.pbs.length > 0}>
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
PBS Instances
</h5>
<For each={diagnosticsData()?.pbs || []}>
{(pbs) => (
<div class="text-xs border-t dark:border-gray-700 pt-2 mt-2 first:border-0 first:pt-0 first:mt-0">
<div class="flex justify-between items-center mb-1">
<span class="font-medium text-gray-700 dark:text-gray-300">
{pbs.name}
</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
pbs.connected ? 'bg-green-500' : 'bg-red-500'
}`}
>
{pbs.connected ? 'Connected' : 'Failed'}
</span>
</div>
<Show when={pbs.error}>
<div class="text-red-500 break-words">{pbs.error}</div>
</Show>
</div>
)}
</For>
</Card>
</Show>
</div>
</Show>
</div>
{/* System Information */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
System Information
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Version:</span>
<span class="font-medium">2.0.0</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Backend:</span>
<span class="font-medium">Go 1.21</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Frontend:</span>
<span class="font-medium">SolidJS + TypeScript</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">WebSocket Status:</span>
<span
class={`font-medium ${connected() ? 'text-green-600' : 'text-red-600'}`}
>
{connected() ? 'Connected' : 'Disconnected'}
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Server Port:</span>
<span class="font-medium">
{window.location.port ||
(window.location.protocol === 'https:' ? '443' : '80')}
</span>
</div>
</div>
</div>
{/* Connection Status */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
Connection Status
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">PVE Nodes:</span>
<span class="font-medium">
{nodes().filter((n) => n.type === 'pve').length}
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">PBS Nodes:</span>
<span class="font-medium">
{nodes().filter((n) => n.type === 'pbs').length}
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total VMs:</span>
<span class="font-medium">{state.vms?.length || 0}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Containers:</span>
<span class="font-medium">{state.containers?.length || 0}</span>
</div>
</div>
</div>
{/* Export Diagnostics */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
Export Diagnostics
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
Export system diagnostics data for troubleshooting
</p>
{/* Helper function to sanitize sensitive data */}
{(() => {
const sanitizeForGitHub = (data: Record<string, unknown>) => {
// Deep clone the data
const sanitized = JSON.parse(JSON.stringify(data)) as Record<
string,
unknown
>;
const connectionKeyMap = new Map<string, string>();
const instanceKeyMap = new Map<string, string>();
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 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<Record<string, unknown>>,
) =>
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<Record<string, unknown>>,
) =>
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<Record<string, unknown>>
).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<Record<string, unknown>>).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<
string,
unknown
> | undefined;
if (physicalDisks && Array.isArray(physicalDisks.nodeResults)) {
physicalDisks.nodeResults = (
physicalDisks.nodeResults as Array<Record<string, unknown>>
).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<
string,
unknown
> | 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<Record<string, unknown>>
).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<string>
).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<Record<string, unknown>>,
);
}
if (Array.isArray(sanitized.guestSnapshots)) {
sanitized.guestSnapshots = sanitizeGuestSnapshots(
sanitized.guestSnapshots as Array<Record<string, unknown>>,
);
}
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 (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') {
const apiTokens = sanitized.apiTokens as Record<string, unknown>;
const tokenIdMap = new Map<string, string>();
if (Array.isArray(apiTokens.tokens)) {
apiTokens.tokens = (apiTokens.tokens as Array<Record<string, unknown>>).map(
(token, tokenIndex: number) => {
const sanitizedToken = { ...token } as Record<string, unknown>;
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<Record<string, unknown>>).map(
(usage, usageIndex: number) => {
const sanitizedUsage = { ...usage } as Record<string, unknown>;
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<string>).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<string, unknown>;
if (Array.isArray(dockerDiag.attention)) {
dockerDiag.attention = (
dockerDiag.attention as Array<Record<string, unknown>>
).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<string, unknown>;
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<Record<string, unknown>>,
);
}
if (Array.isArray(backend.guestSnapshots)) {
backend.guestSnapshots = sanitizeGuestSnapshots(
backend.guestSnapshots as Array<Record<string, unknown>>,
);
}
if (Array.isArray(backend.nodes)) {
backend.nodes = backend.nodes.map((rawNode, index: number) => {
const node = rawNode as Record<string, unknown>;
const originalName = typeof node.name === 'string' ? node.name : '';
const sanitizedId = `diagnostic-node-${index}`;
connectionKeyMap.set(originalName, sanitizedId);
const vmDiskCheck = node.vmDiskCheck as Record<string, unknown> | undefined;
const physicalDisks = node.physicalDisks as Record<
string,
unknown
> | 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<Record<string, unknown>>
).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<string>
).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<Record<string, unknown>>
).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<string, unknown>;
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<Record<string, unknown>>
).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<string, unknown> | undefined;
if (backups) {
// Sanitize PVE backup tasks
if (Array.isArray(backups.pveBackupTasks)) {
backups.pveBackupTasks = (
backups.pveBackupTasks as Array<Record<string, unknown>>
).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<Record<string, unknown>>
).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<Record<string, unknown>>
).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<Record<string, unknown>>
| 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<string, unknown>
| undefined;
if (websocketInfo && typeof websocketInfo.url === 'string') {
websocketInfo.url = websocketInfo.url.replace(
/\/\/[^\/]+/,
'//REDACTED',
);
}
if (
sanitized.connectionHealth &&
typeof sanitized.connectionHealth === 'object'
) {
const newConnectionHealth: Record<string, unknown> = {};
let index = 1;
Object.entries(
sanitized.connectionHealth as Record<string, unknown>,
).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<string, unknown> = {
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: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`,
},
// 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<string, unknown>
>;
const originalNodeStatus = diagnostics.nodeStatus as Array<
Record<string, unknown>
>;
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<Record<string, unknown>>
).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<string>
).map(() => 'node-REDACTED');
}
// Sanitize nodeIds array if present
if (Array.isArray(sanitizedItem.nodeIds)) {
sanitizedItem.nodeIds = (
sanitizedItem.nodeIds as Array<string>
).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<Record<string, unknown>>
).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 (
<div class="space-y-3">
<Show when={!diagnosticsData()}>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded p-2">
💡 Run diagnostics first for more comprehensive export data
</div>
</Show>
<div class="flex gap-2">
<button
type="button"
onClick={() => exportDiagnostics(false)}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Export Full
</button>
<button
type="button"
onClick={() => exportDiagnostics(true)}
class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Export for GitHub
</button>
</div>
</div>
);
})()}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
<strong>Export Full:</strong> Complete data for private troubleshooting
<br />
<strong>Export for GitHub:</strong> Sanitized data safe for public sharing
</p>
</div>
</div>
</div>
</div>
</Show>
{/* Guest URLs Tab */}
<Show when={activeTab() === 'urls'}>
<GuestURLs
hasUnsavedChanges={hasUnsavedChanges}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
</Show>
</div>
</div>
</Card>
</div>
{/* Delete Node Modal */}
<Show when={showDeleteNodeModal()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card padding="lg" class="w-full max-w-lg space-y-5">
<SectionHeader
title={`Remove ${nodePendingDeleteLabel()}`}
size="md"
class="mb-1"
/>
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p>
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.
</p>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm leading-relaxed dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
<p class="font-medium text-blue-900 dark:text-blue-100">What happens next</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-blue-800 dark:text-blue-200 text-sm">
<li>Pulse removes the node entry and clears related alerts.</li>
<li>
{nodePendingDeleteHost() ? (
<>
The host{' '}
<span class="font-semibold">{nodePendingDeleteHost()}</span> loses the
proxy service, SSH key, and API token.
</>
) : (
'The host loses the proxy service, SSH key, and API token.'
)}
</li>
<li>
If the host comes back later, rerunning the setup script reinstalls everything
with a fresh key.
</li>
<Show when={nodePendingDeleteType() === 'pbs'}>
<li>
Backup user tokens on the PBS are removed, so jobs referencing them will no
longer authenticate until the node is re-added.
</li>
</Show>
<Show when={nodePendingDeleteType() === 'pmg'}>
<li>
Mail gateway tokens are removed as part of the cleanup; re-enroll to restore
outbound telemetry.
</li>
</Show>
</ul>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={cancelDeleteNode}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
disabled={deleteNodeLoading()}
>
Keep node
</button>
<button
type="button"
onClick={deleteNode}
disabled={deleteNodeLoading()}
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
>
{deleteNodeLoading() ? 'Removing…' : 'Remove node'}
</button>
</div>
</Card>
</div>
</Show>
{/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */}
<Show when={showNodeModal() && currentNodeType() === 'pve'}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
onClose={() => {
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}
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');
}
}}
/>
</Show>
{/* PBS Node Modal - Separate instance to prevent contamination */}
<Show when={showNodeModal() && currentNodeType() === 'pbs'}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
onClose={() => {
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}
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');
}
}}
/>
</Show>
{/* PMG Node Modal */}
<Show when={showNodeModal() && currentNodeType() === 'pmg'}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
onClose={() => {
setShowNodeModal(false);
setEditingNode(null);
setModalResetKey((prev) => prev + 1);
}}
nodeType="pmg"
editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined}
securityStatus={securityStatus() ?? undefined}
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');
}
}}
/>
</Show>
{/* Export Dialog */}
<Show when={showExportDialog()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Export configuration" size="md" class="mb-4" />
<div class="space-y-4">
{/* Password Choice Section - Only show if auth is enabled */}
<Show when={securityStatus()?.hasAuthentication}>
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="radio"
checked={!useCustomPassphrase()}
onChange={() => {
setUseCustomPassphrase(false);
setExportPassphrase('');
}}
class="mt-1 text-blue-600 focus:ring-blue-500"
/>
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
Use your login password
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use the same password you use to log into Pulse (recommended)
</div>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer">
<input
type="radio"
checked={useCustomPassphrase()}
onChange={() => setUseCustomPassphrase(true)}
class="mt-1 text-blue-600 focus:ring-blue-500"
/>
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
Use a custom passphrase
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Create a different passphrase for this backup
</div>
</div>
</label>
</div>
</div>
</Show>
{/* Show password input based on selection */}
<div class={formField}>
<label class={labelClass()}>
{securityStatus()?.hasAuthentication
? useCustomPassphrase()
? 'Custom Passphrase'
: 'Enter Your Login Password'
: 'Encryption Passphrase'}
</label>
<input
type="password"
value={exportPassphrase()}
onInput={(e) => 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()}
/>
<Show when={!securityStatus()?.hasAuthentication || useCustomPassphrase()}>
<p class={`${formHelpText} mt-1`}>
You'll need this passphrase to restore the backup.
</p>
</Show>
<Show when={securityStatus()?.hasAuthentication && !useCustomPassphrase()}>
<p class={`${formHelpText} mt-1`}>
You'll use this same password when restoring the backup
</p>
</Show>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div class="flex gap-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-xs text-amber-700 dark:text-amber-300">
<strong>Important:</strong> 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.
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowExportDialog(false);
setExportPassphrase('');
setUseCustomPassphrase(false);
}}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={handleExport}
disabled={
!exportPassphrase() || (useCustomPassphrase() && exportPassphrase().length < 12)
}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Export
</button>
</div>
</div>
</Card>
</div>
</Show>
{/* API Token Modal */}
<Show when={showApiTokenModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="API token required" size="md" class="mb-4" />
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
This Pulse instance requires an API token for export/import operations. Please enter
the API token configured on the server.
</p>
<div class={formField}>
<label class={labelClass()}>API Token</label>
<input
type="password"
value={apiTokenInput()}
onInput={(e) => setApiTokenInput(e.currentTarget.value)}
placeholder="Enter API token"
class={controlClass()}
/>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded p-2">
<p class="font-semibold mb-1">The API token is set as an environment variable:</p>
<code class="block">API_TOKENS=token-for-export,token-for-automation</code>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button
type="button"
onClick={() => {
setShowApiTokenModal(false);
setApiTokenInput('');
}}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={() => {
if (apiTokenInput()) {
localStorage.setItem('apiToken', apiTokenInput());
const source = apiTokenModalSource();
setShowApiTokenModal(false);
setApiTokenInput('');
setApiTokenModalSource(null);
// Retry the operation that triggered the modal
if (source === 'export') {
handleExport();
} else if (source === 'import') {
handleImport();
}
} else {
showError('Please enter the API token');
}
}}
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
</button>
</div>
</Card>
</div>
</Show>
{/* Import Dialog */}
<Show when={showImportDialog()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card padding="lg" class="max-w-md w-full">
<SectionHeader title="Import configuration" size="md" class="mb-4" />
<div class="space-y-4">
<div class={formField}>
<label class={labelClass()}>Configuration File</label>
<input
type="file"
accept=".json"
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) setImportFile(file);
}}
class={controlClass('cursor-pointer')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>Backup Password</label>
<input
type="password"
value={importPassphrase()}
onInput={(e) => setImportPassphrase(e.currentTarget.value)}
placeholder="Enter the password used when creating this backup"
class={controlClass()}
/>
<p class={`${formHelpText} mt-1`}>
This is usually your Pulse login password, unless you used a custom passphrase
</p>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3">
<p class="text-xs text-yellow-700 dark:text-yellow-300">
<strong>Warning:</strong> Importing will replace all current configuration. This
action cannot be undone.
</p>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowImportDialog(false);
setImportPassphrase('');
setImportFile(null);
}}
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={handleImport}
disabled={!importPassphrase() || !importFile()}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Import
</button>
</div>
</div>
</Card>
</div>
</Show>
<ChangePasswordModal
isOpen={showPasswordModal()}
onClose={() => {
setShowPasswordModal(false);
// Refresh security status after password change
loadSecurityStatus();
}}
/>
</>
);
};
export default Settings;