diff --git a/Makefile b/Makefile index 01d054d..d8965b2 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ dev: frontend backend sudo systemctl restart pulse-hot-dev dev-hot: - ./scripts/dev-hot.sh + ./scripts/hot-dev.sh # Clean build artifacts clean: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bf9d4cb..28d9efa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -129,7 +129,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout "adaptivePollingBaseInterval": 10, // Target cadence (seconds) "adaptivePollingMinInterval": 5, // Fastest cadence (seconds) "adaptivePollingMaxInterval": 300, // Slowest cadence (seconds) - "discoveryEnabled": true, // Enable/disable network discovery for Proxmox/PBS servers + "discoveryEnabled": false, // Enable/disable network discovery for Proxmox/PBS servers "discoverySubnet": "auto", // CIDR to scan ("auto" discovers common ranges) "theme": "" // UI theme preference: "", "light", or "dark" } @@ -362,7 +362,7 @@ Settings are loaded in this order (later overrides earlier): #### Configuration Variables (override system.json) These env vars override system.json values. When set, the UI will show a warning and disable the affected fields: -- `DISCOVERY_ENABLED` - Enable/disable network discovery (default: true) +- `DISCOVERY_ENABLED` - Enable/disable network discovery (default: false) - `DISCOVERY_SUBNET` - Custom network to scan (default: auto-scans common networks) - `CONNECTION_TIMEOUT` - API timeout in seconds (default: 10) - `ALLOWED_ORIGINS` - CORS origins (default: same-origin only) diff --git a/docs/PULSE_SENSOR_PROXY_HARDENING.md b/docs/PULSE_SENSOR_PROXY_HARDENING.md index e0c0a3f..4220df2 100644 --- a/docs/PULSE_SENSOR_PROXY_HARDENING.md +++ b/docs/PULSE_SENSOR_PROXY_HARDENING.md @@ -184,7 +184,7 @@ If you previously followed the legacy guide (manual `lxc.mount.entry` and `/run/ This is the same workflow you used originally—no extra commands are required. Just remove the node from Pulse, click “Copy install script,” run it on the Proxmox host, and add the node again. -> **Advanced option**: If you’d rather refresh in place without removing the node, you can rerun the host-side installer directly (e.g. `sudo /opt/pulse/scripts/install-sensor-proxy.sh --ctid `). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt. +> **Advanced option**: If you’d rather refresh in place without removing the node, you can rerun the host-side installer directly (e.g. `sudo /opt/pulse/scripts/install-sensor-proxy.sh --ctid --pulse-server http://:7655`). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt. ### Runtime Verification diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index 5b4d002..a9c1d69 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -354,14 +354,34 @@ As of v4.24.0, containerized deployments use **pulse-sensor-proxy** which elimin **For new installations:** The proxy is installed automatically during LXC setup. No action required. +**Installed from inside an existing LXC?** The container-only installer cannot create the host bind mount. Run the host-side script below on your Proxmox node to enable temperature monitoring. When Pulse is running in that container, append the server URL so the proxy script can fall back to downloading the binary from Pulse itself if GitHub isn’t available. + **For existing installations (pre-v4.24.0):** Upgrade your deployment to use the proxy: ```bash # On your Proxmox host curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \ - bash -s -- --ctid + bash -s -- --ctid --pulse-server http://:7655 ``` +> **Heads up for v4.23.x:** Those builds don't ship a standalone `pulse-sensor-proxy` binary yet and the HTTP fallback still requires authentication. Either upgrade to a newer release, install Pulse from source (`install.sh --source main`), or pass a locally built binary with `--local-binary /path/to/pulse-sensor-proxy`. + +### Manual / Automated Host Configuration + +If you prefer to manage the proxy installation yourself (Ansible, Salt, etc.), follow these steps: + +1. **Copy the installer** + Download `install-sensor-proxy.sh` from the repository (or cache it internally) and distribute it to the Proxmox host(s). +2. **Run with required flags** + Execute it with the container ID and Pulse URL: + `bash install-sensor-proxy.sh --ctid --pulse-server http://:7655` +3. **Verify bind mount** + Check `/etc/pve/lxc/.conf` for the `mp` entry and ensure `/mnt/pulse-proxy/pulse-sensor-proxy.sock` exists inside the container after the restart. +4. **Re-run after adding Proxmox nodes** + The script is idempotent; rerun it anytime you add Proxmox nodes so their SSH keys are provisioned. + +Document these steps in your automation so GitHub/network hiccups still succeed by pulling the proxy binary directly from the running Pulse instance. + ### Legacy Security Concerns (Pre-v4.24.0) Older versions stored SSH keys inside the container, creating security risks: diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 773b053..8a2c3b3 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -44,7 +44,6 @@ import { DockerIcon } from '@/components/icons/DockerIcon'; import { AlertsIcon } from '@/components/icons/AlertsIcon'; import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon'; import { TokenRevealDialog } from './components/TokenRevealDialog'; -import { ActivationBanner } from './components/Alerts/ActivationBanner'; import { useAlertsActivation } from './stores/alertsActivation'; // Enhanced store type with proper typing @@ -567,15 +566,6 @@ function App() { -
diff --git a/frontend-modern/src/components/Alerts/ActivationBanner.tsx b/frontend-modern/src/components/Alerts/ActivationBanner.tsx index 6f3b04a..a112d56 100644 --- a/frontend-modern/src/components/Alerts/ActivationBanner.tsx +++ b/frontend-modern/src/components/Alerts/ActivationBanner.tsx @@ -1,8 +1,6 @@ -import { Show, createEffect, createMemo, createSignal } from 'solid-js'; import type { JSX } from 'solid-js'; import type { Alert } from '@/types/api'; import type { ActivationState, AlertConfig } from '@/types/alerts'; -import { ActivationModal } from './ActivationModal'; interface ActivationBannerProps { activationState: () => ActivationState | null; @@ -14,104 +12,7 @@ interface ActivationBannerProps { activate: () => Promise; } -export function ActivationBanner(props: ActivationBannerProps): JSX.Element { - const [isModalOpen, setIsModalOpen] = createSignal(false); - - const shouldShow = createMemo(() => { - const state = props.activationState(); - return state === 'pending_review' || state === 'snoozed'; - }); - - createEffect(() => { - // Close the modal automatically if activation becomes active while it is open - if (!shouldShow() && isModalOpen()) { - setIsModalOpen(false); - } - }); - - const violationCount = createMemo(() => props.activeAlerts()?.length ?? 0); - - const observationSummary = createMemo(() => { - const count = violationCount(); - if (count <= 0) { - return 'All systems healthy — no alerts triggered.'; - } - const label = count === 1 ? 'issue' : 'issues'; - return `${count} ${label} detected.`; - }); - - const handleReview = async () => { - await props.refreshActiveAlerts(); - setIsModalOpen(true); - }; - - const handleActivated = async () => { - await props.refreshActiveAlerts(); - }; - - return ( - <> - -
-
-
-
- - - - -
-

- Monitoring is active. Review your settings to enable notifications. -

-

{observationSummary()}

- -

- 24-hour setup period ending soon — activate to start receiving notifications. -

-
-
-
- -
- -
-
-
-
-
- - setIsModalOpen(false)} - onActivated={handleActivated} - config={props.config} - activeAlerts={props.activeAlerts} - isLoading={props.isLoading} - activate={props.activate} - refreshActiveAlerts={props.refreshActiveAlerts} - /> - - ); +export function ActivationBanner(_props: ActivationBannerProps): JSX.Element { + // Notifications activation banner/modal intentionally disabled. + return <>; } diff --git a/frontend-modern/src/components/Alerts/ActivationModal.tsx b/frontend-modern/src/components/Alerts/ActivationModal.tsx index 156bf2c..03e0652 100644 --- a/frontend-modern/src/components/Alerts/ActivationModal.tsx +++ b/frontend-modern/src/components/Alerts/ActivationModal.tsx @@ -185,7 +185,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
-
+

Ready to activate notifications

@@ -206,7 +206,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
-
+

Current thresholds diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index 48b0bb1..4b6c1d9 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -21,6 +21,7 @@ import type { } from '@/types/api'; import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable'; +import { useAlertsActivation } from '@/stores/alertsActivation'; type OverrideType = | 'guest' | 'node' @@ -232,6 +233,8 @@ interface ThresholdsTableProps { export function ThresholdsTable(props: ThresholdsTableProps) { const navigate = useNavigate(); const location = useLocation(); + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const [searchTerm, setSearchTerm] = createSignal(''); const [editingId, setEditingId] = createSignal(null); @@ -393,6 +396,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) { // Check if there's an active alert for a resource/metric const hasActiveAlert = (resourceId: string, metric: string): boolean => { + if (!alertsEnabled()) return false; if (!props.activeAlerts) return false; const alertKey = `${resourceId}-${metric}`; return alertKey in props.activeAlerts; diff --git a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx b/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx index 73f3d7f..2921e80 100644 --- a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx +++ b/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx @@ -6,6 +6,7 @@ import { AlertIndicator } from '@/components/shared/AlertIndicators'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; +import { useAlertsActivation } from '@/stores/alertsActivation'; interface CompactNodeCardProps { node: Node; @@ -16,6 +17,8 @@ interface CompactNodeCardProps { const CompactNodeCard: Component = (props) => { const { activeAlerts } = useWebSocket(); + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const isOnline = () => props.node.status === 'online' && props.node.uptime > 0; @@ -29,11 +32,15 @@ const CompactNodeCard: Component = (props) => { const displayName = () => getNodeDisplayName(props.node); const showActualName = () => hasAlternateDisplayName(props.node); - const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts); - const nodeAlerts = createMemo(() => - getResourceAlerts(props.node.id || props.node.name, activeAlerts), + const alertStyles = createMemo(() => + getAlertStyles(props.node.id || props.node.name, activeAlerts, alertsEnabled()), + ); + const nodeAlerts = createMemo(() => + getResourceAlerts(props.node.id || props.node.name, activeAlerts, alertsEnabled()), + ); + const unacknowledgedNodeAlerts = createMemo(() => + nodeAlerts().filter((alert) => !alert.acknowledged), ); - const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged)); // Get status color const getMetricColor = (value: number, type: 'cpu' | 'mem' | 'disk') => { @@ -72,9 +79,9 @@ const CompactNodeCard: Component = (props) => { ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : !isOnline() ? 'border-red-500' - : alertStyles.hasUnacknowledgedAlert + : alertStyles().hasUnacknowledgedAlert ? 'border-orange-500' - : alertStyles.hasAcknowledgedOnlyAlert + : alertStyles().hasAcknowledgedOnlyAlert ? 'border-gray-400 dark:border-gray-600' : 'border-gray-200 dark:border-gray-700' } border transition-all cursor-pointer hover:scale-[1.01]`} @@ -119,8 +126,8 @@ const CompactNodeCard: Component = (props) => { {/* Alert indicator */} - - + + {/* Metrics */} @@ -155,9 +162,9 @@ const CompactNodeCard: Component = (props) => { ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : !isOnline() ? 'border-red-500' - : alertStyles.hasUnacknowledgedAlert + : alertStyles().hasUnacknowledgedAlert ? 'border-orange-500' - : alertStyles.hasAcknowledgedOnlyAlert + : alertStyles().hasAcknowledgedOnlyAlert ? 'border-gray-400 dark:border-gray-600' : 'border-gray-200 dark:border-gray-700' } cursor-pointer transition-all hover:scale-[1.02]`} @@ -196,8 +203,8 @@ const CompactNodeCard: Component = (props) => { {props.node.isClusterMember ? props.node.clusterName : 'Standalone'} - - + +

diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 6bb32ef..32400b0 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -4,6 +4,7 @@ import type { VM, Container, Node } from '@/types/api'; import { GuestRow } from './GuestRow'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery'; @@ -33,6 +34,8 @@ export function Dashboard(props: DashboardProps) { const navigate = useNavigate(); const ws = useWebSocket(); const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws; + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const [search, setSearch] = createSignal(''); const [isSearchLocked, setIsSearchLocked] = createSignal(false); const [selectedNode, setSelectedNode] = createSignal(null); @@ -796,7 +799,7 @@ export function Dashboard(props: DashboardProps) { return ( ; }> = (props) => { const { container, host } = props.entry; + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const containerId = createMemo(() => buildContainerId(container, host.id)); const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(containerId()) ?? false); @@ -129,11 +132,12 @@ const DockerContainerRow: Component<{ }; const alertStyles = createMemo(() => { + if (!alertsEnabled()) return defaultAlertStyles; if (!props.activeAlerts) return defaultAlertStyles; try { // Convert Store to plain object if needed const alertsObj = typeof props.activeAlerts === 'object' ? { ...props.activeAlerts } : props.activeAlerts; - return getAlertStyles(containerResourceId(), alertsObj) || defaultAlertStyles; + return getAlertStyles(containerResourceId(), alertsObj, alertsEnabled()) || defaultAlertStyles; } catch (e) { console.warn('Error getting alert styles for container:', containerResourceId(), e); return defaultAlertStyles; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 4a8dece..10864c9 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -35,6 +35,14 @@ import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; import { showTokenReveal } from '@/stores/tokenReveal'; import { updateStore } from '@/stores/updates'; + +const COMMON_DISCOVERY_SUBNETS = [ + '192.168.1.0/24', + '192.168.0.0/24', + '10.0.0.0/24', + '172.16.0.0/24', + '192.168.10.0/24', +]; // Type definitions interface DiscoveredServer { ip: string; @@ -360,9 +368,92 @@ const Settings: Component = (props) => { // System settings // PBS polling interval removed - fixed at 10 seconds const [allowedOrigins, setAllowedOrigins] = createSignal('*'); - const [discoveryEnabled, setDiscoveryEnabled] = createSignal(true); + 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(); + const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false); const [envOverrides, setEnvOverrides] = createSignal>({}); + let discoverySubnetInputRef: HTMLInputElement | undefined; + + const parseSubnetList = (value: string) => { + const seen = new Set(); + return value + .split(',') + .map((token) => token.trim()) + .filter((token) => { + if (!token || token.toLowerCase() === 'auto' || seen.has(token)) { + return false; + } + seen.add(token); + return true; + }); + }; + + const normalizeSubnetList = (value: string) => parseSubnetList(value).join(', '); + + const currentDraftSubnetValue = () => { + if (discoveryMode() === 'custom') { + return discoverySubnetDraft(); + } + const draft = discoverySubnetDraft(); + if (draft.trim() !== '') { + return draft; + } + const saved = discoverySubnet(); + return saved.toLowerCase() === 'auto' ? '' : saved; + }; + + const isValidCIDR = (value: string) => { + const subnets = parseSubnetList(value); + if (subnets.length === 0) { + return false; + } + + return subnets.every((token) => { + const [network, prefix] = token.split('/'); + if (!network || typeof prefix === 'undefined') { + return false; + } + + const prefixNumber = Number(prefix); + if (!Number.isInteger(prefixNumber) || prefixNumber < 0 || prefixNumber > 32) { + return false; + } + + const octets = network.split('.'); + if (octets.length !== 4) { + return false; + } + + return octets.every((octet) => { + if (octet === '') return false; + if (!/^\d+$/.test(octet)) return false; + const valueNumber = Number(octet); + return valueNumber >= 0 && valueNumber <= 255; + }); + }); + }; + + const applySavedDiscoverySubnet = (subnet?: string | null) => { + const raw = typeof subnet === 'string' ? subnet.trim() : ''; + if (raw === '' || raw.toLowerCase() === 'auto') { + setDiscoverySubnet('auto'); + setDiscoveryMode('auto'); + setDiscoverySubnetDraft(''); + } else { + setDiscoveryMode('custom'); + const normalizedValue = normalizeSubnetList(raw); + setDiscoverySubnet(normalizedValue); + setDiscoverySubnetDraft(normalizedValue); + setLastCustomSubnet(normalizedValue); + setDiscoverySubnetError(undefined); + return; + } + setDiscoverySubnetError(undefined); + }; // Connection timeout removed - backend-only setting // Iframe embedding settings @@ -893,6 +984,187 @@ const Settings: Component = (props) => { } }; + const handleDiscoveryEnabledChange = async (enabled: boolean): Promise => { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + return false; + } + + const previousEnabled = discoveryEnabled(); + const previousSubnet = discoverySubnet(); + let subnetToSend = discoverySubnet(); + + if (enabled) { + if (discoveryMode() === 'custom') { + const trimmedDraft = discoverySubnetDraft().trim(); + if (!trimmedDraft) { + setDiscoverySubnetError('Enter at least one subnet before enabling discovery'); + notificationStore.error('Enter at least one subnet before enabling discovery'); + return false; + } + if (!isValidCIDR(trimmedDraft)) { + setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); + notificationStore.error('Enter valid CIDR subnet values before enabling discovery'); + return false; + } + const normalizedDraft = normalizeSubnetList(trimmedDraft); + setDiscoverySubnetDraft(normalizedDraft); + setDiscoverySubnetError(undefined); + subnetToSend = normalizedDraft; + } else { + subnetToSend = 'auto'; + setDiscoverySubnetError(undefined); + } + } + + setDiscoveryEnabled(enabled); + setSavingDiscoverySettings(true); + + try { + await SettingsAPI.updateSystemSettings({ + discoveryEnabled: enabled, + discoverySubnet: subnetToSend, + }); + applySavedDiscoverySubnet(subnetToSend); + if (enabled && subnetToSend !== 'auto') { + setLastCustomSubnet(subnetToSend); + } + + if (enabled) { + await triggerDiscoveryScan({ quiet: true }); + notificationStore.success('Discovery enabled — scanning network...', 2000); + } else { + notificationStore.info('Discovery disabled', 2000); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: false, + })); + } + + return true; + } catch (error) { + console.error('Failed to update discovery setting:', error); + notificationStore.error('Failed to update discovery setting'); + setDiscoveryEnabled(previousEnabled); + applySavedDiscoverySubnet(previousSubnet); + return false; + } finally { + setSavingDiscoverySettings(false); + await loadDiscoveredNodes(); + } + }; + + const commitDiscoverySubnet = async (rawValue: string): Promise => { + if (envOverrides().discoverySubnet) { + return false; + } + + const value = rawValue.trim(); + if (!value) { + setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'); + return false; + } + if (!isValidCIDR(value)) { + setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); + return false; + } + + const normalizedValue = normalizeSubnetList(value); + if (!normalizedValue) { + setDiscoverySubnetError('Enter at least one valid subnet in CIDR format'); + return false; + } + + const previousSubnet = discoverySubnet(); + const previousNormalized = + previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet); + + if (normalizedValue === previousNormalized) { + setDiscoverySubnetDraft(normalizedValue); + setDiscoverySubnetError(undefined); + setLastCustomSubnet(normalizedValue); + return true; + } + + setSavingDiscoverySettings(true); + + try { + setDiscoverySubnetError(undefined); + await SettingsAPI.updateSystemSettings({ + discoveryEnabled: discoveryEnabled(), + discoverySubnet: normalizedValue, + }); + setLastCustomSubnet(normalizedValue); + applySavedDiscoverySubnet(normalizedValue); + if (discoveryEnabled()) { + await triggerDiscoveryScan({ quiet: true }); + notificationStore.success('Discovery subnet updated — scanning network...', 2000); + } else { + notificationStore.success('Discovery subnet saved', 2000); + } + return true; + } catch (error) { + console.error('Failed to update discovery subnet:', error); + notificationStore.error('Failed to update discovery subnet'); + applySavedDiscoverySubnet(previousSubnet); + setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet)); + return false; + } finally { + setDiscoverySubnetError(undefined); + setSavingDiscoverySettings(false); + await loadDiscoveredNodes(); + } + }; + + const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => { + if (envOverrides().discoverySubnet || savingDiscoverySettings()) { + return; + } + if (mode === discoveryMode()) { + return; + } + + if (mode === 'auto') { + const previousSubnet = discoverySubnet(); + setDiscoveryMode('auto'); + setDiscoverySubnetDraft(''); + setDiscoverySubnetError(undefined); + setSavingDiscoverySettings(true); + try { + await SettingsAPI.updateSystemSettings({ + discoveryEnabled: discoveryEnabled(), + discoverySubnet: 'auto', + }); + applySavedDiscoverySubnet('auto'); + if (discoveryEnabled()) { + await triggerDiscoveryScan({ quiet: true }); + } + notificationStore.info( + 'Auto discovery scans each network phase. Large networks may take longer.', + 4000, + ); + } catch (error) { + console.error('Failed to update discovery subnet:', error); + notificationStore.error('Failed to update discovery subnet'); + applySavedDiscoverySubnet(previousSubnet); + } finally { + setSavingDiscoverySettings(false); + await loadDiscoveredNodes(); + } + return; + } + + setDiscoveryMode('custom'); + const rawDraft = + discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || ''; + const normalizedDraft = normalizeSubnetList(rawDraft); + setDiscoverySubnetDraft(normalizedDraft); + setDiscoverySubnetError(undefined); + queueMicrotask(() => { + discoverySubnetInputRef?.focus(); + discoverySubnetInputRef?.select(); + }); + }; + // Load nodes and system settings on mount onMount(async () => { // Subscribe to events @@ -963,6 +1235,10 @@ const Settings: Component = (props) => { 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 @@ -1026,9 +1302,9 @@ const Settings: Component = (props) => { setAllowedOrigins(systemSettings.allowedOrigins || '*'); // Connection timeout is backend-only // Load discovery settings - // Backend defaults to true, so we should respect that - setDiscoveryEnabled(systemSettings.discoveryEnabled ?? true); // Default to true if undefined - setDiscoverySubnet(systemSettings.discoverySubnet || 'auto'); + // Backend defaults to false, so we should respect that + setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); // Default to false if undefined + applySavedDiscoverySubnet(systemSettings.discoverySubnet); // Load embedding settings setAllowEmbedding(systemSettings.allowEmbedding ?? false); setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || ''); @@ -1623,38 +1899,18 @@ const Settings: Component = (props) => { { - if (envOverrides().discoveryEnabled) { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + e.preventDefault(); return; } - const newValue = e.currentTarget.checked; - setDiscoveryEnabled(newValue); - try { - await SettingsAPI.updateSystemSettings({ - discoveryEnabled: newValue, - discoverySubnet: discoverySubnet(), - }); - if (newValue) { - await triggerDiscoveryScan({ quiet: true }); - notificationStore.success( - 'Discovery enabled — scanning network...', - 2000, - ); - } else { - notificationStore.info('Discovery disabled', 2000); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: false, - })); - } - } catch (error) { - console.error('Failed to update discovery setting:', error); - notificationStore.error('Failed to update discovery setting'); - setDiscoveryEnabled(!newValue); - } finally { - await loadDiscoveredNodes(); + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); } }} - disabled={envOverrides().discoveryEnabled} + disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} containerClass="gap-2" label={ @@ -2048,6 +2304,19 @@ const Settings: Component = (props) => { {(err) =>
  • {err}
  • } + + /timed out|timeout/i.test(err), + ) + } + > +

    + Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

    +
    = (props) => { { - if (envOverrides().discoveryEnabled) { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + e.preventDefault(); return; } - const newValue = e.currentTarget.checked; - setDiscoveryEnabled(newValue); - try { - await SettingsAPI.updateSystemSettings({ - discoveryEnabled: newValue, - discoverySubnet: discoverySubnet(), - }); - if (newValue) { - await triggerDiscoveryScan({ quiet: true }); - notificationStore.success( - 'Discovery enabled — scanning network...', - 2000, - ); - } else { - notificationStore.info('Discovery disabled', 2000); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: false, - })); - } - } catch (error) { - console.error('Failed to update discovery setting:', error); - notificationStore.error('Failed to update discovery setting'); - setDiscoveryEnabled(!newValue); - } finally { - await loadDiscoveredNodes(); + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); } }} - disabled={envOverrides().discoveryEnabled} + disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} containerClass="gap-2" label={ @@ -2483,6 +2732,19 @@ const Settings: Component = (props) => { {(err) =>
  • {err}
  • } + + /timed out|timeout/i.test(err), + ) + } + > +

    + Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

    +
    = (props) => { { - if (envOverrides().discoveryEnabled) { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + e.preventDefault(); return; } - const newValue = e.currentTarget.checked; - setDiscoveryEnabled(newValue); - try { - await SettingsAPI.updateSystemSettings({ - discoveryEnabled: newValue, - discoverySubnet: discoverySubnet(), - }); - if (newValue) { - await triggerDiscoveryScan({ quiet: true }); - notificationStore.success( - 'Discovery enabled — scanning network...', - 2000, - ); - } else { - notificationStore.info('Discovery disabled', 2000); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: false, - })); - } - } catch (error) { - console.error('Failed to update discovery setting:', error); - notificationStore.error('Failed to update discovery setting'); - setDiscoveryEnabled(!newValue); - } finally { - await loadDiscoveredNodes(); + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); } }} - disabled={envOverrides().discoveryEnabled} + disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} containerClass="gap-2" label={ @@ -2899,6 +3141,19 @@ const Settings: Component = (props) => { {(err) =>
  • {err}
  • } + + /timed out|timeout/i.test(err), + ) + } + > +

    + Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

    +
    = (props) => {
    + + +
    +
    +

    Automatic scanning

    +

    + Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically. +

    +
    + { + 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={ + + {discoveryEnabled() ? 'On' : 'Off'} + + } + /> +
    + + +
    +
    + + Scan scope + +
    + + + + +
    + + Common networks: + + + {(preset) => { + const baseValue = currentDraftSubnetValue(); + const currentSelections = parseSubnetList(baseValue); + const isActive = currentSelections.includes(preset); + return ( + + ); + }} + +
    +
    +
    +
    + +
    +
    + + + + + + + + +
    + { + discoverySubnetInputRef = el; + }} + type="text" + value={discoverySubnetDraft()} + placeholder={ + discoveryMode() === 'auto' + ? 'auto (scan every network phase)' + : '192.168.1.0/24, 10.0.0.0/24' + } + class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + envOverrides().discoverySubnet + ? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60' + : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70' + }`} + disabled={envOverrides().discoverySubnet} + onInput={(e) => { + if (envOverrides().discoverySubnet) { + return; + } + const rawValue = e.currentTarget.value; + setDiscoverySubnetDraft(rawValue); + if (discoveryMode() !== 'custom') { + setDiscoveryMode('custom'); + } + setLastCustomSubnet(rawValue); + const trimmed = rawValue.trim(); + if (!trimmed) { + setDiscoverySubnetError(undefined); + return; + } + if (!isValidCIDR(trimmed)) { + setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); + } else { + setDiscoverySubnetError(undefined); + } + }} + onBlur={async (e) => { + if (envOverrides().discoverySubnet || discoveryMode() !== 'custom') { + return; + } + const rawValue = e.currentTarget.value; + setDiscoverySubnetDraft(rawValue); + const trimmed = rawValue.trim(); + if (!trimmed) { + setDiscoverySubnetError( + 'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)', + ); + return; + } + if (!isValidCIDR(trimmed)) { + setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); + return; + } + setDiscoverySubnetError(undefined); + await commitDiscoverySubnet(rawValue); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.currentTarget as HTMLInputElement).blur(); + } + }} + /> + +

    + {discoverySubnetError()} +

    +
    + +

    + Auto scans every reachable network phase. Large networks may time out — + switch to custom subnets to narrow the search. +

    +
    + +

    + Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish faster and avoid timeouts. +

    +
    +
    +
    +
    + + +
    + Discovery settings are locked by environment variables. Update the service + configuration and restart Pulse to change them here. +
    +
    +
    + { const navigate = useNavigate(); const { state, connected, activeAlerts, initialDataReceived } = useWebSocket(); + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const [viewMode, setViewMode] = usePersistentSignal<'node' | 'storage'>( 'storageViewMode', 'node', @@ -795,9 +798,12 @@ const Storage: Component = () => { return nodes.join(', '); }); - const alertStyles = getAlertStyles( - storage.id || `${storage.instance}-${storage.node}-${storage.name}`, - activeAlerts, + const alertStyles = createMemo(() => + getAlertStyles( + storage.id || `${storage.instance}-${storage.node}-${storage.name}`, + activeAlerts, + alertsEnabled(), + ), ); const parentNodeOnline = createMemo(() => { @@ -808,7 +814,7 @@ const Storage: Component = () => { }); const showAlertHighlight = createMemo( - () => alertStyles.hasUnacknowledgedAlert && parentNodeOnline(), + () => alertStyles().hasUnacknowledgedAlert && parentNodeOnline(), ); const isCephStorage = createMemo(() => isCephType(storage.type)); @@ -934,7 +940,7 @@ const Storage: Component = () => { ); const hasAcknowledgedOnlyAlert = createMemo( - () => alertStyles.hasAcknowledgedOnlyAlert && parentNodeOnline(), + () => alertStyles().hasAcknowledgedOnlyAlert && parentNodeOnline(), ); const rowClass = createMemo(() => { @@ -946,7 +952,7 @@ const Storage: Component = () => { if (showAlertHighlight()) { classes.push( - alertStyles.severity === 'critical' + alertStyles().severity === 'critical' ? 'bg-red-50 dark:bg-red-950/30' : 'bg-yellow-50 dark:bg-yellow-950/20', ); @@ -972,7 +978,7 @@ const Storage: Component = () => { const rowStyle = createMemo(() => { if (showAlertHighlight()) { const color = - alertStyles.severity === 'critical' ? '#ef4444' : '#eab308'; + alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; return { 'box-shadow': `inset 4px 0 0 0 ${color}`, }; diff --git a/frontend-modern/src/components/Toast/Toast.tsx b/frontend-modern/src/components/Toast/Toast.tsx index 80e11d8..a981e40 100644 --- a/frontend-modern/src/components/Toast/Toast.tsx +++ b/frontend-modern/src/components/Toast/Toast.tsx @@ -164,7 +164,7 @@ export const ToastContainer: Component = () => { return ( -
    +
    {(toast) => }
    diff --git a/frontend-modern/src/components/shared/AlertIndicators.tsx b/frontend-modern/src/components/shared/AlertIndicators.tsx index 4029860..52cd138 100644 --- a/frontend-modern/src/components/shared/AlertIndicators.tsx +++ b/frontend-modern/src/components/shared/AlertIndicators.tsx @@ -1,6 +1,7 @@ -import { Component } from 'solid-js'; +import { Component, createMemo } from 'solid-js'; import type { Alert } from '@/types/api'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; +import { useAlertsActivation } from '@/stores/alertsActivation'; const getMetadataUnit = (alert: Alert): string | undefined => { const rawUnit = alert.metadata?.['unit']; @@ -69,7 +70,9 @@ interface AlertIndicatorProps { } export const AlertIndicator: Component = (props) => { - if (!props.severity) return null; + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); + if (!alertsEnabled() || !props.severity) return null; const dotClass = props.severity === 'critical' ? 'bg-red-500 animate-pulse' : 'bg-orange-500'; @@ -110,6 +113,10 @@ interface AlertCountBadgeProps { } export const AlertCountBadge: Component = (props) => { + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); + if (!alertsEnabled()) return null; + const badgeClass = props.severity === 'critical' ? 'bg-red-500 text-white' : 'bg-orange-500 text-white'; diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index f45d8a7..719df98 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -7,6 +7,7 @@ import { getAlertStyles } from '@/utils/alerts'; import { Card } from '@/components/shared/Card'; import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; import { getCpuTemperature } from '@/utils/temperature'; +import { useAlertsActivation } from '@/stores/alertsActivation'; interface NodeSummaryTableProps { nodes: Node[]; @@ -22,6 +23,8 @@ interface NodeSummaryTableProps { export const NodeSummaryTable: Component = (props) => { const { activeAlerts, state } = useWebSocket(); + const alertsActivation = useAlertsActivation(); + const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount'; type SortKey = | 'default' @@ -423,15 +426,19 @@ export const NodeSummaryTable: Component = (props) => { const isSelected = () => props.selectedNode === nodeId; // Use the full resource ID for alert matching const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name; - const alertStyles = getAlertStyles(resourceId, activeAlerts); - const showAlertHighlight = alertStyles.hasUnacknowledgedAlert && online; + const alertStyles = createMemo(() => + getAlertStyles(resourceId, activeAlerts, alertsEnabled()), + ); + const showAlertHighlight = createMemo( + () => alertStyles().hasUnacknowledgedAlert && online, + ); const rowStyle = createMemo(() => { const styles: Record = {}; const shadows: string[] = []; - if (showAlertHighlight) { - const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308'; + if (showAlertHighlight()) { + const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; shadows.push(`inset 4px 0 0 0 ${color}`); } @@ -454,8 +461,8 @@ export const NodeSummaryTable: Component = (props) => { return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`; } - if (showAlertHighlight) { - return alertStyles.severity === 'critical' + if (showAlertHighlight()) { + return alertStyles().severity === 'critical' ? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm' : 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm'; } diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 98e17ec..165997f 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -8,7 +8,6 @@ import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, Back import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { SettingsPanel } from '@/components/shared/SettingsPanel'; -import { Toggle } from '@/components/shared/Toggle'; import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; @@ -19,6 +18,7 @@ import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { HysteresisThreshold } from '@/types/alerts'; import type { Alert, State, VM, Container, DockerHost, DockerContainer } from '@/types/api'; import { useNavigate, useLocation } from '@solidjs/router'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import LayoutDashboard from 'lucide-solid/icons/layout-dashboard'; import History from 'lucide-solid/icons/history'; import Gauge from 'lucide-solid/icons/gauge'; @@ -317,6 +317,56 @@ export function Alerts() { const { state, activeAlerts, updateAlert, removeAlerts } = useWebSocket(); const navigate = useNavigate(); const location = useLocation(); + const alertsActivation = useAlertsActivation(); + const [isSwitchingActivation, setIsSwitchingActivation] = createSignal(false); + const isAlertsActive = createMemo(() => alertsActivation.activationState() === 'active'); + + const handleActivateAlerts = async () => { + if (alertsActivation.isLoading() || isSwitchingActivation()) { + return; + } + setIsSwitchingActivation(true); + try { + const success = await alertsActivation.activate(); + if (success) { + showSuccess('Alerts activated! You\'ll now receive alerts when issues are detected.'); + try { + await alertsActivation.refreshActiveAlerts(); + } catch (error) { + console.error('Failed to refresh alerts after activation', error); + } + } else { + showError('Unable to activate alerts. Please try again.'); + } + } finally { + setIsSwitchingActivation(false); + } + }; + + const handleDeactivateAlerts = async () => { + if (isSwitchingActivation()) { + return; + } + setIsSwitchingActivation(true); + try { + const success = await alertsActivation.deactivate(); + if (success) { + showSuccess('Alerts deactivated. Nothing will be sent until you activate them again.'); + try { + await alertsActivation.refreshActiveAlerts(); + } catch (error) { + console.error('Failed to refresh alerts after deactivation', error); + } + } else { + showError('Unable to deactivate alerts. Please try again.'); + } + } catch (error) { + console.error('Deactivate alerts failed', error); + showError('Unable to deactivate alerts. Please try again.'); + } finally { + setIsSwitchingActivation(false); + } + }; const [activeTab, setActiveTab] = createSignal(tabFromPath(location.pathname)); @@ -344,6 +394,12 @@ export function Alerts() { } }); + createEffect(() => { + if (!isAlertsActive() && activeTab() !== 'overview') { + handleTabChange('overview'); + } + }); + const handleTabChange = (tab: AlertTab) => { const targetPath = pathForTab(tab); if (location.pathname !== targetPath) { @@ -1211,11 +1267,51 @@ const [appriseConfig, setAppriseConfig] = createSignal(
    {/* Header with better styling */} - +
    + + +
    + + {isAlertsActive() ? 'Alerts enabled' : 'Alerts disabled'} + + +
    +
    +
    {/* Save notification bar - only show when there are unsaved changes */} @@ -1419,6 +1515,10 @@ const [appriseConfig, setAppriseConfig] = createSignal( +
    +
    (
    - {(item) => ( - - )} + {(item) => { + const disabled = () => item.id !== 'overview' && !isAlertsActive(); + return ( + + ); + }}
    @@ -1475,19 +1590,27 @@ const [appriseConfig, setAppriseConfig] = createSignal( style="-webkit-overflow-scrolling: touch;" > - {(tab) => ( - - )} + {(tab) => { + const disabled = () => tab.id !== 'overview' && !isAlertsActive(); + return ( + + ); + }}
    @@ -1616,6 +1739,7 @@ const [appriseConfig, setAppriseConfig] = createSignal(
    +
    ); } diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index 8ed29b3..ae9bcff 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -2,14 +2,29 @@ import { createSignal } from 'solid-js'; import { AlertsAPI } from '@/api/alerts'; import type { AlertConfig, ActivationState as ActivationStateType } from '@/types/alerts'; import type { Alert } from '@/types/api'; +import { setGlobalActivationState } from '@/utils/alertsActivation'; // Create signals for activation state const [config, setConfig] = createSignal(null); -const [activationState, setActivationState] = createSignal(null); +const [activationState, setActivationStateSignal] = createSignal(null); const [isLoading, setIsLoading] = createSignal(false); const [activeAlerts, setActiveAlerts] = createSignal([]); const [lastError, setLastError] = createSignal(null); +const applyActivationState = (state: ActivationStateType | null) => { + setActivationStateSignal(state); + setGlobalActivationState(state); +}; + +const ensureConfigLoaded = async (): Promise => { + let current = config(); + if (!current) { + await refreshConfig(); + current = config(); + } + return current; +}; + // Refresh config from API const refreshConfig = async (): Promise => { try { @@ -17,7 +32,7 @@ const refreshConfig = async (): Promise => { setLastError(null); const alertConfig = await AlertsAPI.getConfig(); setConfig(alertConfig); - setActivationState(alertConfig.activationState || 'active'); + applyActivationState(alertConfig.activationState || 'active'); } catch (error) { console.error('Failed to fetch alert config:', error); setLastError(error instanceof Error ? error.message : 'Unknown error'); @@ -59,6 +74,35 @@ const activate = async (): Promise => { } }; +const updateActivationState = async (state: ActivationStateType): Promise => { + try { + setIsLoading(true); + setLastError(null); + const current = await ensureConfigLoaded(); + if (!current) { + throw new Error('Alert configuration is unavailable'); + } + const updated: AlertConfig = { ...current, activationState: state }; + const result = await AlertsAPI.updateConfig(updated); + if (!result.success) { + return false; + } + setConfig(updated); + applyActivationState(state); + return true; + } catch (error) { + console.error('Failed to update activation state:', error); + setLastError(error instanceof Error ? error.message : 'Unknown error'); + return false; + } finally { + setIsLoading(false); + } +}; + +const deactivate = async (): Promise => updateActivationState('pending_review'); + +const snooze = async (): Promise => updateActivationState('snoozed'); + // Check if past observation window const isPastObservationWindow = (): boolean => { const cfg = config(); @@ -89,6 +133,8 @@ export const useAlertsActivation = () => ({ refreshConfig, refreshActiveAlerts, activate, + deactivate, + snooze, }); // Initialize on module load diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 57aed5b..8334540 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -9,10 +9,12 @@ import type { VM, Container, } from '@/types/api'; +import type { ActivationState as ActivationStateType } from '@/types/alerts'; import { logger } from '@/utils/logger'; import { POLLING_INTERVALS, WEBSOCKET } from '@/constants'; import { notificationStore } from './notifications'; import { eventBus } from './events'; +import { ALERTS_ACTIVATION_EVENT, isAlertsActivationEnabled } from '@/utils/alertsActivation'; // Type-safe WebSocket store export function createWebSocketStore(url: string) { @@ -79,6 +81,66 @@ export function createWebSocketStore(url: string) { // Track alerts with pending acknowledgment changes to prevent race conditions const pendingAckChanges = new Map(); + let alertsEnabled = isAlertsActivationEnabled(); + let lastActiveAlertsPayload: Record = {}; + + const applyActiveAlerts = (alertsMap: Record) => { + // Remove alerts that no longer exist + const currentAlertIds = Object.keys(activeAlerts); + currentAlertIds.forEach((id) => { + if (!alertsMap[id]) { + setActiveAlerts(id, undefined as unknown as Alert); + } + }); + + // Add or update alerts with pending acknowledgment safeguards + Object.entries(alertsMap).forEach(([id, alert]) => { + if (pendingAckChanges.has(id)) { + const pending = pendingAckChanges.get(id)!; + + if (pending.ack) { + if (!alert.acknowledged) { + logger.debug( + `Skipping update for alert ${id} - awaiting server acknowledgment confirmation`, + ); + return; + } + + const serverAckTime = alert.ackTime || ''; + const previousAckTime = pending.previousAckTime || ''; + if (serverAckTime === previousAckTime) { + logger.debug( + `Server ack time for alert ${id} unchanged (${serverAckTime}); treating as confirmed`, + ); + } + } else if (alert.acknowledged) { + logger.debug( + `Skipping update for alert ${id} - awaiting server unacknowledge confirmation`, + ); + return; + } + + pendingAckChanges.delete(id); + } + + setActiveAlerts(id, alert); + }); + + setState('activeAlerts', Object.values(alertsMap)); + }; + + if (typeof window !== 'undefined') { + window.addEventListener( + ALERTS_ACTIVATION_EVENT, + (event: Event) => { + const detail = (event as CustomEvent).detail; + alertsEnabled = detail === 'active'; + applyActiveAlerts(alertsEnabled ? lastActiveAlertsPayload : {}); + }, + { passive: true }, + ); + } + let ws: WebSocket | null = null; let reconnectTimeout: number; let heartbeatInterval: number; @@ -317,9 +379,6 @@ export function createWebSocketStore(url: string) { setState('physicalDisks', message.data.physicalDisks); // Sync active alerts from state if (message.data.activeAlerts !== undefined) { - // Received activeAlerts update - - // Update alerts atomically to prevent race conditions const newAlerts: Record = {}; if (message.data.activeAlerts && Array.isArray(message.data.activeAlerts)) { message.data.activeAlerts.forEach((alert: Alert) => { @@ -327,53 +386,8 @@ export function createWebSocketStore(url: string) { }); } - // Clear existing alerts and set new ones - const currentAlertIds = Object.keys(activeAlerts); - currentAlertIds.forEach((id) => { - if (!newAlerts[id]) { - setActiveAlerts(id, undefined as unknown as Alert); - } - }); - - // Add new alerts (skip those with pending acknowledgment changes until server confirms) - Object.entries(newAlerts).forEach(([id, alert]) => { - // Skip updating if this alert has a pending acknowledgment change - if (pendingAckChanges.has(id)) { - const pending = pendingAckChanges.get(id)!; - - if (pending.ack) { - // Expecting an acknowledged alert - if (!alert.acknowledged) { - logger.debug( - `Skipping update for alert ${id} - awaiting server acknowledgment confirmation`, - ); - return; - } - - const serverAckTime = alert.ackTime || ''; - const previousAckTime = pending.previousAckTime || ''; - if (serverAckTime === previousAckTime) { - logger.debug( - `Server ack time for alert ${id} unchanged (${serverAckTime}); treating as confirmed`, - ); - } - } else { - // Expecting an unacknowledged alert - if (alert.acknowledged) { - logger.debug( - `Skipping update for alert ${id} - awaiting server unacknowledge confirmation`, - ); - return; - } - } - - pendingAckChanges.delete(id); - } - setActiveAlerts(id, alert); - }); - - // Updated activeAlerts - also sync to state for badge count - setState('activeAlerts', message.data.activeAlerts); + lastActiveAlertsPayload = newAlerts; + applyActiveAlerts(alertsEnabled ? newAlerts : {}); } // Sync recently resolved alerts if (message.data.recentlyResolved !== undefined) { diff --git a/frontend-modern/src/utils/alerts.ts b/frontend-modern/src/utils/alerts.ts index 9881db1..e5fb822 100644 --- a/frontend-modern/src/utils/alerts.ts +++ b/frontend-modern/src/utils/alerts.ts @@ -1,7 +1,31 @@ import type { Alert } from '@/types/api'; +import { isAlertsActivationEnabled } from '@/utils/alertsActivation'; + +const noAlertStyles = { + rowClass: '', + indicatorClass: '', + badgeClass: '', + hasAlert: false, + alertCount: 0, + severity: null as 'critical' | 'warning' | null, + hasPoweredOffAlert: false, + hasNonPoweredOffAlert: false, + hasUnacknowledgedAlert: false, + unacknowledgedCount: 0, + acknowledgedCount: 0, + hasAcknowledgedOnlyAlert: false, +}; // Get alert highlighting styles based on active alerts for a resource -export const getAlertStyles = (resourceId: string, activeAlerts: Record) => { +export const getAlertStyles = ( + resourceId: string, + activeAlerts: Record, + alertsEnabled: boolean | undefined = isAlertsActivationEnabled(), +) => { + if (!alertsEnabled) { + return noAlertStyles; + } + const alertsForResource = Object.values(activeAlerts).filter( (alert) => alert.resourceId === resourceId, ); @@ -86,6 +110,8 @@ export const getAlertStyles = (resourceId: string, activeAlerts: Record, + alertsEnabled: boolean | undefined = isAlertsActivationEnabled(), ): Alert[] => { + if (!alertsEnabled) return []; return Object.values(activeAlerts).filter((alert) => alert.resourceId === resourceId); }; diff --git a/frontend-modern/src/utils/alertsActivation.ts b/frontend-modern/src/utils/alertsActivation.ts new file mode 100644 index 0000000..a4bd090 --- /dev/null +++ b/frontend-modern/src/utils/alertsActivation.ts @@ -0,0 +1,30 @@ +import type { ActivationState } from '@/types/alerts'; + +export const ALERTS_ACTIVATION_EVENT = 'pulse-alerts-activation-change'; + +declare global { + interface Window { + __pulseAlertsActivationState?: ActivationState | null; + } +} + +const isBrowser = typeof window !== 'undefined'; + +export const setGlobalActivationState = (state: ActivationState | null): void => { + if (!isBrowser) return; + window.__pulseAlertsActivationState = state; + window.dispatchEvent( + new CustomEvent(ALERTS_ACTIVATION_EVENT, { + detail: state, + }), + ); +}; + +export const isAlertsActivationEnabled = (): boolean => { + if (!isBrowser) return true; + const state = window.__pulseAlertsActivationState; + if (state === undefined || state === null) { + return true; + } + return state === 'active'; +}; diff --git a/install.sh b/install.sh index c6ef7ff..33939a8 100755 --- a/install.sh +++ b/install.sh @@ -31,10 +31,94 @@ PROXY_PREPARE_MOUNT=false CURRENT_INSTALL_CTID="" CONTAINER_CREATED_FOR_CLEANUP=false BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE" +DETECTED_CTID="" DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst" DEBIAN_TEMPLATE="" +detect_lxc_ctid() { + local ctid="" + + if [[ -r /proc/1/cgroup ]]; then + ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1) + if [[ -n "$ctid" ]]; then + echo "$ctid" + return + fi + fi + + if command -v hostname >/dev/null 2>&1; then + ctid=$(hostname 2>/dev/null || true) + if [[ "$ctid" =~ ^[0-9]+$ ]]; then + echo "$ctid" + return + fi + fi + + if command -v hostnamectl >/dev/null 2>&1; then + ctid=$(hostnamectl hostname 2>/dev/null || true) + if [[ "$ctid" =~ ^[0-9]+$ ]]; then + echo "$ctid" + return + fi + fi + + if command -v findmnt >/dev/null 2>&1; then + local mount_src + mount_src=$(findmnt -no SOURCE / 2>/dev/null || true) + if [[ "$mount_src" =~ -([0-9]+)-disk ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + fi + + if command -v df >/dev/null 2>&1; then + local root_src + root_src=$(df -P / 2>/dev/null | awk 'NR==2 {print $1}') + if [[ "$root_src" =~ -([0-9]+)-disk ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + fi +} + +auto_detect_container_environment() { + if [[ "$IN_CONTAINER" == "true" ]]; then + if [[ -z "$DETECTED_CTID" ]]; then + DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true) + fi + return + fi + + local virt_type="" + if command -v systemd-detect-virt >/dev/null 2>&1; then + virt_type=$(systemd-detect-virt --container 2>/dev/null || true) + if [[ -n "$virt_type" && "$virt_type" != "none" ]]; then + IN_CONTAINER=true + if [[ "$virt_type" == "docker" ]]; then + IN_DOCKER=true + fi + fi + fi + + if [[ "$IN_CONTAINER" != "true" ]]; then + if [[ -f /.dockerenv ]] || grep -qa docker /proc/1/cgroup 2>/dev/null || grep -qa docker /proc/self/cgroup 2>/dev/null; then + IN_CONTAINER=true + IN_DOCKER=true + elif grep -qaE '(lxc|machine-lxc)' /proc/1/cgroup 2>/dev/null; then + IN_CONTAINER=true + elif [[ -r /proc/1/environ ]] && grep -qa 'container=lxc' /proc/1/environ 2>/dev/null; then + IN_CONTAINER=true + fi + fi + + if [[ "$IN_CONTAINER" == "true" ]]; then + if [[ -z "$DETECTED_CTID" ]]; then + DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true) + fi + fi +} + handle_install_interrupt() { echo "" print_error "Installation cancelled" @@ -2375,7 +2459,9 @@ print_completion() { print_header print_success "Pulse installation completed!" echo - echo -e "${GREEN}Access Pulse at:${NC} http://${IP}:${PORT}" + local PULSE_URL="http://${IP}:${PORT}" + + echo -e "${GREEN}Access Pulse at:${NC} ${PULSE_URL}" echo echo -e "${YELLOW}Quick commands:${NC}" echo " systemctl status $SERVICE_NAME - Check status" @@ -2386,6 +2472,16 @@ print_completion() { echo " Update: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash" echo " Reset: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --reset" echo " Uninstall: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --uninstall" + + if [[ "$IN_CONTAINER" == "true" ]]; then + local proxy_ctid="${DETECTED_CTID:-}" + echo + echo -e "${YELLOW}Temperature monitoring:${NC}" + echo " Run on the Proxmox host to enable secure temperature collection:" + echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \\" + echo " bash -s -- --ctid ${proxy_ctid} --pulse-server ${PULSE_URL}" + echo " See docs/TEMPERATURE_MONITORING.md for details." + fi # Show auto-update status if timer exists if systemctl list-unit-files --no-legend | grep -q "^pulse-update.timer"; then @@ -3057,6 +3153,8 @@ while [[ $# -gt 0 ]]; do esac done +auto_detect_container_environment + # Export for use in download_pulse function export FORCE_VERSION FORCE_CHANNEL diff --git a/internal/api/alerts.go b/internal/api/alerts.go index 15da79c..5adf4df 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -98,7 +98,10 @@ func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request log.Error().Err(err).Msg("Failed to save alert configuration") } - if err := utils.WriteJSONResponse(w, map[string]string{"status": "success"}); err != nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "success": true, + "message": "Alert configuration updated successfully", + }); err != nil { log.Error().Err(err).Msg("Failed to write alert config update response") } } @@ -111,9 +114,10 @@ func (h *AlertHandlers) ActivateAlerts(w http.ResponseWriter, r *http.Request) { // Check if already active if config.ActivationState == alerts.ActivationActive { if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "status": "success", - "message": "Alerts already activated", - "state": string(config.ActivationState), + "success": true, + "message": "Alerts already activated", + "state": string(config.ActivationState), + "activationTime": config.ActivationTime, }); err != nil { log.Error().Err(err).Msg("Failed to write activate response") } @@ -155,7 +159,7 @@ func (h *AlertHandlers) ActivateAlerts(w http.ResponseWriter, r *http.Request) { log.Info().Msg("Alert notifications activated") if err := utils.WriteJSONResponse(w, map[string]interface{}{ - "status": "success", + "success": true, "message": "Alert notifications activated", "state": string(config.ActivationState), "activationTime": config.ActivationTime, diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index d409dac..11d342c 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -3104,41 +3104,117 @@ if [ "$EUID" -ne 0 ]; then exit 1 fi -# Check if running inside a container (LXC/Docker) -if [ -f /run/systemd/container ] || [ -f /.dockerenv ] || [ ! -z "${container:-}" ]; then - echo "" - echo "❌ ERROR: This script is running inside a container!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This setup script must be run on the Proxmox VE host itself," - echo "not inside an LXC container or Docker container." - echo "" - echo "Please:" - echo " 1. Exit this container (type 'exit')" - echo " 2. Run the script directly on your Proxmox host" - echo "" - echo "The script needs access to 'pveum' commands which are only" - echo "available on the Proxmox VE host system." - echo "" - exit 1 -fi +# Detect environment (Proxmox host vs LXC guest) +detect_environment() { + if command -v pveum >/dev/null 2>&1 && command -v pveversion >/dev/null 2>&1; then + echo "pve_host" + return + fi -# Check if pveum command exists -if ! command -v pveum &> /dev/null; then - echo "" - echo "❌ ERROR: 'pveum' command not found!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This script must be run on a Proxmox VE host." - echo "The 'pveum' command is required to create users and tokens." - echo "" - echo "If you're seeing this error, you might be:" - echo " • Running on a non-Proxmox system" - echo " • Inside an LXC container (exit and run on the host)" - echo " • On a PBS server (use the PBS setup script instead)" - echo "" - exit 1 -fi + if [ -f /proc/1/cgroup ] && grep -qE '/(lxc|machine\.slice/machine-lxc)' /proc/1/cgroup 2>/dev/null; then + echo "lxc_guest" + return + fi + + if command -v systemd-detect-virt >/dev/null 2>&1; then + if systemd-detect-virt -q -c 2>/dev/null; then + local virt_type + virt_type=$(systemd-detect-virt -c 2>/dev/null | tr '[:upper:]' '[:lower:]') + if echo "$virt_type" | grep -q "lxc"; then + echo "lxc_guest" + return + fi + fi + fi + + echo "unknown" +} + +detect_lxc_ctid() { + local ctid="" + if [ -f /proc/1/cgroup ]; then + ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1) + if [ -n "$ctid" ]; then + echo "$ctid" + return + fi + fi + + if command -v hostname >/dev/null 2>&1; then + ctid=$(hostname 2>/dev/null) + if echo "$ctid" | grep -qE '^[0-9]+$'; then + echo "$ctid" + return + fi + fi + + echo "" +} + +ENVIRONMENT=$(detect_environment) + +case "$ENVIRONMENT" in + pve_host) + echo "Detected Proxmox VE host environment." + echo "" + ;; + lxc_guest) + echo "Detected Proxmox LXC container environment." + echo "" + LXC_CTID=$(detect_lxc_ctid) + CTID_DISPLAY="$LXC_CTID" + if [ -n "$LXC_CTID" ]; then + echo " • Container ID: $LXC_CTID" + else + CTID_DISPLAY="" + echo " • Unable to auto-detect container ID." + echo " Replace '${CTID_DISPLAY}' in the commands below with your container ID." + fi + echo "" + echo "Run the following commands on your Proxmox host to continue:" + echo "" + cat < --pulse-server %s" + echo "" + exit 1 + ;; +esac # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Main Menu @@ -4345,11 +4421,14 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then echo " Host URL: YOUR_PROXMOX_HOST:8006" echo "" fi -`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, +`, serverName, time.Now().Format("2006-01-02 15:04:05"), + tokenName, pulseURL, pulseURL, pulseURL, serverHost, pulseURL, + pulseURL, serverHost, pulseURL, tokenName, pulseURL, pulseURL, + pulseIP, tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshKeys.ProxyPublicKey, sshKeys.SensorsPublicKey, minProxyReadyVersion, - pulseURL, "%s", "%s", pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName) + pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName) } else { // PBS script = fmt.Sprintf(`#!/bin/bash diff --git a/internal/config/config.go b/internal/config/config.go index 60f59ca..3494037 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,7 +131,7 @@ type Config struct { AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"` // Discovery settings - DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"` + DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"false"` DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"` Discovery DiscoveryConfig `json:"discoveryConfig"` @@ -511,7 +511,7 @@ func Load() (*Config, error) { IframeEmbeddingAllow: "SAMEORIGIN", PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower) PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats) - DiscoveryEnabled: true, + DiscoveryEnabled: false, DiscoverySubnet: "auto", EnvOverrides: make(map[string]bool), OIDC: NewOIDCConfig(), diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 4920cf6..d3fc221 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -717,7 +717,7 @@ func DefaultSystemSettings() *SystemSettings { PBSPollingInterval: 60, PMGPollingInterval: 60, AutoUpdateEnabled: false, - DiscoveryEnabled: true, + DiscoveryEnabled: false, DiscoverySubnet: "auto", DiscoveryConfig: defaultDiscovery, AllowEmbedding: false, diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index 8b3daa6..5a19083 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -48,6 +48,24 @@ type DiscoveryResult struct { Environment *EnvironmentInfo `json:"environment,omitempty"` } +// friendlyPhaseName converts technical phase names to user-friendly descriptions +func friendlyPhaseName(phase string) string { + friendlyNames := map[string]string{ + "lxc_container_network": "Container network", + "docker_bridge_network": "Docker bridge network", + "docker_container_network": "Docker container network", + "host_local_network": "Local network", + "inferred_gateway_network": "Gateway network", + "extra_targets": "Additional targets", + "proxmox_cluster_network": "Proxmox cluster network", + } + + if friendly, ok := friendlyNames[phase]; ok { + return friendly + } + return phase +} + // AddError adds a structured error to the result (also maintains backward-compatible error list) func (r *DiscoveryResult) AddError(phase, errorType, message, ip string, port int) { structuredErr := DiscoveryError{ @@ -60,13 +78,14 @@ func (r *DiscoveryResult) AddError(phase, errorType, message, ip string, port in } r.StructuredErrors = append(r.StructuredErrors, structuredErr) - // Also add to legacy errors for backward compatibility + // Also add to legacy errors for backward compatibility (use friendly phase name for display) + friendlyPhase := friendlyPhaseName(phase) if ip != "" && port > 0 { - r.Errors = append(r.Errors, fmt.Sprintf("%s [%s:%d]: %s", phase, ip, port, message)) + r.Errors = append(r.Errors, fmt.Sprintf("%s [%s:%d]: %s", friendlyPhase, ip, port, message)) } else if ip != "" { - r.Errors = append(r.Errors, fmt.Sprintf("%s [%s]: %s", phase, ip, message)) + r.Errors = append(r.Errors, fmt.Sprintf("%s [%s]: %s", friendlyPhase, ip, message)) } else { - r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", phase, message)) + r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", friendlyPhase, message)) } } @@ -259,12 +278,16 @@ func (s *Scanner) DiscoverServersWithCallbacks(ctx context.Context, subnet strin Msg("Starting discovery for explicit extra targets") if err := s.runPhaseWithProgress(ctx, "extra_targets", phaseNumber, totalPhases, extraIPs, serverCallback, progressCallback, &totalProcessed, totalTargets, result); err != nil { errType := "phase_error" + errMsg := err.Error() + if errors.Is(err, context.Canceled) { errType = "canceled" } else if errors.Is(err, context.DeadlineExceeded) { errType = "timeout" + // Provide user-friendly timeout message + errMsg = fmt.Sprintf("Scan timed out after 2 minutes - this is normal for large networks. Servers found in earlier phases are still available.") } - result.AddError("extra_targets", errType, err.Error(), "", 0) + result.AddError("extra_targets", errType, errMsg, "", 0) if errors.Is(err, context.Canceled) { return result, ctx.Err() } @@ -303,12 +326,16 @@ func (s *Scanner) DiscoverServersWithCallbacks(ctx context.Context, subnet strin if err := s.runPhaseWithProgress(ctx, phase.Name, phaseNumber, totalPhases, phaseIPs, serverCallback, progressCallback, &totalProcessed, totalTargets, result); err != nil { errType := "phase_error" + errMsg := err.Error() + if errors.Is(err, context.Canceled) { errType = "canceled" } else if errors.Is(err, context.DeadlineExceeded) { errType = "timeout" + // Provide user-friendly timeout message + errMsg = fmt.Sprintf("Scan timed out after 2 minutes - this is normal for large networks. Servers found in earlier phases are still available.") } - result.AddError(phase.Name, errType, err.Error(), "", 0) + result.AddError(phase.Name, errType, errMsg, "", 0) if errors.Is(err, context.Canceled) { return result, ctx.Err() } diff --git a/scripts/codex-router.sh b/scripts/codex-router.sh new file mode 100755 index 0000000..55cc33e --- /dev/null +++ b/scripts/codex-router.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Heuristic router for selecting a Codex reasoning tier. +# Usage: codex-router.sh "Fix the typo" or echo "..." | codex-router.sh + +set -euo pipefail + +WORKDIR="/opt/pulse" + +# Collect a prompt from stdin and/or arguments. +PROMPT_INPUT="" +if [[ ! -t 0 ]]; then + PROMPT_INPUT="$(cat)" +fi + +if [[ $# -gt 0 ]]; then + if [[ -n "${PROMPT_INPUT}" ]]; then + PROMPT="${PROMPT_INPUT}"$'\n'"$*" + else + PROMPT="$*" + fi +else + PROMPT="${PROMPT_INPUT}" +fi + +PROMPT="$(printf '%s' "${PROMPT}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + +if [[ -z "${PROMPT}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +reasoning_profile="low" + +# Escalate when the task hints at analysis, design, or larger edits. +if [[ ${#PROMPT} -gt 400 ]] \ + || grep -qiE '(analysis|investigate|explain|design|architecture|strategy|refactor|spec|diagnos|trade[- ]?off|postmortem)' <<<"${PROMPT}"; then + reasoning_profile="medium" +fi + +case "${reasoning_profile}" in + medium) + profile_arg=(--profile medium) + ;; + *) + profile_arg=(--profile low) + ;; +esac + +echo "Routing prompt to Codex profile '${reasoning_profile}'" >&2 +exec codex exec "${profile_arg[@]}" -C "${WORKDIR}" "${PROMPT}" diff --git a/scripts/install-docker.sh b/scripts/install-docker.sh index 5450142..9792425 100644 --- a/scripts/install-docker.sh +++ b/scripts/install-docker.sh @@ -6,6 +6,387 @@ set -euo pipefail PULSE_IMAGE="${PULSE_IMAGE:-rcourtman/pulse:latest}" PULSE_PORT="${PULSE_PORT:-7655}" +PULSE_PROXY_CHANNEL="${PULSE_PROXY_CHANNEL:-stable}" +DETERMINED_PROXY_VERSION="" +PROXY_INSTALLER_SOURCE_LABEL="unset" +PROXY_ARCH_LABEL="" + +compute_proxy_arch_label() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64) + PROXY_ARCH_LABEL="linux-amd64" + ;; + aarch64 | arm64) + PROXY_ARCH_LABEL="linux-arm64" + ;; + armv7l | armhf) + PROXY_ARCH_LABEL="linux-armv7" + ;; + *) + PROXY_ARCH_LABEL="" + ;; + esac +} + +set_github_fallback_url() { + local ref="$1" + if [[ -z "$PROXY_ARCH_LABEL" ]]; then + return + fi + + local base="" + if [[ "$ref" == "main" ]]; then + base="https://raw.githubusercontent.com/rcourtman/Pulse/${ref}/release/pulse-sensor-proxy-${PROXY_ARCH_LABEL}" + else + base="https://github.com/rcourtman/Pulse/releases/download/${ref}/pulse-sensor-proxy-${PROXY_ARCH_LABEL}" + fi + + export PULSE_SENSOR_PROXY_FALLBACK_URL="$base" +} + +EMBEDDED_INSTALLER_ARCHIVE_B64=$(cat <<'EOF' +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 +EOF +) + +determine_proxy_release() { + if [[ -n "${PULSE_PROXY_VERSION:-}" ]]; then + DETERMINED_PROXY_VERSION="${PULSE_PROXY_VERSION}" + return 0 + fi + + local channel="${PULSE_PROXY_CHANNEL:-stable}" + local tag="" + local api_url="" + + if command -v curl >/dev/null 2>&1; then + if [[ "$channel" == "rc" ]]; then + api_url="https://api.github.com/repos/rcourtman/Pulse/releases" + tag=$(curl -fsSL --connect-timeout 5 --max-time 15 "$api_url" 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true) + else + api_url="https://api.github.com/repos/rcourtman/Pulse/releases/latest" + tag=$(curl -fsSL --connect-timeout 5 --max-time 15 "$api_url" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true) + fi + fi + + if [[ -n "$tag" ]]; then + DETERMINED_PROXY_VERSION="$tag" + return 0 + fi + + DETERMINED_PROXY_VERSION="main" + return 1 +} + +download_installer_from_local() { + local destination="$1" + local base_url="${PULSE_SERVER:-http://localhost:7655}" + local url="${base_url%/}/api/install/install-sensor-proxy.sh" + + if curl --fail --silent --location --connect-timeout 5 --max-time 20 "$url" -o "$destination" 2>/dev/null; then + chmod +x "$destination" + PROXY_INSTALLER_SOURCE_LABEL="local" + export PULSE_SENSOR_PROXY_FALLBACK_URL="${base_url%/}/api/install/pulse-sensor-proxy" + echo " ✓ Downloaded proxy installer from ${url}" + return 0 + fi + + echo " ⚠️ Unable to download proxy installer from ${url}" + return 1 +} + +download_installer_from_github() { + local destination="$1" + determine_proxy_release + local tag="$DETERMINED_PROXY_VERSION" + local ref="$tag" + local attempted=false + + if [[ "$tag" == "main" ]]; then + ref="main" + fi + + if [[ "$tag" != "main" ]]; then + local asset_url="https://github.com/rcourtman/Pulse/releases/download/${tag}/install-sensor-proxy.sh" + attempted=true + if curl --fail --silent --location --connect-timeout 5 --max-time 30 "$asset_url" -o "$destination" 2>/dev/null; then + chmod +x "$destination" + PROXY_INSTALLER_SOURCE_LABEL="github:${tag}" + set_github_fallback_url "$tag" + echo " ✓ Downloaded proxy installer from ${asset_url}" + return 0 + fi + fi + + local raw_url="https://raw.githubusercontent.com/rcourtman/Pulse/${ref}/scripts/install-sensor-proxy.sh" + attempted=true + if curl --fail --silent --location --connect-timeout 5 --max-time 30 "$raw_url" -o "$destination" 2>/dev/null; then + chmod +x "$destination" + PROXY_INSTALLER_SOURCE_LABEL="github:${ref}" + set_github_fallback_url "$ref" + echo " ✓ Downloaded proxy installer from ${raw_url}" + return 0 + fi + + if [[ "$attempted" == true ]]; then + echo " ⚠️ Unable to download proxy installer from GitHub (tag=${tag})" + fi + return 1 +} + +write_embedded_installer() { + local destination="$1" + + if ! command -v base64 >/dev/null 2>&1; then + echo " ⚠️ base64 command not available; cannot use embedded installer" + return 1 + fi + + if ! command -v gzip >/dev/null 2>&1; then + echo " ⚠️ gzip command not available; cannot use embedded installer" + return 1 + fi + + local decode_output + if decode_output=$(printf '%s' "$EMBEDDED_INSTALLER_ARCHIVE_B64" | base64 -d 2>&1 | gzip -dc 2>&1 >"$destination"); then + chmod +x "$destination" + PROXY_INSTALLER_SOURCE_LABEL="embedded" + echo " ✓ Used embedded proxy installer fallback" + return 0 + fi + + echo " ⚠️ Embedded proxy installer fallback failed" + if [[ -n "$decode_output" ]]; then + echo " → $decode_output" + fi + return 1 +} + +download_proxy_installer() { + local destination="$1" + + if download_installer_from_local "$destination"; then + return 0 + fi + + echo " Attempting GitHub fallback..." + if download_installer_from_github "$destination"; then + return 0 + fi + + echo " Attempting embedded installer fallback..." + if write_embedded_installer "$destination"; then + return 0 + fi + + return 1 +} + # ============================================ # Helper Functions @@ -36,6 +417,8 @@ validate_socket() { # Pre-flight Checks # ============================================ +compute_proxy_arch_label + echo "============================================" echo " Pulse Turnkey Docker Installation" echo "============================================" @@ -134,23 +517,40 @@ else # Download and run the proxy installer PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh" - INSTALLER_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/install-sensor-proxy.sh" - if ! curl --fail --silent --location "$INSTALLER_URL" -o "$PROXY_INSTALLER" 2>/dev/null; then - echo "❌ ERROR: Could not download installer from Pulse server" + unset PULSE_SENSOR_PROXY_FALLBACK_URL + + if ! download_proxy_installer "$PROXY_INSTALLER"; then + echo "❌ ERROR: Could not obtain pulse-sensor-proxy installer" echo "" - echo "Please ensure:" - echo " 1. Pulse server is running at ${PULSE_SERVER:-http://localhost:7655}" - echo " 2. Network connectivity is available" + echo "Sources attempted:" + echo " • ${PULSE_SERVER:-http://localhost:7655}/api/install/install-sensor-proxy.sh" + if [[ -n "$DETERMINED_PROXY_VERSION" ]]; then + echo " • GitHub release/tag: ${DETERMINED_PROXY_VERSION}" + else + echo " • GitHub releases (auto-detected)" + fi + echo " • Embedded fallback installer" echo "" - echo "Alternatively, download from GitHub releases (coming soon)" + echo "Hints:" + echo " 1. Ensure network connectivity to the Pulse host or GitHub." + echo " 2. Set PULSE_PROXY_VERSION (e.g. v4.24.0) to pin a specific release." + echo " 3. Confirm curl is allowed through firewalls/proxies." + rm -f "$PROXY_INSTALLER" exit 1 fi - chmod +x "$PROXY_INSTALLER" - - # Set fallback URL for proxy binary download - export PULSE_SENSOR_PROXY_FALLBACK_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/pulse-sensor-proxy" + case "$PROXY_INSTALLER_SOURCE_LABEL" in + local) + # already set during download + ;; + github:*) + # fallback URL set in download helper + ;; + *) + unset PULSE_SENSOR_PROXY_FALLBACK_URL + ;; + esac # Run installer in standalone mode (no container) if ! "$PROXY_INSTALLER" --standalone --pulse-server "${PULSE_SERVER:-http://localhost:7655}" --quiet; then diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 0b22402..b0bbe94 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -397,64 +397,155 @@ else ;; esac - # If fallback URL is provided (e.g., from Pulse setup script), use it directly - # Try GitHub first for releases GITHUB_REPO="rcourtman/Pulse" DOWNLOAD_SUCCESS=false + ATTEMPTED_SOURCES=() + LATEST_RELEASE_TAG="" - if [[ "$VERSION" == "latest" ]]; then - RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest" - print_info "Fetching latest release info..." - RELEASE_ERROR=$(mktemp) - RELEASE_DATA=$(curl -fSL "$RELEASE_URL" 2>"$RELEASE_ERROR") - CURL_EXIT=$? - if [ $CURL_EXIT -ne 0 ] && [ -s "$RELEASE_ERROR" ]; then - print_warn "Failed to fetch GitHub release info: $(cat "$RELEASE_ERROR")" - fi - rm -f "$RELEASE_ERROR" - VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4) - - if [[ -n "$VERSION" ]]; then - print_info "Latest version: $VERSION" - DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME" - print_info "Downloading $BINARY_NAME from GitHub..." - DOWNLOAD_ERROR=$(mktemp) - if curl -fSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp" 2>"$DOWNLOAD_ERROR"; then - DOWNLOAD_SUCCESS=true - rm -f "$DOWNLOAD_ERROR" + fetch_latest_release_tag() { + local api_url="https://api.github.com/repos/$GITHUB_REPO/releases/latest" + local tmp_err + tmp_err=$(mktemp) + local response + response=$(curl --fail --silent --location --connect-timeout 10 --max-time 30 "$api_url" 2>"$tmp_err") + local status=$? + if [[ $status -ne 0 ]]; then + if [[ -s "$tmp_err" ]]; then + print_warn "Failed to resolve latest GitHub release: $(cat "$tmp_err")" else - if [ -s "$DOWNLOAD_ERROR" ]; then - print_warn "GitHub download failed: $(cat "$DOWNLOAD_ERROR")" - fi - rm -f "$DOWNLOAD_ERROR" + print_warn "Failed to resolve latest GitHub release (HTTP $status)" fi + rm -f "$tmp_err" + return 1 fi - fi + rm -f "$tmp_err" + local tag + tag=$(echo "$response" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ -z "$tag" ]]; then + print_warn "Could not parse latest release tag from GitHub response" + return 1 + fi + LATEST_RELEASE_TAG="$tag" + return 0 + } - # Fall back to Pulse server if GitHub failed or fallback is provided - if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$FALLBACK_BASE" ]]; then - FALLBACK_URL="${FALLBACK_BASE%/}?arch=${ARCH_LABEL}" - print_info "Downloading $BINARY_NAME from Pulse server..." - FALLBACK_ERROR=$(mktemp) - if curl -fSL "$FALLBACK_URL" -o "$BINARY_PATH.tmp" 2>"$FALLBACK_ERROR"; then + attempt_github_asset_or_tarball() { + local tag="$1" + [[ -z "$tag" ]] && return 1 + + local asset_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${BINARY_NAME}" + ATTEMPTED_SOURCES+=("GitHub release asset ${tag}") + print_info "Downloading $BINARY_NAME from GitHub release ${tag}..." + local tmp_err + tmp_err=$(mktemp) + if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$asset_url" -o "$BINARY_PATH.tmp" 2>"$tmp_err"; then + rm -f "$tmp_err" DOWNLOAD_SUCCESS=true - rm -f "$FALLBACK_ERROR" - else - if [ -s "$FALLBACK_ERROR" ]; then - print_error "Pulse server download failed: $(cat "$FALLBACK_ERROR")" + return 0 + fi + + local asset_error="" + if [[ -s "$tmp_err" ]]; then + asset_error="$(cat "$tmp_err")" + fi + rm -f "$tmp_err" + rm -f "$BINARY_PATH.tmp" 2>/dev/null || true + + local tarball_name="pulse-${tag}-linux-${ARCH_LABEL#linux-}.tar.gz" + local tarball_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${tarball_name}" + ATTEMPTED_SOURCES+=("GitHub release tarball ${tarball_name}") + print_info "Downloading ${tarball_name} to extract pulse-sensor-proxy..." + tmp_err=$(mktemp) + local tarball_tmp + tarball_tmp=$(mktemp) + if curl --fail --silent --location --connect-timeout 10 --max-time 240 "$tarball_url" -o "$tarball_tmp" 2>"$tmp_err"; then + if tar -tzf "$tarball_tmp" >/dev/null 2>&1 && tar -xzf "$tarball_tmp" -C "$(dirname "$tarball_tmp")" ./bin/pulse-sensor-proxy >/dev/null 2>&1; then + mv "$(dirname "$tarball_tmp")/bin/pulse-sensor-proxy" "$BINARY_PATH.tmp" + rm -f "$tarball_tmp" "$tmp_err" + DOWNLOAD_SUCCESS=true + return 0 + else + print_warn "Release tarball did not contain expected ./bin/pulse-sensor-proxy" fi - rm -f "$FALLBACK_ERROR" + else + if [[ -s "$tmp_err" ]]; then + print_warn "Tarball download failed: $(cat "$tmp_err")" + else + print_warn "Tarball download failed (HTTP error)" + fi + fi + rm -f "$tarball_tmp" "$tmp_err" + if [[ -n "$asset_error" ]]; then + print_warn "GitHub release asset error: $asset_error" + fi + return 1 + } + + REQUESTED_VERSION="${VERSION:-latest}" + if [[ "$REQUESTED_VERSION" == "latest" || "$REQUESTED_VERSION" == "main" || -z "$REQUESTED_VERSION" ]]; then + if fetch_latest_release_tag; then + attempt_github_asset_or_tarball "$LATEST_RELEASE_TAG" || true + fi + else + attempt_github_asset_or_tarball "$REQUESTED_VERSION" || true + fi + + if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$FALLBACK_BASE" ]]; then + fallback_url="$FALLBACK_BASE" + if [[ "$fallback_url" == *"?"* ]]; then + fallback_url="$fallback_url" + elif [[ "$fallback_url" == *"pulse-sensor-proxy-"* ]]; then + fallback_url="${fallback_url}" + else + fallback_url="${fallback_url%/}?arch=${ARCH_LABEL}" + fi + + ATTEMPTED_SOURCES+=("Fallback ${fallback_url}") + print_info "Downloading $BINARY_NAME from fallback source..." + fallback_err=$(mktemp) + if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$fallback_url" -o "$BINARY_PATH.tmp" 2>"$fallback_err"; then + rm -f "$fallback_err" + DOWNLOAD_SUCCESS=true + else + if [[ -s "$fallback_err" ]]; then + print_error "Fallback download failed: $(cat "$fallback_err")" + else + print_error "Fallback download failed (HTTP error)" + fi + rm -f "$fallback_err" + rm -f "$BINARY_PATH.tmp" 2>/dev/null || true fi fi - # Exit if both methods failed + if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$CTID" ]] && command -v pct >/dev/null 2>&1; then + pull_targets=( + "/opt/pulse/bin/${BINARY_NAME}" + "/opt/pulse/bin/pulse-sensor-proxy" + ) + for src in "${pull_targets[@]}"; do + tmp_pull=$(mktemp) + if pct pull "$CTID" "$src" "$tmp_pull" >/dev/null 2>&1; then + mv "$tmp_pull" "$BINARY_PATH.tmp" + print_info "Copied pulse-sensor-proxy binary from container $CTID ($src)" + DOWNLOAD_SUCCESS=true + break + fi + rm -f "$tmp_pull" + done + fi + if [[ "$DOWNLOAD_SUCCESS" != true ]]; then - print_error "Failed to download binary from GitHub and Pulse server" - print_error "Check network connectivity and firewall rules" + print_error "Unable to download pulse-sensor-proxy binary." + if [[ ${#ATTEMPTED_SOURCES[@]} -gt 0 ]]; then + print_error "Sources attempted:" + for src in "${ATTEMPTED_SOURCES[@]}"; do + print_error " - $src" + done + fi + print_error "Publish a GitHub release with binary assets or ensure a Pulse server is reachable." exit 1 fi - # Make executable and move to final location chmod +x "$BINARY_PATH.tmp" mv "$BINARY_PATH.tmp" "$BINARY_PATH" print_info "Binary installed to $BINARY_PATH"