- KubernetesClusters.tsx: Escape -> as → in JSX text to fix parsing error - Settings.tsx: Remove unused HostProxySummary interface (deprecated in v5) - AIOverviewTable.tsx: Prefix unused summarizeAction with underscore
4170 lines
167 KiB
TypeScript
4170 lines
167 KiB
TypeScript
import {
|
|
Component,
|
|
createSignal,
|
|
onMount,
|
|
For,
|
|
Show,
|
|
createEffect,
|
|
createMemo,
|
|
onCleanup,
|
|
on,
|
|
} from 'solid-js';
|
|
import { useNavigate, useLocation } from '@solidjs/router';
|
|
import { useWebSocket } from '@/App';
|
|
import { showSuccess, showError, showWarning } from '@/utils/toast';
|
|
import { logger } from '@/utils/logger';
|
|
import {
|
|
apiFetch,
|
|
clearApiToken as clearApiClientToken,
|
|
getApiToken as getApiClientToken,
|
|
setApiToken as setApiClientToken,
|
|
} from '@/utils/apiClient';
|
|
import { NodeModal } from './NodeModal';
|
|
import { ChangePasswordModal } from './ChangePasswordModal';
|
|
import { UnifiedAgents } from './UnifiedAgents';
|
|
import { OIDCPanel } from './OIDCPanel';
|
|
import { AISettings } from './AISettings';
|
|
import { AICostDashboard } from '@/components/AI/AICostDashboard';
|
|
import { DiagnosticsPanel } from './DiagnosticsPanel';
|
|
import { GeneralSettingsPanel } from './GeneralSettingsPanel';
|
|
import { NetworkSettingsPanel } from './NetworkSettingsPanel';
|
|
import { UpdatesSettingsPanel } from './UpdatesSettingsPanel';
|
|
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
|
|
import { BackupsSettingsPanel } from './BackupsSettingsPanel';
|
|
import { ProLicensePanel } from './ProLicensePanel';
|
|
import { SecurityAuthPanel } from './SecurityAuthPanel';
|
|
import { APIAccessPanel } from './APIAccessPanel';
|
|
import { SecurityOverviewPanel } from './SecurityOverviewPanel';
|
|
import {
|
|
PveNodesTable,
|
|
PbsNodesTable,
|
|
PmgNodesTable,
|
|
type TemperatureTransportInfo,
|
|
} from './ConfiguredNodeTables';
|
|
import { SettingsSectionNav } from './SettingsSectionNav';
|
|
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 type { ToggleChangeEvent } 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 Shield from 'lucide-solid/icons/shield';
|
|
import Lock from 'lucide-solid/icons/lock';
|
|
import Key from 'lucide-solid/icons/key';
|
|
import Activity from 'lucide-solid/icons/activity';
|
|
import Loader from 'lucide-solid/icons/loader';
|
|
import Network from 'lucide-solid/icons/network';
|
|
import Monitor from 'lucide-solid/icons/monitor';
|
|
import Sliders from 'lucide-solid/icons/sliders-horizontal';
|
|
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
|
import Clock from 'lucide-solid/icons/clock';
|
|
import Sparkles from 'lucide-solid/icons/sparkles';
|
|
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
|
|
import BadgeCheck from 'lucide-solid/icons/badge-check';
|
|
import type { NodeConfig } from '@/types/nodes';
|
|
import type { UpdateInfo, VersionInfo } from '@/api/updates';
|
|
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
|
|
import { eventBus } from '@/stores/events';
|
|
import { notificationStore } from '@/stores/notifications';
|
|
import { updateStore } from '@/stores/updates';
|
|
|
|
// 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 TemperatureProxyHTTPStatus {
|
|
node: string;
|
|
url?: string;
|
|
reachable: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
interface TemperatureProxyControlPlaneState {
|
|
instance: string;
|
|
lastSync?: string;
|
|
refreshIntervalSeconds?: number;
|
|
secondsBehind?: number;
|
|
status?: string;
|
|
}
|
|
|
|
interface TemperatureProxySocketHost {
|
|
node?: string;
|
|
host?: string;
|
|
cooldownUntil?: string;
|
|
secondsRemaining?: number;
|
|
lastError?: string;
|
|
}
|
|
|
|
type TemperatureSocketCooldownInfo = {
|
|
secondsRemaining?: number;
|
|
until?: string;
|
|
lastError?: string;
|
|
};
|
|
|
|
// HostProxySummary removed - pulse-sensor-proxy is deprecated in v5
|
|
|
|
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;
|
|
proxyCapabilities?: string[];
|
|
notes?: string[];
|
|
httpProxies?: TemperatureProxyHTTPStatus[];
|
|
controlPlaneEnabled?: boolean;
|
|
controlPlaneStates?: TemperatureProxyControlPlaneState[];
|
|
socketHostCooldowns?: TemperatureProxySocketHost[];
|
|
}
|
|
|
|
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 DiscoveryDiagnostic {
|
|
enabled: boolean;
|
|
configuredSubnet?: string;
|
|
activeSubnet?: string;
|
|
environmentOverride?: string;
|
|
subnetAllowlist?: string[];
|
|
subnetBlocklist?: string[];
|
|
scanning?: boolean;
|
|
scanInterval?: string;
|
|
lastScanStartedAt?: string;
|
|
lastResultTimestamp?: string;
|
|
lastResultServers?: number;
|
|
lastResultErrors?: number;
|
|
}
|
|
|
|
interface AlertsDiagnostic {
|
|
legacyThresholdsDetected: boolean;
|
|
legacyThresholdSources?: string[];
|
|
legacyScheduleSettings?: string[];
|
|
missingCooldown: boolean;
|
|
missingGroupingWindow: boolean;
|
|
notes?: string[];
|
|
}
|
|
|
|
interface DiagnosticsData {
|
|
version: string;
|
|
runtime: string;
|
|
uptime: number;
|
|
nodes: DiagnosticsNode[];
|
|
pbs: DiagnosticsPBS[];
|
|
system: SystemDiagnostic;
|
|
temperatureProxy?: TemperatureProxyDiagnostic | null;
|
|
apiTokens?: APITokenDiagnostic | null;
|
|
dockerAgents?: DockerAgentDiagnostic | null;
|
|
alerts?: AlertsDiagnostic | null;
|
|
discovery?: DiscoveryDiagnostic | null;
|
|
errors: string[];
|
|
}
|
|
|
|
interface DiscoveryScanStatus {
|
|
scanning: boolean;
|
|
subnet?: string;
|
|
lastScanStartedAt?: number;
|
|
lastResultAt?: number;
|
|
errors?: string[];
|
|
}
|
|
|
|
type SettingsTab =
|
|
| 'proxmox'
|
|
| 'docker'
|
|
| 'hosts'
|
|
| 'agents'
|
|
| 'system-general'
|
|
| 'system-network'
|
|
| 'system-updates'
|
|
| 'system-backups'
|
|
| 'system-ai'
|
|
| 'system-pro'
|
|
| 'api'
|
|
| 'security-overview'
|
|
| 'security-auth'
|
|
| 'security-sso'
|
|
| 'diagnostics'
|
|
| 'updates';
|
|
|
|
type AgentKey = 'pve' | 'pbs' | 'pmg';
|
|
|
|
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
|
|
proxmox: {
|
|
title: 'Proxmox',
|
|
description:
|
|
'Monitor your Proxmox Virtual Environment, Backup Server, and Mail Gateway infrastructure.',
|
|
},
|
|
docker: {
|
|
title: 'Docker',
|
|
description:
|
|
'Monitor Docker hosts, containers, images, and volumes across your infrastructure.',
|
|
},
|
|
hosts: {
|
|
title: 'Hosts',
|
|
description: 'Monitor Linux, macOS, and Windows machines—servers, desktops, and laptops.',
|
|
},
|
|
agents: {
|
|
title: 'Agents',
|
|
description: 'Install and manage the unified Pulse agent for host and Docker monitoring.',
|
|
},
|
|
'system-general': {
|
|
title: 'General Settings',
|
|
description: 'Configure appearance preferences and UI behavior.',
|
|
},
|
|
'system-network': {
|
|
title: 'Network Settings',
|
|
description: 'Manage CORS, embedding, and network discovery configuration.',
|
|
},
|
|
'system-updates': {
|
|
title: 'Updates',
|
|
description:
|
|
'Check for updates, configure update channels, and manage automatic update checks.',
|
|
},
|
|
'system-backups': {
|
|
title: 'Backup Polling',
|
|
description: 'Control how often Pulse queries Proxmox for backup tasks and snapshots.',
|
|
},
|
|
'system-ai': {
|
|
title: 'AI Assistant',
|
|
description: 'Configure AI-powered infrastructure analysis and remediation suggestions.',
|
|
},
|
|
'system-pro': {
|
|
title: 'Pulse Pro',
|
|
description: 'Manage license activation and Pulse Pro feature access.',
|
|
},
|
|
api: {
|
|
title: 'API access',
|
|
description:
|
|
'Generate scoped tokens and manage automation credentials for agents and integrations.',
|
|
},
|
|
'security-overview': {
|
|
title: 'Security Overview',
|
|
description: 'View your security posture at a glance and monitor authentication status.',
|
|
},
|
|
'security-auth': {
|
|
title: 'Authentication',
|
|
description: 'Manage password-based authentication and credential rotation.',
|
|
},
|
|
'security-sso': {
|
|
title: 'Single Sign-On',
|
|
description: 'Configure OIDC providers for enterprise authentication.',
|
|
},
|
|
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 PVE_POLLING_MIN_SECONDS = 10;
|
|
const PVE_POLLING_MAX_SECONDS = 3600;
|
|
const PVE_POLLING_PRESETS = [
|
|
{ label: '10 seconds (default)', value: 10 },
|
|
{ label: '15 seconds', value: 15 },
|
|
{ label: '30 seconds', value: 30 },
|
|
{ label: '60 seconds', value: 60 },
|
|
{ label: '2 minutes', value: 120 },
|
|
{ label: '5 minutes', value: 300 },
|
|
];
|
|
|
|
// 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: _connected } = useWebSocket();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const deriveTabFromPath = (path: string): SettingsTab => {
|
|
if (path.includes('/settings/proxmox')) return 'proxmox';
|
|
if (path.includes('/settings/agent-hub')) return 'proxmox';
|
|
if (path.includes('/settings/docker')) return 'agents';
|
|
if (path.includes('/settings/containers')) return 'agents';
|
|
if (
|
|
path.includes('/settings/hosts') ||
|
|
path.includes('/settings/host-agents') ||
|
|
path.includes('/settings/servers') ||
|
|
path.includes('/settings/linuxServers') ||
|
|
path.includes('/settings/windowsServers') ||
|
|
path.includes('/settings/macServers') ||
|
|
path.includes('/settings/agents')
|
|
)
|
|
return 'agents';
|
|
if (path.includes('/settings/system-general')) return 'system-general';
|
|
if (path.includes('/settings/system-network')) return 'system-network';
|
|
if (path.includes('/settings/system-updates')) return 'system-updates';
|
|
if (path.includes('/settings/system-backups')) return 'system-backups';
|
|
if (path.includes('/settings/system-ai')) return 'system-ai';
|
|
if (path.includes('/settings/system-pro')) return 'system-pro';
|
|
if (path.includes('/settings/system')) return 'system-general';
|
|
if (path.includes('/settings/api')) return 'api';
|
|
if (path.includes('/settings/security-overview')) return 'security-overview';
|
|
if (path.includes('/settings/security-auth')) return 'security-auth';
|
|
if (path.includes('/settings/security-sso')) return 'security-sso';
|
|
if (path.includes('/settings/security')) return 'security-overview';
|
|
if (path.includes('/settings/diagnostics')) return 'diagnostics';
|
|
if (path.includes('/settings/updates')) return 'updates';
|
|
// Legacy platform paths map to the Proxmox tab
|
|
if (
|
|
path.includes('/settings/pve') ||
|
|
path.includes('/settings/pbs') ||
|
|
path.includes('/settings/pmg') ||
|
|
path.includes('/settings/docker') ||
|
|
path.includes('/settings/linuxServers') ||
|
|
path.includes('/settings/windowsServers') ||
|
|
path.includes('/settings/macServers')
|
|
) {
|
|
return 'proxmox';
|
|
}
|
|
return 'proxmox';
|
|
};
|
|
|
|
const deriveAgentFromPath = (path: string): AgentKey | null => {
|
|
if (path.includes('/settings/pve')) return 'pve';
|
|
if (path.includes('/settings/pbs')) return 'pbs';
|
|
if (path.includes('/settings/pmg')) return 'pmg';
|
|
return null;
|
|
};
|
|
|
|
const [currentTab, setCurrentTab] = createSignal<SettingsTab>(
|
|
deriveTabFromPath(location.pathname),
|
|
);
|
|
const activeTab = () => currentTab();
|
|
|
|
const [selectedAgent, setSelectedAgent] = createSignal<AgentKey>('pve');
|
|
|
|
const agentPaths: Record<AgentKey, string> = {
|
|
pve: '/settings/pve',
|
|
pbs: '/settings/pbs',
|
|
pmg: '/settings/pmg',
|
|
};
|
|
|
|
const handleSelectAgent = (agent: AgentKey) => {
|
|
setSelectedAgent(agent);
|
|
if (currentTab() !== 'proxmox') {
|
|
setCurrentTab('proxmox');
|
|
}
|
|
const target = agentPaths[agent];
|
|
if (target && location.pathname !== target) {
|
|
navigate(target, { scroll: false });
|
|
}
|
|
};
|
|
|
|
const setActiveTab = (tab: SettingsTab) => {
|
|
if (tab === 'proxmox' && deriveAgentFromPath(location.pathname) === null) {
|
|
setSelectedAgent('pve');
|
|
}
|
|
const targetPath = `/settings/${tab}`;
|
|
if (location.pathname !== targetPath) {
|
|
navigate(targetPath, { scroll: false });
|
|
return;
|
|
}
|
|
if (currentTab() !== tab) {
|
|
setCurrentTab(tab);
|
|
}
|
|
};
|
|
|
|
const headerMeta = () =>
|
|
SETTINGS_HEADER_META[activeTab()] ?? {
|
|
title: 'Settings',
|
|
description: 'Manage Pulse configuration.',
|
|
};
|
|
|
|
// 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() !== 'proxmox') {
|
|
setCurrentTab('proxmox');
|
|
}
|
|
setSelectedAgent('pve');
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith('/settings/agent-hub')) {
|
|
navigate(path.replace('/settings/agent-hub', '/settings/proxmox'), {
|
|
replace: true,
|
|
scroll: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith('/settings/servers')) {
|
|
navigate(path.replace('/settings/servers', '/settings/hosts'), {
|
|
replace: true,
|
|
scroll: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.startsWith('/settings/containers')) {
|
|
navigate(path.replace('/settings/containers', '/settings/docker'), {
|
|
replace: true,
|
|
scroll: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
path.startsWith('/settings/linuxServers') ||
|
|
path.startsWith('/settings/windowsServers') ||
|
|
path.startsWith('/settings/macServers')
|
|
) {
|
|
navigate('/settings/hosts', {
|
|
replace: true,
|
|
scroll: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const resolved = deriveTabFromPath(path);
|
|
if (resolved !== currentTab()) {
|
|
setCurrentTab(resolved);
|
|
}
|
|
|
|
if (resolved === 'proxmox') {
|
|
const agentFromPath = deriveAgentFromPath(path);
|
|
setSelectedAgent(agentFromPath ?? 'pve');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
|
|
// Sidebar always starts expanded for discoverability (issue #764)
|
|
// Users can collapse during session but it resets on page reload
|
|
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
|
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 isNodeModalVisible = (type: 'pve' | 'pbs' | 'pmg') =>
|
|
Boolean(showNodeModal() && currentNodeType() === type);
|
|
const resolveTemperatureMonitoringEnabled = (node?: NodeConfigWithStatus | null) => {
|
|
if (node && typeof node.temperatureMonitoringEnabled === 'boolean') {
|
|
return node.temperatureMonitoringEnabled;
|
|
}
|
|
return temperatureMonitoringEnabled();
|
|
};
|
|
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
|
|
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({
|
|
scanning: false,
|
|
});
|
|
|
|
const pveNodes = createMemo(() => nodes().filter((n) => n.type === 'pve'));
|
|
const pbsNodes = createMemo(() => nodes().filter((n) => n.type === 'pbs'));
|
|
const pmgNodes = createMemo(() => nodes().filter((n) => n.type === 'pmg'));
|
|
|
|
// System settings
|
|
const [pvePollingInterval, setPVEPollingInterval] = createSignal<number>(PVE_POLLING_MIN_SECONDS);
|
|
const [pvePollingSelection, setPVEPollingSelection] = createSignal<number | 'custom'>(
|
|
PVE_POLLING_MIN_SECONDS,
|
|
);
|
|
const [pvePollingCustomSeconds, setPVEPollingCustomSeconds] = createSignal(30);
|
|
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>>({});
|
|
const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true);
|
|
const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false);
|
|
// hostProxyStatus removed - pulse-sensor-proxy is deprecated in v5
|
|
const [hideLocalLogin, setHideLocalLogin] = createSignal(false);
|
|
const [savingHideLocalLogin, setSavingHideLocalLogin] = createSignal(false);
|
|
|
|
const temperatureMonitoringLocked = () =>
|
|
Boolean(
|
|
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
|
|
);
|
|
const hideLocalLoginLocked = () =>
|
|
Boolean(envOverrides().hideLocalLogin || envOverrides()['PULSE_AUTH_HIDE_LOCAL_LOGIN']);
|
|
|
|
const pvePollingEnvLocked = () =>
|
|
Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL);
|
|
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 handleHideLocalLoginChange = async (enabled: boolean): Promise<void> => {
|
|
if (hideLocalLoginLocked() || savingHideLocalLogin()) {
|
|
return;
|
|
}
|
|
|
|
const previous = hideLocalLogin();
|
|
setHideLocalLogin(enabled);
|
|
setSavingHideLocalLogin(true);
|
|
|
|
try {
|
|
await SettingsAPI.updateSystemSettings({ hideLocalLogin: enabled });
|
|
if (enabled) {
|
|
notificationStore.success('Local login hidden', 2000);
|
|
} else {
|
|
notificationStore.info('Local login visible', 2000);
|
|
}
|
|
// Reload security status to reflect changes
|
|
await loadSecurityStatus();
|
|
} catch (error) {
|
|
logger.error('Failed to update hide local login setting', error);
|
|
notificationStore.error(
|
|
error instanceof Error ? error.message : 'Failed to update hide local login setting',
|
|
);
|
|
setHideLocalLogin(previous);
|
|
} finally {
|
|
setSavingHideLocalLogin(false);
|
|
}
|
|
};
|
|
|
|
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('');
|
|
|
|
// Webhook security settings
|
|
const [webhookAllowedPrivateCIDRs, setWebhookAllowedPrivateCIDRs] = 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');
|
|
// Update installation state
|
|
const [updatePlan, setUpdatePlan] = createSignal<import('@/api/updates').UpdatePlan | null>(null);
|
|
const [isInstallingUpdate, setIsInstallingUpdate] = createSignal(false);
|
|
const [showUpdateConfirmation, setShowUpdateConfirmation] = createSignal(false);
|
|
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
|
|
const [backupPollingInterval, setBackupPollingInterval] = createSignal(0);
|
|
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
|
|
const [backupPollingUseCustom, setBackupPollingUseCustom] = createSignal(false);
|
|
const backupPollingEnvLocked = () =>
|
|
Boolean(envOverrides()['ENABLE_BACKUP_POLLING'] || envOverrides()['BACKUP_POLLING_INTERVAL']);
|
|
const backupIntervalSelectValue = () => {
|
|
if (backupPollingUseCustom()) {
|
|
return 'custom';
|
|
}
|
|
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);
|
|
|
|
// 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 authDisabledByEnv = createMemo(() => Boolean(securityStatus()?.deprecatedDisableAuth));
|
|
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 normalizeHostKey = (value?: string | null) => {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
let result = value.trim().toLowerCase();
|
|
if (!result) {
|
|
return '';
|
|
}
|
|
result = result.replace(/^https?:\/\//, '');
|
|
const slashIndex = result.indexOf('/');
|
|
if (slashIndex !== -1) {
|
|
result = result.slice(0, slashIndex);
|
|
}
|
|
const colonIndex = result.indexOf(':');
|
|
if (colonIndex !== -1) {
|
|
result = result.slice(0, colonIndex);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
|
|
if (!diag?.temperatureProxy) {
|
|
return;
|
|
}
|
|
if (diag.temperatureProxy.httpProxies) {
|
|
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
|
|
(proxy) => proxy && proxy.node && !proxy.reachable,
|
|
);
|
|
if (failing.length > 0) {
|
|
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
|
|
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
|
|
}
|
|
}
|
|
if (diag.temperatureProxy.controlPlaneStates) {
|
|
const stale = (diag.temperatureProxy.controlPlaneStates as TemperatureProxyControlPlaneState[]).filter(
|
|
(state) => state && (state.status === 'stale' || state.status === 'offline'),
|
|
);
|
|
if (stale.length > 0) {
|
|
const names = stale.map((state) => state.instance || 'Proxy').join(', ');
|
|
showWarning(`Temperature proxy control plane is behind on: ${names}`);
|
|
}
|
|
}
|
|
if (diag.temperatureProxy.socketHostCooldowns) {
|
|
const cooling = (diag.temperatureProxy.socketHostCooldowns as TemperatureProxySocketHost[]).filter(
|
|
(entry) => entry && (entry.node || entry.host),
|
|
);
|
|
if (cooling.length > 0) {
|
|
const hosts = cooling.map((entry) => entry.node || entry.host || 'proxy').join(', ');
|
|
showWarning(`Temperature proxy is cooling down the following hosts: ${hosts}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const temperatureTransportInfo = createMemo<TemperatureTransportInfo | null>(() => {
|
|
const diag = diagnosticsData();
|
|
if (!diag?.temperatureProxy) {
|
|
return null;
|
|
}
|
|
const httpMap: TemperatureTransportInfo['httpMap'] = {};
|
|
const proxies = diag.temperatureProxy.httpProxies || [];
|
|
proxies.forEach((proxy) => {
|
|
if (!proxy || !proxy.node) {
|
|
return;
|
|
}
|
|
const key = proxy.node.trim().toLowerCase();
|
|
if (!key) {
|
|
return;
|
|
}
|
|
httpMap[key] = {
|
|
reachable: Boolean(proxy.reachable),
|
|
error: proxy.error || undefined,
|
|
url: proxy.url || undefined,
|
|
};
|
|
});
|
|
const socketStatus: TemperatureTransportInfo['socketStatus'] =
|
|
diag.temperatureProxy.socketFound && diag.temperatureProxy.proxyReachable
|
|
? 'healthy'
|
|
: diag.temperatureProxy.socketFound
|
|
? 'error'
|
|
: 'missing';
|
|
const cooldowns: Record<string, TemperatureSocketCooldownInfo> = {};
|
|
const socketHosts = diag.temperatureProxy.socketHostCooldowns || [];
|
|
(socketHosts as TemperatureProxySocketHost[]).forEach((entry) => {
|
|
const key = normalizeHostKey(entry.node) || normalizeHostKey(entry.host);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
cooldowns[key] = {
|
|
secondsRemaining: entry.secondsRemaining,
|
|
until: entry.cooldownUntil,
|
|
lastError: entry.lastError || undefined,
|
|
};
|
|
});
|
|
return { httpMap, socketStatus, socketCooldowns: cooldowns };
|
|
});
|
|
|
|
|
|
|
|
const runDiagnostics = async () => {
|
|
setRunningDiagnostics(true);
|
|
try {
|
|
const response = await apiFetch('/api/diagnostics');
|
|
const diag = await response.json();
|
|
setDiagnosticsData(diag);
|
|
emitTemperatureProxyWarnings(diag);
|
|
// hostProxyStatus removed - pulse-sensor-proxy is deprecated in v5
|
|
} catch (err) {
|
|
logger.error('Failed to fetch diagnostics', err);
|
|
showError('Failed to run diagnostics');
|
|
} finally {
|
|
setRunningDiagnostics(false);
|
|
}
|
|
};
|
|
|
|
// refreshHostProxyStatus removed - pulse-sensor-proxy is deprecated in v5
|
|
|
|
createEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
const shouldPoll = currentTab() === 'proxmox';
|
|
if (!shouldPoll) {
|
|
return;
|
|
}
|
|
void runDiagnostics();
|
|
const intervalId = window.setInterval(() => {
|
|
void runDiagnostics();
|
|
}, 60000);
|
|
onCleanup(() => {
|
|
window.clearInterval(intervalId);
|
|
});
|
|
});
|
|
|
|
const tabGroups: {
|
|
id: 'platforms' | 'operations' | 'system' | 'security';
|
|
label: string;
|
|
items: {
|
|
id: SettingsTab;
|
|
label: string;
|
|
icon: Component<{ class?: string; strokeWidth?: number }>;
|
|
iconProps?: { strokeWidth?: number };
|
|
disabled?: boolean;
|
|
}[];
|
|
}[] = [
|
|
{
|
|
id: 'platforms',
|
|
label: 'Platforms',
|
|
items: [
|
|
{ id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon },
|
|
{ id: 'agents', label: 'Agents', icon: Monitor, iconProps: { strokeWidth: 2 } },
|
|
],
|
|
},
|
|
{
|
|
id: 'operations',
|
|
label: 'Operations',
|
|
items: [
|
|
{ id: 'api', label: 'API Tokens', icon: BadgeCheck },
|
|
{
|
|
id: 'diagnostics',
|
|
label: 'Diagnostics',
|
|
icon: Activity,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'system',
|
|
label: 'System',
|
|
items: [
|
|
{
|
|
id: 'system-general',
|
|
label: 'General',
|
|
icon: Sliders,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'system-network',
|
|
label: 'Network',
|
|
icon: Network,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'system-updates',
|
|
label: 'Updates',
|
|
icon: RefreshCw,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'system-backups',
|
|
label: 'Backups',
|
|
icon: Clock,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'system-ai',
|
|
label: 'AI Assistant',
|
|
icon: Sparkles,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'system-pro',
|
|
label: 'Pulse Pro',
|
|
icon: BadgeCheck,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'security',
|
|
label: 'Security',
|
|
items: [
|
|
{
|
|
id: 'security-overview',
|
|
label: 'Overview',
|
|
icon: Shield,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'security-auth',
|
|
label: 'Authentication',
|
|
icon: Lock,
|
|
iconProps: { strokeWidth: 2 },
|
|
},
|
|
{
|
|
id: 'security-sso',
|
|
label: 'Single Sign-On',
|
|
icon: Key,
|
|
iconProps: { 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) {
|
|
logger.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'))
|
|
) {
|
|
logger.info('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();
|
|
logger.debug('Security status loaded', status);
|
|
setSecurityStatus(status);
|
|
} else {
|
|
logger.error('Failed to fetch security status', { status: response.status });
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to fetch security status', err);
|
|
} finally {
|
|
setSecurityStatusLoading(false);
|
|
}
|
|
};
|
|
|
|
createEffect(() => {
|
|
if (authDisabledByEnv() && showQuickSecuritySetup()) {
|
|
setShowQuickSecuritySetup(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) {
|
|
logger.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) {
|
|
logger.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) {
|
|
logger.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) {
|
|
logger.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 handleTemperatureMonitoringChange = async (enabled: boolean): Promise<void> => {
|
|
if (temperatureMonitoringLocked() || savingTemperatureSetting()) {
|
|
return;
|
|
}
|
|
|
|
const previous = temperatureMonitoringEnabled();
|
|
setTemperatureMonitoringEnabled(enabled);
|
|
setSavingTemperatureSetting(true);
|
|
|
|
try {
|
|
await SettingsAPI.updateSystemSettings({ temperatureMonitoringEnabled: enabled });
|
|
if (enabled) {
|
|
notificationStore.success('Temperature monitoring enabled', 2000);
|
|
} else {
|
|
notificationStore.info('Temperature monitoring disabled', 2000);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to update temperature monitoring setting', error);
|
|
notificationStore.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to update temperature monitoring setting',
|
|
);
|
|
setTemperatureMonitoringEnabled(previous);
|
|
} finally {
|
|
setSavingTemperatureSetting(false);
|
|
}
|
|
};
|
|
|
|
const handleNodeTemperatureMonitoringChange = async (nodeId: string, enabled: boolean | null): Promise<void> => {
|
|
if (savingTemperatureSetting()) {
|
|
return;
|
|
}
|
|
|
|
const node = nodes().find((n) => n.id === nodeId);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
|
|
const previous = node.temperatureMonitoringEnabled;
|
|
setSavingTemperatureSetting(true);
|
|
|
|
// Update local state optimistically
|
|
setNodes(
|
|
nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: enabled } : n)),
|
|
);
|
|
|
|
// Also update editingNode if this is the node being edited
|
|
if (editingNode()?.id === nodeId) {
|
|
setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: enabled });
|
|
}
|
|
|
|
try {
|
|
await NodesAPI.updateNode(nodeId, { temperatureMonitoringEnabled: enabled } as any);
|
|
if (enabled === true) {
|
|
notificationStore.success('Temperature monitoring enabled for this node', 2000);
|
|
} else if (enabled === false) {
|
|
notificationStore.info('Temperature monitoring disabled for this node', 2000);
|
|
} else {
|
|
notificationStore.info('Using global temperature monitoring setting', 2000);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to update node temperature monitoring setting', error);
|
|
notificationStore.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to update temperature monitoring setting',
|
|
);
|
|
// Revert on error
|
|
setNodes(
|
|
nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)),
|
|
);
|
|
// Also revert editingNode
|
|
if (editingNode()?.id === nodeId) {
|
|
setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: previous });
|
|
}
|
|
} finally {
|
|
setSavingTemperatureSetting(false);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
logger.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 systemSettings = await SettingsAPI.getSystemSettings();
|
|
const rawPVESecs =
|
|
typeof systemSettings.pvePollingInterval === 'number'
|
|
? Math.round(systemSettings.pvePollingInterval)
|
|
: PVE_POLLING_MIN_SECONDS;
|
|
const clampedPVESecs = Math.min(
|
|
PVE_POLLING_MAX_SECONDS,
|
|
Math.max(PVE_POLLING_MIN_SECONDS, rawPVESecs),
|
|
);
|
|
setPVEPollingInterval(clampedPVESecs);
|
|
const presetMatch = PVE_POLLING_PRESETS.find((opt) => opt.value === clampedPVESecs);
|
|
if (presetMatch) {
|
|
setPVEPollingSelection(presetMatch.value);
|
|
} else {
|
|
setPVEPollingSelection('custom');
|
|
setPVEPollingCustomSeconds(clampedPVESecs);
|
|
}
|
|
setAllowedOrigins(systemSettings.allowedOrigins || '*');
|
|
// Connection timeout is backend-only
|
|
// Load discovery settings (default to false when unset)
|
|
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false);
|
|
applySavedDiscoverySubnet(systemSettings.discoverySubnet);
|
|
// Load embedding settings
|
|
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
|
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
|
// Load webhook security settings
|
|
setWebhookAllowedPrivateCIDRs(systemSettings.webhookAllowedPrivateCIDRs || '');
|
|
setTemperatureMonitoringEnabled(
|
|
typeof systemSettings.temperatureMonitoringEnabled === 'boolean'
|
|
? systemSettings.temperatureMonitoringEnabled
|
|
: true,
|
|
);
|
|
// Load hideLocalLogin setting
|
|
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
|
|
|
|
// 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)));
|
|
}
|
|
// Determine if the loaded interval is a custom value
|
|
const isPresetInterval = BACKUP_INTERVAL_OPTIONS.some((opt) => opt.value === intervalSeconds);
|
|
setBackupPollingUseCustom(!isPresetInterval && intervalSeconds > 0);
|
|
// 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);
|
|
}
|
|
} catch (error) {
|
|
logger.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
|
|
await updateStore.checkForUpdates(); // This will load version info too
|
|
|
|
// Fetch update info and plan from store
|
|
const storeInfo = updateStore.updateInfo();
|
|
if (storeInfo) {
|
|
setUpdateInfo(storeInfo);
|
|
// Fetch update plan if update is available
|
|
if (storeInfo.available && storeInfo.latestVersion) {
|
|
try {
|
|
const plan = await UpdatesAPI.getUpdatePlan(storeInfo.latestVersion);
|
|
setUpdatePlan(plan);
|
|
} catch (planError) {
|
|
logger.warn('Failed to fetch update plan on load', planError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only use version.channel as fallback if user hasn't configured a preference
|
|
// The user's saved updateChannel preference should take priority
|
|
// Check the signal value since systemSettings is scoped to the previous try block
|
|
if (version.channel && !updateChannel()) {
|
|
setUpdateChannel(version.channel as 'stable' | 'rc');
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load version', error);
|
|
}
|
|
} catch (error) {
|
|
logger.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-general' ||
|
|
activeTab() === 'system-network' ||
|
|
activeTab() === 'system-updates' ||
|
|
activeTab() === 'system-backups'
|
|
) {
|
|
// Save system settings using typed API
|
|
await SettingsAPI.updateSystemSettings({
|
|
pvePollingInterval: pvePollingInterval(),
|
|
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(),
|
|
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
|
|
});
|
|
}
|
|
|
|
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') {
|
|
// Check for warnings in the response
|
|
if (result.warnings && Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
const warningMessage = result.message + '\n\nWarnings:\n' + result.warnings.map((w: string) => '• ' + w).join('\n');
|
|
showWarning(warningMessage);
|
|
} else {
|
|
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 refreshClusterNodes = async (nodeId: string) => {
|
|
try {
|
|
notificationStore.info('Refreshing cluster membership...', 2000);
|
|
const result = await NodesAPI.refreshClusterNodes(nodeId);
|
|
if (result.status === 'success') {
|
|
if (result.nodesAdded && result.nodesAdded > 0) {
|
|
showSuccess(`Found ${result.nodesAdded} new node(s) in cluster "${result.clusterName}"`);
|
|
} else {
|
|
showSuccess(`Cluster "${result.clusterName}" membership verified (${result.newNodeCount} nodes)`);
|
|
}
|
|
// Refresh nodes list to show updated cluster info
|
|
await loadNodes();
|
|
} else {
|
|
throw new Error('Failed to refresh cluster');
|
|
}
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : 'Failed to refresh cluster membership');
|
|
}
|
|
};
|
|
|
|
const checkForUpdates = async () => {
|
|
setCheckingForUpdates(true);
|
|
try {
|
|
// Force check with current channel selection
|
|
await updateStore.checkForUpdates(true);
|
|
const info = updateStore.updateInfo();
|
|
setUpdateInfo(info);
|
|
|
|
// Fetch update plan if update is available
|
|
if (info?.available && info.latestVersion) {
|
|
try {
|
|
const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion);
|
|
setUpdatePlan(plan);
|
|
} catch (planError) {
|
|
logger.warn('Failed to fetch update plan', planError);
|
|
setUpdatePlan(null);
|
|
}
|
|
} else {
|
|
setUpdatePlan(null);
|
|
}
|
|
|
|
// 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');
|
|
logger.error('Update check error', error);
|
|
} finally {
|
|
setCheckingForUpdates(false);
|
|
}
|
|
};
|
|
|
|
// Handle install update from settings panel
|
|
const handleInstallUpdate = () => {
|
|
setShowUpdateConfirmation(true);
|
|
};
|
|
|
|
const handleConfirmUpdate = async () => {
|
|
const info = updateInfo();
|
|
if (!info?.downloadUrl) return;
|
|
|
|
setIsInstallingUpdate(true);
|
|
try {
|
|
await UpdatesAPI.applyUpdate(info.downloadUrl);
|
|
// Close confirmation - GlobalUpdateProgressWatcher will auto-open the progress modal
|
|
setShowUpdateConfirmation(false);
|
|
} catch (error) {
|
|
logger.error('Failed to start update', error);
|
|
showError('Failed to start update. Please try again.');
|
|
} finally {
|
|
setIsInstallingUpdate(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;
|
|
}
|
|
|
|
// Backend requires at least 12 characters for encryption security
|
|
if (exportPassphrase().length < 12) {
|
|
const hasAuth = securityStatus()?.hasAuthentication;
|
|
showError(
|
|
hasAuth && !useCustomPassphrase()
|
|
? 'Your password must be at least 12 characters. Please use a custom passphrase instead.'
|
|
: 'Passphrase must be at least 12 characters long',
|
|
);
|
|
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 && !getApiClientToken()) {
|
|
setApiTokenModalSource('export');
|
|
setShowApiTokenModal(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get CSRF token from cookie
|
|
const csrfCookie = document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('pulse_csrf='));
|
|
const csrfToken = csrfCookie
|
|
? decodeURIComponent(csrfCookie.split('=').slice(1).join('='))
|
|
: undefined;
|
|
|
|
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 = getApiClientToken();
|
|
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 = getApiClientToken();
|
|
if (hadToken) {
|
|
clearApiClientToken();
|
|
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);
|
|
logger.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 && !getApiClientToken()) {
|
|
setApiTokenModalSource('import');
|
|
setShowApiTokenModal(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fileContent = await importFile()!.text();
|
|
|
|
// Support three formats:
|
|
// 1. UI export: {status: "success", data: "base64string"}
|
|
// 2. Legacy format: {data: "base64string"}
|
|
// 3. CLI export: raw base64 string (no JSON wrapper)
|
|
let encryptedData: string;
|
|
|
|
// Try to parse as JSON first
|
|
try {
|
|
const exportData = JSON.parse(fileContent);
|
|
|
|
if (typeof exportData === 'string') {
|
|
// Raw base64 string wrapped in JSON (edge case)
|
|
encryptedData = exportData;
|
|
} else if (exportData.data) {
|
|
// Standard format with data field
|
|
encryptedData = exportData.data;
|
|
} else {
|
|
showError('Invalid backup file format. Expected encrypted data in "data" field.');
|
|
return;
|
|
}
|
|
} catch (_parseError) {
|
|
// Not JSON - treat entire contents as raw base64 from CLI export
|
|
encryptedData = fileContent.trim();
|
|
}
|
|
|
|
// Get CSRF token from cookie
|
|
const csrfCookie = document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('pulse_csrf='));
|
|
const csrfToken = csrfCookie
|
|
? decodeURIComponent(csrfCookie.split('=').slice(1).join('='))
|
|
: undefined;
|
|
|
|
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 = getApiClientToken();
|
|
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: encryptedData,
|
|
}),
|
|
});
|
|
|
|
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 = getApiClientToken();
|
|
if (hadToken) {
|
|
clearApiClientToken();
|
|
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');
|
|
logger.error('Import error', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div class="space-y-6">
|
|
{/* Page header - no card wrapper for cleaner hierarchy */}
|
|
<div class="px-1">
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
|
{headerMeta().title}
|
|
</h1>
|
|
<p class="text-base text-gray-600 dark:text-gray-400">{headerMeta().description}</p>
|
|
</div>
|
|
|
|
{/* Save notification bar - only show when there are unsaved changes */}
|
|
<Show
|
|
when={
|
|
hasUnsavedChanges() &&
|
|
(activeTab() === 'proxmox' ||
|
|
activeTab() === 'system-general' ||
|
|
activeTab() === 'system-network' ||
|
|
activeTab() === 'system-updates' ||
|
|
activeTab() === 'system-backups')
|
|
}
|
|
>
|
|
<div class="bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-500 dark:border-amber-400 rounded-r-lg shadow-sm p-4">
|
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
<div class="flex items-start gap-3">
|
|
<svg
|
|
class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
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-semibold text-amber-900 dark:text-amber-100">Unsaved changes</p>
|
|
<p class="text-sm text-amber-700 dark:text-amber-200 mt-0.5">
|
|
Your changes will be lost if you navigate away
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex w-full sm:w-auto gap-3">
|
|
<button
|
|
type="button"
|
|
class="flex-1 sm:flex-initial px-5 py-2.5 text-sm font-medium bg-amber-600 text-white rounded-lg hover:bg-amber-700 shadow-sm transition-colors"
|
|
onClick={saveSettings}
|
|
>
|
|
Save Changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-200 hover:underline transition-colors"
|
|
onClick={() => {
|
|
window.location.reload();
|
|
}}
|
|
>
|
|
Discard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Card padding="none" class="relative lg:flex overflow-hidden">
|
|
<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-200`}
|
|
aria-label="Settings navigation"
|
|
aria-expanded={!sidebarCollapsed()}
|
|
>
|
|
<div
|
|
class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
|
|
>
|
|
<Show when={!sidebarCollapsed()}>
|
|
<div class="flex items-center justify-between pb-2 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSidebarCollapsed(true)}
|
|
class="p-1 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
aria-label="Collapse sidebar"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
<Show when={sidebarCollapsed()}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
class="w-full p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
aria-label="Expand sidebar"
|
|
>
|
|
<svg
|
|
class="w-5 h-5 mx-auto"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 5l7 7-7 7M5 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</Show>
|
|
<div id="settings-sidebar-menu" class="space-y-5">
|
|
<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) => {
|
|
const isActive = () => activeTab() === item.id;
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-current={isActive() ? 'page' : undefined}
|
|
disabled={item.disabled}
|
|
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 ${item.disabled
|
|
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
|
|
: isActive()
|
|
? '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={() => {
|
|
if (item.disabled) return;
|
|
setActiveTab(item.id);
|
|
}}
|
|
title={sidebarCollapsed() ? item.label : undefined}
|
|
>
|
|
<item.icon class="w-4 h-4" {...(item.iconProps || {})} />
|
|
<Show when={!sidebarCollapsed()}>
|
|
<span class="truncate">{item.label}</span>
|
|
</Show>
|
|
</button>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-hidden">
|
|
<Show when={flatTabs.length > 0}>
|
|
<div class="lg:hidden border-b border-gray-200 dark:border-gray-700">
|
|
<div
|
|
class="flex gap-1 px-2 py-1 w-full overflow-x-auto scrollbar-hide"
|
|
style="-webkit-overflow-scrolling: touch;"
|
|
>
|
|
<For each={flatTabs}>
|
|
{(tab) => {
|
|
const isActive = activeTab() === tab.id;
|
|
const disabled = tab.disabled;
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
class={`px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${disabled
|
|
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600 border-transparent'
|
|
: isActive
|
|
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
|
: 'text-gray-600 dark:text-gray-400 border-transparent hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
|
}`}
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
setActiveTab(tab.id);
|
|
}}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="p-6 lg:p-8">
|
|
<Show when={activeTab() === 'proxmox'}>
|
|
<SettingsSectionNav
|
|
current={selectedAgent()}
|
|
onSelect={handleSelectAgent}
|
|
class="mb-6"
|
|
/>
|
|
</Show>
|
|
|
|
{/* Recommendation banner for Proxmox tab */}
|
|
<Show when={activeTab() === 'proxmox'}>
|
|
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 mb-6 dark:border-blue-800 dark:bg-blue-900/20">
|
|
<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"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<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="flex-1">
|
|
<p class="text-sm text-blue-800 dark:text-blue-200">
|
|
<strong>Recommended:</strong> Install the Pulse agent on your Proxmox nodes for automatic setup, temperature monitoring, and AI features.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate('/settings/agents')}
|
|
class="mt-2 text-sm font-medium text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200 underline"
|
|
>
|
|
Go to Agents tab →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* PVE Nodes Tab */}
|
|
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'pve'}>
|
|
<div class="space-y-6 mt-6">
|
|
<div class="space-y-4">
|
|
<Show when={!initialLoadComplete()}>
|
|
<div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
|
|
Loading configuration...
|
|
</div>
|
|
</Show>
|
|
<Show when={initialLoadComplete()}>
|
|
<Card padding="lg">
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
|
Proxmox VE nodes
|
|
</h4>
|
|
<div class="flex flex-wrap items-center justify-start gap-2 sm: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: ToggleChangeEvent) => {
|
|
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>
|
|
|
|
<Show when={pveNodes().length > 0}>
|
|
<PveNodesTable
|
|
nodes={pveNodes()}
|
|
stateNodes={state.nodes ?? []}
|
|
stateHosts={state.hosts ?? []}
|
|
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
|
|
temperatureTransports={temperatureTransportInfo()}
|
|
onTestConnection={testNodeConnection}
|
|
onEdit={(node) => {
|
|
setEditingNode(node);
|
|
setCurrentNodeType('pve');
|
|
setShowNodeModal(true);
|
|
}}
|
|
onDelete={requestDeleteNode}
|
|
onRefreshCluster={refreshClusterNodes}
|
|
/>
|
|
</Show>
|
|
|
|
<Show
|
|
when={
|
|
pveNodes().length === 0 &&
|
|
discoveredNodes().filter((n) => n.type === 'pve').length === 0
|
|
}
|
|
>
|
|
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
|
|
<Server class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<p class="text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
|
No PVE nodes configured
|
|
</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Add a Proxmox VE node to start monitoring your infrastructure
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* 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">
|
|
<Loader class="h-4 w-4 animate-spin" />
|
|
<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">
|
|
<Loader class="h-4 w-4 animate-spin" />
|
|
<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>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* PBS Nodes Tab */}
|
|
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'pbs'}>
|
|
<div class="space-y-6 mt-6">
|
|
<div class="space-y-4">
|
|
<Show when={!initialLoadComplete()}>
|
|
<div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
|
|
Loading configuration...
|
|
</div>
|
|
</Show>
|
|
<Show when={initialLoadComplete()}>
|
|
<Card padding="lg">
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
|
Proxmox Backup Server nodes
|
|
</h4>
|
|
<div class="flex flex-wrap items-center justify-start gap-2 sm: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: ToggleChangeEvent) => {
|
|
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>
|
|
|
|
<Show when={pbsNodes().length > 0}>
|
|
<PbsNodesTable
|
|
nodes={pbsNodes()}
|
|
statePbs={state.pbs ?? []}
|
|
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
|
|
onTestConnection={testNodeConnection}
|
|
onEdit={(node) => {
|
|
setEditingNode(node);
|
|
setCurrentNodeType('pbs');
|
|
setShowNodeModal(true);
|
|
}}
|
|
onDelete={requestDeleteNode}
|
|
/>
|
|
</Show>
|
|
|
|
<Show
|
|
when={
|
|
pbsNodes().length === 0 &&
|
|
discoveredNodes().filter((n) => n.type === 'pbs').length === 0
|
|
}
|
|
>
|
|
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
|
|
<HardDrive class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<p class="text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
|
No PBS nodes configured
|
|
</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Add a Proxmox Backup Server to monitor your backup infrastructure
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* 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">
|
|
<Loader class="h-4 w-4 animate-spin" />
|
|
<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">
|
|
<Loader class="h-4 w-4 animate-spin" />
|
|
<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>
|
|
</div>
|
|
</Show>
|
|
{/* PMG Nodes Tab */}
|
|
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'pmg'}>
|
|
<div class="space-y-6 mt-6">
|
|
<div class="space-y-4">
|
|
<Show when={!initialLoadComplete()}>
|
|
<div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
|
|
Loading configuration...
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={initialLoadComplete()}>
|
|
<Card padding="lg">
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
|
Proxmox Mail Gateway nodes
|
|
</h4>
|
|
<div class="flex flex-wrap items-center justify-start gap-2 sm: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: ToggleChangeEvent) => {
|
|
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>
|
|
|
|
<Show when={pmgNodes().length > 0}>
|
|
<PmgNodesTable
|
|
nodes={pmgNodes()}
|
|
statePmg={state.pmg ?? []}
|
|
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
|
|
onTestConnection={testNodeConnection}
|
|
onEdit={(node) => {
|
|
setEditingNode(nodes().find((n) => n.id === node.id) ?? null);
|
|
setCurrentNodeType('pmg');
|
|
setModalResetKey((prev) => prev + 1);
|
|
setShowNodeModal(true);
|
|
}}
|
|
onDelete={requestDeleteNode}
|
|
/>
|
|
</Show>
|
|
|
|
<Show when={pmgNodes().length === 0}>
|
|
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
|
|
<Mail class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<p class="text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
|
No PMG nodes configured
|
|
</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Add a Proxmox Mail Gateway to monitor mail queue and quarantine
|
|
metrics
|
|
</p>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* 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">
|
|
<Loader class="h-4 w-4 animate-spin" />
|
|
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>
|
|
</div>
|
|
</Show>
|
|
{/* Unified Agents Tab */}
|
|
<Show when={activeTab() === 'agents'}>
|
|
<UnifiedAgents />
|
|
</Show>
|
|
|
|
{/* System General Tab */}
|
|
<Show when={activeTab() === 'system-general'}>
|
|
<GeneralSettingsPanel
|
|
darkMode={props.darkMode}
|
|
toggleDarkMode={props.toggleDarkMode}
|
|
pvePollingInterval={pvePollingInterval}
|
|
setPVEPollingInterval={setPVEPollingInterval}
|
|
pvePollingSelection={pvePollingSelection}
|
|
setPVEPollingSelection={setPVEPollingSelection}
|
|
pvePollingCustomSeconds={pvePollingCustomSeconds}
|
|
setPVEPollingCustomSeconds={setPVEPollingCustomSeconds}
|
|
pvePollingEnvLocked={pvePollingEnvLocked}
|
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
|
/>
|
|
</Show>
|
|
|
|
{/* System Network Tab */}
|
|
<Show when={activeTab() === 'system-network'}>
|
|
<NetworkSettingsPanel
|
|
discoveryEnabled={discoveryEnabled}
|
|
discoveryMode={discoveryMode}
|
|
discoverySubnetDraft={discoverySubnetDraft}
|
|
discoverySubnetError={discoverySubnetError}
|
|
savingDiscoverySettings={savingDiscoverySettings}
|
|
envOverrides={envOverrides}
|
|
allowedOrigins={allowedOrigins}
|
|
setAllowedOrigins={setAllowedOrigins}
|
|
allowEmbedding={allowEmbedding}
|
|
setAllowEmbedding={setAllowEmbedding}
|
|
allowedEmbedOrigins={allowedEmbedOrigins}
|
|
setAllowedEmbedOrigins={setAllowedEmbedOrigins}
|
|
webhookAllowedPrivateCIDRs={webhookAllowedPrivateCIDRs}
|
|
setWebhookAllowedPrivateCIDRs={setWebhookAllowedPrivateCIDRs}
|
|
handleDiscoveryEnabledChange={handleDiscoveryEnabledChange}
|
|
handleDiscoveryModeChange={handleDiscoveryModeChange}
|
|
setDiscoveryMode={setDiscoveryMode}
|
|
setDiscoverySubnetDraft={setDiscoverySubnetDraft}
|
|
setDiscoverySubnetError={setDiscoverySubnetError}
|
|
setLastCustomSubnet={setLastCustomSubnet}
|
|
commitDiscoverySubnet={commitDiscoverySubnet}
|
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
|
parseSubnetList={parseSubnetList}
|
|
normalizeSubnetList={normalizeSubnetList}
|
|
isValidCIDR={isValidCIDR}
|
|
currentDraftSubnetValue={currentDraftSubnetValue}
|
|
discoverySubnetInputRef={(el) => {
|
|
discoverySubnetInputRef = el;
|
|
}}
|
|
/>
|
|
</Show>
|
|
|
|
{/* System Updates Tab */}
|
|
<Show when={activeTab() === 'system-updates'}>
|
|
<UpdatesSettingsPanel
|
|
versionInfo={versionInfo}
|
|
updateInfo={updateInfo}
|
|
checkingForUpdates={checkingForUpdates}
|
|
updateChannel={updateChannel}
|
|
setUpdateChannel={setUpdateChannel}
|
|
autoUpdateEnabled={autoUpdateEnabled}
|
|
setAutoUpdateEnabled={setAutoUpdateEnabled}
|
|
autoUpdateCheckInterval={autoUpdateCheckInterval}
|
|
setAutoUpdateCheckInterval={setAutoUpdateCheckInterval}
|
|
autoUpdateTime={autoUpdateTime}
|
|
setAutoUpdateTime={setAutoUpdateTime}
|
|
checkForUpdates={checkForUpdates}
|
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
|
updatePlan={updatePlan}
|
|
onInstallUpdate={handleInstallUpdate}
|
|
isInstalling={isInstallingUpdate}
|
|
/>
|
|
</Show>
|
|
|
|
{/* System Backups Tab */}
|
|
<Show when={activeTab() === 'system-backups'}>
|
|
<BackupsSettingsPanel
|
|
backupPollingEnabled={backupPollingEnabled}
|
|
setBackupPollingEnabled={setBackupPollingEnabled}
|
|
backupPollingInterval={backupPollingInterval}
|
|
setBackupPollingInterval={setBackupPollingInterval}
|
|
backupPollingCustomMinutes={backupPollingCustomMinutes}
|
|
setBackupPollingCustomMinutes={setBackupPollingCustomMinutes}
|
|
backupPollingUseCustom={backupPollingUseCustom}
|
|
setBackupPollingUseCustom={setBackupPollingUseCustom}
|
|
backupPollingEnvLocked={backupPollingEnvLocked}
|
|
backupIntervalSelectValue={backupIntervalSelectValue}
|
|
backupIntervalSummary={backupIntervalSummary}
|
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
|
showExportDialog={showExportDialog}
|
|
setShowExportDialog={setShowExportDialog}
|
|
showImportDialog={showImportDialog}
|
|
setShowImportDialog={setShowImportDialog}
|
|
setUseCustomPassphrase={setUseCustomPassphrase}
|
|
securityStatus={securityStatus}
|
|
/>
|
|
</Show>
|
|
|
|
{/* AI Assistant Tab */}
|
|
<Show when={activeTab() === 'system-ai'}>
|
|
<div class="space-y-6">
|
|
<AISettings />
|
|
<AICostDashboard />
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Pulse Pro License Tab */}
|
|
<Show when={activeTab() === 'system-pro'}>
|
|
<ProLicensePanel />
|
|
</Show>
|
|
|
|
{/* API Access */}
|
|
<Show when={activeTab() === 'api'}>
|
|
<APIAccessPanel
|
|
currentTokenHint={securityStatus()?.apiTokenHint}
|
|
onTokensChanged={() => {
|
|
void loadSecurityStatus();
|
|
}}
|
|
refreshing={securityStatusLoading()}
|
|
/>
|
|
</Show>
|
|
|
|
{/* Security Overview Tab */}
|
|
<Show when={activeTab() === 'security-overview'}>
|
|
<SecurityOverviewPanel
|
|
securityStatus={securityStatus}
|
|
securityStatusLoading={securityStatusLoading}
|
|
/>
|
|
</Show>
|
|
|
|
{/* Security Authentication Tab */}
|
|
<Show when={activeTab() === 'security-auth'}>
|
|
<SecurityAuthPanel
|
|
securityStatus={securityStatus}
|
|
securityStatusLoading={securityStatusLoading}
|
|
versionInfo={versionInfo}
|
|
authDisabledByEnv={authDisabledByEnv}
|
|
showQuickSecuritySetup={showQuickSecuritySetup}
|
|
setShowQuickSecuritySetup={setShowQuickSecuritySetup}
|
|
showQuickSecurityWizard={showQuickSecurityWizard}
|
|
setShowQuickSecurityWizard={setShowQuickSecurityWizard}
|
|
showPasswordModal={showPasswordModal}
|
|
setShowPasswordModal={setShowPasswordModal}
|
|
hideLocalLogin={hideLocalLogin}
|
|
hideLocalLoginLocked={hideLocalLoginLocked}
|
|
savingHideLocalLogin={savingHideLocalLogin}
|
|
handleHideLocalLoginChange={handleHideLocalLoginChange}
|
|
loadSecurityStatus={loadSecurityStatus}
|
|
/>
|
|
</Show>
|
|
|
|
{/* Security Single Sign-On Tab */}
|
|
<Show when={activeTab() === 'security-sso'}>
|
|
<div class="space-y-6">
|
|
<OIDCPanel onConfigUpdated={loadSecurityStatus} />
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Diagnostics Tab */}
|
|
<Show when={activeTab() === 'diagnostics'}>
|
|
<DiagnosticsPanel />
|
|
</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={isNodeModalVisible('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}
|
|
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
|
|
editingNode()?.type === 'pve' ? editingNode() : null,
|
|
)}
|
|
temperatureMonitoringLocked={temperatureMonitoringLocked()}
|
|
savingTemperatureSetting={savingTemperatureSetting()}
|
|
onToggleTemperatureMonitoring={
|
|
editingNode()?.id
|
|
? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled)
|
|
: handleTemperatureMonitoringChange
|
|
}
|
|
onSave={async (nodeData) => {
|
|
try {
|
|
if (editingNode() && editingNode()!.id) {
|
|
// Update existing node (only if it has a valid ID)
|
|
await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig);
|
|
|
|
// Update local state
|
|
setNodes(
|
|
nodes().map((n) =>
|
|
n.id === editingNode()!.id
|
|
? {
|
|
...n,
|
|
...nodeData,
|
|
// Update hasPassword/hasToken based on whether credentials were provided
|
|
hasPassword: nodeData.password ? true : n.hasPassword,
|
|
hasToken: nodeData.tokenValue ? true : n.hasToken,
|
|
status: 'pending',
|
|
}
|
|
: n,
|
|
),
|
|
);
|
|
showSuccess('Node updated successfully');
|
|
} else {
|
|
// Add new node
|
|
await NodesAPI.addNode(nodeData as NodeConfig);
|
|
|
|
// Reload nodes to get the new ID
|
|
const nodesList = await NodesAPI.getNodes();
|
|
const nodesWithStatus = nodesList.map((node) => ({
|
|
...node,
|
|
// Use the hasPassword/hasToken from the API if available, otherwise check local fields
|
|
hasPassword: node.hasPassword ?? !!node.password,
|
|
hasToken: node.hasToken ?? !!node.tokenValue,
|
|
status: node.status || ('pending' as const),
|
|
}));
|
|
setNodes(nodesWithStatus);
|
|
showSuccess('Node added successfully');
|
|
}
|
|
|
|
setShowNodeModal(false);
|
|
setEditingNode(null);
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : 'Operation failed');
|
|
}
|
|
}}
|
|
/>
|
|
</Show >
|
|
|
|
{/* PBS Node Modal - Separate instance to prevent contamination */}
|
|
< Show when={isNodeModalVisible('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}
|
|
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
|
|
editingNode()?.type === 'pbs' ? editingNode() : null,
|
|
)}
|
|
temperatureMonitoringLocked={temperatureMonitoringLocked()}
|
|
savingTemperatureSetting={savingTemperatureSetting()}
|
|
onToggleTemperatureMonitoring={
|
|
editingNode()?.id
|
|
? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled)
|
|
: handleTemperatureMonitoringChange
|
|
}
|
|
onSave={async (nodeData) => {
|
|
try {
|
|
if (editingNode() && editingNode()!.id) {
|
|
// Update existing node (only if it has a valid ID)
|
|
await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig);
|
|
|
|
// Update local state
|
|
setNodes(
|
|
nodes().map((n) =>
|
|
n.id === editingNode()!.id
|
|
? {
|
|
...n,
|
|
...nodeData,
|
|
hasPassword: nodeData.password ? true : n.hasPassword,
|
|
hasToken: nodeData.tokenValue ? true : n.hasToken,
|
|
status: 'pending',
|
|
}
|
|
: n,
|
|
),
|
|
);
|
|
showSuccess('Node updated successfully');
|
|
} else {
|
|
// Add new node
|
|
await NodesAPI.addNode(nodeData as NodeConfig);
|
|
|
|
// Reload the nodes list to get the latest state
|
|
const nodesList = await NodesAPI.getNodes();
|
|
const nodesWithStatus = nodesList.map((node) => ({
|
|
...node,
|
|
// Use the hasPassword/hasToken from the API if available, otherwise check local fields
|
|
hasPassword: node.hasPassword ?? !!node.password,
|
|
hasToken: node.hasToken ?? !!node.tokenValue,
|
|
status: node.status || ('pending' as const),
|
|
}));
|
|
setNodes(nodesWithStatus);
|
|
showSuccess('Node added successfully');
|
|
}
|
|
|
|
setShowNodeModal(false);
|
|
setEditingNode(null);
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : 'Operation failed');
|
|
}
|
|
}}
|
|
/>
|
|
</Show >
|
|
|
|
{/* PMG Node Modal */}
|
|
< Show when={isNodeModalVisible('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}
|
|
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
|
|
editingNode()?.type === 'pmg' ? editingNode() : null,
|
|
)}
|
|
temperatureMonitoringLocked={temperatureMonitoringLocked()}
|
|
savingTemperatureSetting={savingTemperatureSetting()}
|
|
onToggleTemperatureMonitoring={
|
|
editingNode()?.id
|
|
? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled)
|
|
: handleTemperatureMonitoringChange
|
|
}
|
|
onSave={async (nodeData) => {
|
|
try {
|
|
if (editingNode() && editingNode()!.id) {
|
|
await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig);
|
|
setNodes(
|
|
nodes().map((n) =>
|
|
n.id === editingNode()!.id
|
|
? {
|
|
...n,
|
|
...nodeData,
|
|
hasPassword: nodeData.password ? true : n.hasPassword,
|
|
hasToken: nodeData.tokenValue ? true : n.hasToken,
|
|
status: 'pending',
|
|
}
|
|
: n,
|
|
),
|
|
);
|
|
showSuccess('Node updated successfully');
|
|
} else {
|
|
await NodesAPI.addNode(nodeData as NodeConfig);
|
|
const nodesList = await NodesAPI.getNodes();
|
|
const nodesWithStatus = nodesList.map((node) => ({
|
|
...node,
|
|
hasPassword: node.hasPassword ?? !!node.password,
|
|
hasToken: node.hasToken ?? !!node.tokenValue,
|
|
status: node.status || ('pending' as const),
|
|
}));
|
|
setNodes(nodesWithStatus);
|
|
showSuccess('Node added successfully');
|
|
}
|
|
|
|
setShowNodeModal(false);
|
|
setEditingNode(null);
|
|
} catch (error) {
|
|
showError(error instanceof Error ? error.message : 'Operation failed');
|
|
}
|
|
}}
|
|
/>
|
|
</Show >
|
|
|
|
{/* Update Confirmation Modal */}
|
|
<UpdateConfirmationModal
|
|
isOpen={showUpdateConfirmation()}
|
|
onClose={() => setShowUpdateConfirmation(false)}
|
|
onConfirm={handleConfirmUpdate}
|
|
currentVersion={versionInfo()?.version || 'Unknown'}
|
|
latestVersion={updateInfo()?.latestVersion || ''}
|
|
plan={updatePlan() || {
|
|
canAutoUpdate: false,
|
|
requiresRoot: false,
|
|
rollbackSupport: false,
|
|
}}
|
|
isApplying={isInstallingUpdate()}
|
|
/>
|
|
|
|
{/* 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()) {
|
|
const tokenValue = apiTokenInput()!;
|
|
setApiClientToken(tokenValue);
|
|
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;
|