Update Pulse install flow and related components

This commit is contained in:
rcourtman 2025-10-21 19:58:53 +00:00
parent 999da6d900
commit ff4dc49ae4
30 changed files with 1906 additions and 431 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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 youd 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 <id>`). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt.
> **Advanced option**: If youd 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 <id> --pulse-server http://<pulse-container-ip>:7655`). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt.
### Runtime Verification

View file

@ -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 isnt 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 <your-pulse-container-id>
bash -s -- --ctid <your-pulse-container-id> --pulse-server http://<pulse-container-ip>: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 <ctid> --pulse-server http://<pulse-container-ip>:7655`
3. **Verify bind mount**
Check `/etc/pve/lxc/<ctid>.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:

View file

@ -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() {
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<DemoBanner />
<ActivationBanner
activationState={alertsActivation.activationState}
activeAlerts={alertsActivation.activeAlerts}
config={alertsActivation.config}
isPastObservationWindow={alertsActivation.isPastObservationWindow}
isLoading={alertsActivation.isLoading}
refreshActiveAlerts={alertsActivation.refreshActiveAlerts}
activate={alertsActivation.activate}
/>
<UpdateBanner />
<LegacySSHBanner />
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans py-4 sm:py-6">

View file

@ -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<boolean>;
}
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 (
<>
<Show when={shouldShow()}>
<div class="bg-blue-50 dark:bg-blue-900/30 border-b border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100 relative animate-slideDown">
<div class="px-4 py-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 flex-shrink-0 text-blue-600 dark:text-blue-300 mt-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 16v-5a6 6 0 1 0-12 0v5l-2 2h16l-2-2z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="space-y-1">
<p class="text-sm font-medium">
Monitoring is active. Review your settings to enable notifications.
</p>
<p class="text-xs text-blue-700 dark:text-blue-200">{observationSummary()}</p>
<Show when={props.isPastObservationWindow()}>
<p class="text-xs font-semibold text-blue-800 dark:text-blue-100">
24-hour setup period ending soon activate to start receiving notifications.
</p>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
onClick={handleReview}
disabled={props.isLoading()}
>
{props.isLoading() ? 'Loading…' : 'Review & Activate'}
</button>
</div>
</div>
</div>
</div>
</Show>
<ActivationModal
isOpen={isModalOpen()}
onClose={() => 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 <></>;
}

View file

@ -185,7 +185,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
<Portal>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50 dark:bg-black/60" onClick={props.onClose} />
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden border border-gray-200 dark:border-gray-700">
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden border border-gray-200 dark:border-gray-700 flex flex-col">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Ready to activate notifications</h2>
@ -206,7 +206,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
</button>
</div>
<div class="px-6 py-5 space-y-6 overflow-y-auto">
<div class="px-6 py-5 space-y-6 flex-1 overflow-y-auto">
<section>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
Current thresholds

View file

@ -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<string | null>(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;

View file

@ -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<CompactNodeCardProps> = (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<CompactNodeCardProps> = (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<CompactNodeCardProps> = (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<CompactNodeCardProps> = (props) => {
</Show>
{/* Alert indicator */}
<Show when={alertStyles.hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
<Show when={alertStyles().hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
</Show>
{/* Metrics */}
@ -155,9 +162,9 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (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<CompactNodeCardProps> = (props) => {
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
</span>
</Show>
<Show when={alertStyles.hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
<Show when={alertStyles().hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
</Show>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">

View file

@ -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<string | null>(null);
@ -796,7 +799,7 @@ export function Dashboard(props: DashboardProps) {
return (
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts)}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}

View file

@ -13,6 +13,7 @@ import { getAlertStyles } from '@/utils/alerts';
// import type { DockerHostSummary } from './DockerHostSummaryTable';
import { renderDockerStatusBadge } from './DockerStatusBadge';
import { useWebSocket } from '@/App';
import { useAlertsActivation } from '@/stores/alertsActivation';
interface DockerHostsProps {
hosts: DockerHost[];
@ -110,6 +111,8 @@ const DockerContainerRow: Component<{
activeAlerts?: Record<string, Alert>;
}> = (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;

View file

@ -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<SettingsProps> = (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<string | undefined>();
const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false);
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
let discoverySubnetInputRef: HTMLInputElement | undefined;
const parseSubnetList = (value: string) => {
const seen = new Set<string>();
return value
.split(',')
.map((token) => token.trim())
.filter((token) => {
if (!token || token.toLowerCase() === 'auto' || seen.has(token)) {
return false;
}
seen.add(token);
return true;
});
};
const normalizeSubnetList = (value: string) => parseSubnetList(value).join(', ');
const currentDraftSubnetValue = () => {
if (discoveryMode() === 'custom') {
return discoverySubnetDraft();
}
const draft = discoverySubnetDraft();
if (draft.trim() !== '') {
return draft;
}
const saved = discoverySubnet();
return saved.toLowerCase() === 'auto' ? '' : saved;
};
const isValidCIDR = (value: string) => {
const subnets = parseSubnetList(value);
if (subnets.length === 0) {
return false;
}
return subnets.every((token) => {
const [network, prefix] = token.split('/');
if (!network || typeof prefix === 'undefined') {
return false;
}
const prefixNumber = Number(prefix);
if (!Number.isInteger(prefixNumber) || prefixNumber < 0 || prefixNumber > 32) {
return false;
}
const octets = network.split('.');
if (octets.length !== 4) {
return false;
}
return octets.every((octet) => {
if (octet === '') return false;
if (!/^\d+$/.test(octet)) return false;
const valueNumber = Number(octet);
return valueNumber >= 0 && valueNumber <= 255;
});
});
};
const applySavedDiscoverySubnet = (subnet?: string | null) => {
const raw = typeof subnet === 'string' ? subnet.trim() : '';
if (raw === '' || raw.toLowerCase() === 'auto') {
setDiscoverySubnet('auto');
setDiscoveryMode('auto');
setDiscoverySubnetDraft('');
} else {
setDiscoveryMode('custom');
const normalizedValue = normalizeSubnetList(raw);
setDiscoverySubnet(normalizedValue);
setDiscoverySubnetDraft(normalizedValue);
setLastCustomSubnet(normalizedValue);
setDiscoverySubnetError(undefined);
return;
}
setDiscoverySubnetError(undefined);
};
// Connection timeout removed - backend-only setting
// Iframe embedding settings
@ -893,6 +984,187 @@ const Settings: Component<SettingsProps> = (props) => {
}
};
const handleDiscoveryEnabledChange = async (enabled: boolean): Promise<boolean> => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
return false;
}
const previousEnabled = discoveryEnabled();
const previousSubnet = discoverySubnet();
let subnetToSend = discoverySubnet();
if (enabled) {
if (discoveryMode() === 'custom') {
const trimmedDraft = discoverySubnetDraft().trim();
if (!trimmedDraft) {
setDiscoverySubnetError('Enter at least one subnet before enabling discovery');
notificationStore.error('Enter at least one subnet before enabling discovery');
return false;
}
if (!isValidCIDR(trimmedDraft)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
notificationStore.error('Enter valid CIDR subnet values before enabling discovery');
return false;
}
const normalizedDraft = normalizeSubnetList(trimmedDraft);
setDiscoverySubnetDraft(normalizedDraft);
setDiscoverySubnetError(undefined);
subnetToSend = normalizedDraft;
} else {
subnetToSend = 'auto';
setDiscoverySubnetError(undefined);
}
}
setDiscoveryEnabled(enabled);
setSavingDiscoverySettings(true);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: enabled,
discoverySubnet: subnetToSend,
});
applySavedDiscoverySubnet(subnetToSend);
if (enabled && subnetToSend !== 'auto') {
setLastCustomSubnet(subnetToSend);
}
if (enabled) {
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery enabled — scanning network...', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
setDiscoveryScanStatus((prev) => ({
...prev,
scanning: false,
}));
}
return true;
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(previousEnabled);
applySavedDiscoverySubnet(previousSubnet);
return false;
} finally {
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
};
const commitDiscoverySubnet = async (rawValue: string): Promise<boolean> => {
if (envOverrides().discoverySubnet) {
return false;
}
const value = rawValue.trim();
if (!value) {
setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)');
return false;
}
if (!isValidCIDR(value)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
return false;
}
const normalizedValue = normalizeSubnetList(value);
if (!normalizedValue) {
setDiscoverySubnetError('Enter at least one valid subnet in CIDR format');
return false;
}
const previousSubnet = discoverySubnet();
const previousNormalized =
previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet);
if (normalizedValue === previousNormalized) {
setDiscoverySubnetDraft(normalizedValue);
setDiscoverySubnetError(undefined);
setLastCustomSubnet(normalizedValue);
return true;
}
setSavingDiscoverySettings(true);
try {
setDiscoverySubnetError(undefined);
await SettingsAPI.updateSystemSettings({
discoveryEnabled: discoveryEnabled(),
discoverySubnet: normalizedValue,
});
setLastCustomSubnet(normalizedValue);
applySavedDiscoverySubnet(normalizedValue);
if (discoveryEnabled()) {
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery subnet updated — scanning network...', 2000);
} else {
notificationStore.success('Discovery subnet saved', 2000);
}
return true;
} catch (error) {
console.error('Failed to update discovery subnet:', error);
notificationStore.error('Failed to update discovery subnet');
applySavedDiscoverySubnet(previousSubnet);
setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet));
return false;
} finally {
setDiscoverySubnetError(undefined);
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
};
const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => {
if (envOverrides().discoverySubnet || savingDiscoverySettings()) {
return;
}
if (mode === discoveryMode()) {
return;
}
if (mode === 'auto') {
const previousSubnet = discoverySubnet();
setDiscoveryMode('auto');
setDiscoverySubnetDraft('');
setDiscoverySubnetError(undefined);
setSavingDiscoverySettings(true);
try {
await SettingsAPI.updateSystemSettings({
discoveryEnabled: discoveryEnabled(),
discoverySubnet: 'auto',
});
applySavedDiscoverySubnet('auto');
if (discoveryEnabled()) {
await triggerDiscoveryScan({ quiet: true });
}
notificationStore.info(
'Auto discovery scans each network phase. Large networks may take longer.',
4000,
);
} catch (error) {
console.error('Failed to update discovery subnet:', error);
notificationStore.error('Failed to update discovery subnet');
applySavedDiscoverySubnet(previousSubnet);
} finally {
setSavingDiscoverySettings(false);
await loadDiscoveredNodes();
}
return;
}
setDiscoveryMode('custom');
const rawDraft =
discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || '';
const normalizedDraft = normalizeSubnetList(rawDraft);
setDiscoverySubnetDraft(normalizedDraft);
setDiscoverySubnetError(undefined);
queueMicrotask(() => {
discoverySubnetInputRef?.focus();
discoverySubnetInputRef?.select();
});
};
// Load nodes and system settings on mount
onMount(async () => {
// Subscribe to events
@ -963,6 +1235,10 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (props) => {
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
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={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -2048,6 +2304,19 @@ const Settings: Component<SettingsProps> = (props) => {
{(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
@ -2169,38 +2438,18 @@ const Settings: Component<SettingsProps> = (props) => {
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
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={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -2483,6 +2732,19 @@ const Settings: Component<SettingsProps> = (props) => {
{(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
@ -2605,38 +2867,18 @@ const Settings: Component<SettingsProps> = (props) => {
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
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={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -2899,6 +3141,19 @@ const Settings: Component<SettingsProps> = (props) => {
{(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
@ -3038,6 +3293,288 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Card>
<Card padding="lg" class="space-y-5">
<SectionHeader
title="Network discovery"
description="Control how Pulse scans for Proxmox services on your network."
size="sm"
align="left"
/>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Automatic scanning</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.
</p>
</div>
<Toggle
checked={discoveryEnabled()}
onChange={async (e) => {
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await handleDiscoveryEnabledChange(e.currentTarget.checked);
if (!success) {
e.currentTarget.checked = discoveryEnabled();
}
}}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={discoveryEnabled()}>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white/40 p-3 dark:border-gray-600 dark:bg-gray-800/40">
<fieldset class="space-y-2">
<legend class="text-xs font-medium text-gray-700 dark:text-gray-300">
Scan scope
</legend>
<div class="space-y-2">
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
discoveryMode() === 'auto'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="auto"
checked={discoveryMode() === 'auto'}
onChange={async () => {
if (discoveryMode() !== 'auto') {
await handleDiscoveryModeChange('auto');
}
}}
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Auto (slower, full scan)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Scans container, local, and gateway networks. Large networks may time
out after two minutes.
</p>
</div>
</label>
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
discoveryMode() === 'custom'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="custom"
checked={discoveryMode() === 'custom'}
onChange={() => {
if (discoveryMode() !== 'custom') {
handleDiscoveryModeChange('custom');
}
}}
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Custom subnet (faster)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Limit discovery to one or more CIDR ranges to finish faster on large networks.
</p>
</div>
</label>
<Show when={discoveryMode() === 'custom'}>
<div class="flex flex-wrap items-center gap-2 pl-9 pr-2">
<span class="text-[0.68rem] uppercase tracking-wide text-gray-500 dark:text-gray-400">
Common networks:
</span>
<For each={COMMON_DISCOVERY_SUBNETS}>
{(preset) => {
const baseValue = currentDraftSubnetValue();
const currentSelections = parseSubnetList(baseValue);
const isActive = currentSelections.includes(preset);
return (
<button
type="button"
class={`rounded-full border px-2.5 py-1 text-[0.7rem] transition-colors ${
isActive
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:border-blue-300 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
}`}
onClick={async () => {
if (envOverrides().discoverySubnet) {
return;
}
let selections = [...currentSelections];
if (isActive) {
selections = selections.filter((item) => item !== preset);
} else {
selections.push(preset);
}
if (selections.length === 0) {
setDiscoverySubnetDraft('');
setLastCustomSubnet('');
setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
);
return;
}
const updatedValue = normalizeSubnetList(selections.join(', '));
setDiscoveryMode('custom');
setDiscoverySubnetDraft(updatedValue);
setLastCustomSubnet(updatedValue);
setDiscoverySubnetError(undefined);
await commitDiscoverySubnet(updatedValue);
}}
disabled={envOverrides().discoverySubnet}
classList={{
'cursor-not-allowed opacity-60': envOverrides().discoverySubnet,
}}
>
{preset}
</button>
);
}}
</For>
</div>
</Show>
</div>
</fieldset>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="discoverySubnetInput"
class="text-xs font-medium text-gray-700 dark:text-gray-300"
>
Discovery subnet
</label>
<span
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300 cursor-help"
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
<input
id="discoverySubnetInput"
ref={(el) => {
discoverySubnetInputRef = el;
}}
type="text"
value={discoverySubnetDraft()}
placeholder={
discoveryMode() === 'auto'
? 'auto (scan every network phase)'
: '192.168.1.0/24, 10.0.0.0/24'
}
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
envOverrides().discoverySubnet
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
}`}
disabled={envOverrides().discoverySubnet}
onInput={(e) => {
if (envOverrides().discoverySubnet) {
return;
}
const rawValue = e.currentTarget.value;
setDiscoverySubnetDraft(rawValue);
if (discoveryMode() !== 'custom') {
setDiscoveryMode('custom');
}
setLastCustomSubnet(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
setDiscoverySubnetError(undefined);
return;
}
if (!isValidCIDR(trimmed)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
} else {
setDiscoverySubnetError(undefined);
}
}}
onBlur={async (e) => {
if (envOverrides().discoverySubnet || discoveryMode() !== 'custom') {
return;
}
const rawValue = e.currentTarget.value;
setDiscoverySubnetDraft(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
);
return;
}
if (!isValidCIDR(trimmed)) {
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
return;
}
setDiscoverySubnetError(undefined);
await commitDiscoverySubnet(rawValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
<Show when={discoverySubnetError()}>
<p class="text-xs text-red-600 dark:text-red-400">
{discoverySubnetError()}
</p>
</Show>
<Show when={!discoverySubnetError() && discoveryMode() === 'auto'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Auto scans every reachable network phase. Large networks may time out
switch to custom subnets to narrow the search.
</p>
</Show>
<Show when={!discoverySubnetError() && discoveryMode() === 'custom'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish faster and avoid timeouts.
</p>
</Show>
</div>
</div>
</Show>
<Show when={envOverrides().discoveryEnabled || envOverrides().discoverySubnet}>
<div class="rounded-lg border border-amber-200 bg-amber-100/80 p-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Discovery settings are locked by environment variables. Update the service
configuration and restart Pulse to change them here.
</div>
</Show>
</Card>
<Card padding="lg" class="space-y-3">
<SectionHeader
title="Appearance"

View file

@ -14,12 +14,15 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { getNodeDisplayName } from '@/utils/nodes';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useAlertsActivation } from '@/stores/alertsActivation';
type StorageSortKey = 'name' | 'node' | 'type' | 'status' | 'usage' | 'free' | 'total';
const Storage: Component = () => {
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}`,
};

View file

@ -164,7 +164,7 @@ export const ToastContainer: Component = () => {
return (
<Portal>
<div class="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm">
<div class="fixed bottom-4 right-4 z-[9999] space-y-2 max-w-sm">
<For each={toasts()}>{(toast) => <Toast toast={toast} onRemove={removeToast} />}</For>
</div>
</Portal>

View file

@ -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<AlertIndicatorProps> = (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<AlertCountBadgeProps> = (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';

View file

@ -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<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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<string, string> = {};
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<NodeSummaryTableProps> = (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';
}

View file

@ -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<AlertTab>(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<UIAppriseConfig>(
<div class="space-y-4">
{/* Header with better styling */}
<Card padding="md">
<SectionHeader
title={headerMeta().title}
description={headerMeta().description}
size="lg"
/>
<div class="flex items-center justify-between gap-4">
<SectionHeader
title={headerMeta().title}
description={headerMeta().description}
size="lg"
/>
<Show when={activeTab() === 'overview'}>
<div class="flex items-center gap-3">
<span
class={`text-sm font-medium ${
isAlertsActive() ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'
}`}
>
{isAlertsActive() ? 'Alerts enabled' : 'Alerts disabled'}
</span>
<label class="relative inline-flex items-center cursor-pointer">
<span class="sr-only">Toggle alerts</span>
<input
type="checkbox"
class="sr-only peer"
checked={isAlertsActive()}
disabled={alertsActivation.isLoading() || isSwitchingActivation()}
onChange={(event) => {
if (event.currentTarget.checked) {
void handleActivateAlerts();
} else {
void handleDeactivateAlerts();
}
}}
/>
<div
class={`relative w-11 h-6 rounded-full transition ${
isAlertsActive() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
} ${alertsActivation.isLoading() || isSwitchingActivation() ? 'opacity-50' : ''}`}
>
<span
class={`absolute top-[2px] left-[2px] h-5 w-5 rounded-full bg-white transition-all shadow ${
isAlertsActive() ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</div>
</label>
</div>
</Show>
</div>
</Card>
{/* Save notification bar - only show when there are unsaved changes */}
@ -1419,6 +1515,10 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
</Card>
</Show>
<div class={`transition-opacity ${
isAlertsActive() ? 'opacity-100' : 'opacity-50 pointer-events-none'
}`}>
<Card padding="none" class="relative lg:flex">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
@ -1439,24 +1539,39 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
</Show>
<div class="space-y-1.5">
<For each={group.items}>
{(item) => (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
activeTab() === item.id
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
}`}
onClick={() => handleTabChange(item.id)}
title={sidebarCollapsed() ? item.label : undefined}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
)}
{(item) => {
const disabled = () => item.id !== 'overview' && !isAlertsActive();
return (
<button
type="button"
aria-current={activeTab() === item.id ? 'page' : undefined}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
activeTab() === item.id
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
} ${disabled() ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`}
disabled={disabled()}
onClick={() => {
if (disabled()) return;
handleTabChange(item.id);
}}
title={
sidebarCollapsed()
? disabled()
? 'Activate alerts to configure'
: item.label
: disabled()
? 'Activate alerts to configure'
: undefined
}
>
{item.icon}
<Show when={!sidebarCollapsed()}>
<span class="truncate">{item.label}</span>
</Show>
</button>
);
}}
</For>
</div>
</div>
@ -1475,19 +1590,27 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
style="-webkit-overflow-scrolling: touch;"
>
<For each={flatTabs}>
{(tab) => (
<button
type="button"
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
activeTab() === tab.id
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
onClick={() => handleTabChange(tab.id)}
>
{tab.label}
</button>
)}
{(tab) => {
const disabled = () => tab.id !== 'overview' && !isAlertsActive();
return (
<button
type="button"
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
activeTab() === tab.id
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
} ${disabled() ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`}
disabled={disabled()}
onClick={() => {
if (disabled()) return;
handleTabChange(tab.id);
}}
title={disabled() ? 'Activate alerts to configure' : undefined}
>
{tab.label}
</button>
);
}}
</For>
</div>
</div>
@ -1616,6 +1739,7 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
</div>
</div>
</Card>
</div>
</div>
);
}

View file

@ -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<AlertConfig | null>(null);
const [activationState, setActivationState] = createSignal<ActivationStateType | null>(null);
const [activationState, setActivationStateSignal] = createSignal<ActivationStateType | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [activeAlerts, setActiveAlerts] = createSignal<Alert[]>([]);
const [lastError, setLastError] = createSignal<string | null>(null);
const applyActivationState = (state: ActivationStateType | null) => {
setActivationStateSignal(state);
setGlobalActivationState(state);
};
const ensureConfigLoaded = async (): Promise<AlertConfig | null> => {
let current = config();
if (!current) {
await refreshConfig();
current = config();
}
return current;
};
// Refresh config from API
const refreshConfig = async (): Promise<void> => {
try {
@ -17,7 +32,7 @@ const refreshConfig = async (): Promise<void> => {
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<boolean> => {
}
};
const updateActivationState = async (state: ActivationStateType): Promise<boolean> => {
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<boolean> => updateActivationState('pending_review');
const snooze = async (): Promise<boolean> => 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

View file

@ -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<string, { ack: boolean; previousAckTime?: string }>();
let alertsEnabled = isAlertsActivationEnabled();
let lastActiveAlertsPayload: Record<string, Alert> = {};
const applyActiveAlerts = (alertsMap: Record<string, Alert>) => {
// 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<ActivationStateType | null>).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<string, Alert> = {};
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) {

View file

@ -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<string, Alert>) => {
export const getAlertStyles = (
resourceId: string,
activeAlerts: Record<string, Alert>,
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<string,
export const getResourceAlerts = (
resourceId: string,
activeAlerts: Record<string, Alert>,
alertsEnabled: boolean | undefined = isAlertsActivationEnabled(),
): Alert[] => {
if (!alertsEnabled) return [];
return Object.values(activeAlerts).filter((alert) => alert.resourceId === resourceId);
};

View file

@ -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<ActivationState | null>(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';
};

View file

@ -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:-<your-container-id>}"
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

View file

@ -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,

View file

@ -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="<your-pulse-container-id>"
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 <<EOF
# 1) Create or reuse the Pulse monitoring API token
pveum user add pulse-monitor@pam --comment "Pulse monitoring service"
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
pveum user token add pulse-monitor@pam %s --privsep 0
# 2) Install or update pulse-sensor-proxy on the host
curl -sSL "%s/api/install/install-sensor-proxy.sh" | bash -s -- --ctid ${CTID_DISPLAY} --pulse-server "%s"
# 3) Ensure the proxy socket is mounted into this container
NEXT_MP=\$(pct config ${CTID_DISPLAY} | awk '\$1 ~ /^mp[0-9]+:/ && index(\$0, "mp=/mnt/pulse-proxy") {gsub(":", "", \$1); print \$1; exit}')
if [ -z "\$NEXT_MP" ]; then NEXT_MP="mp0"; fi
pct set ${CTID_DISPLAY} -\${NEXT_MP} /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy,replicate=0
pct exec ${CTID_DISPLAY} -- test -S /mnt/pulse-proxy/pulse-sensor-proxy.sock && echo "Socket OK"
EOF
echo "For the simplest experience, run this script on your Proxmox host instead:"
echo " curl -sSL \"%s/api/setup-script?type=pve&host=%s&pulse_url=%s\" | bash"
echo ""
echo "Exiting without error. Re-run after completing the host steps."
exit 0
;;
*)
echo "This script requires Proxmox host tooling (pveum)."
echo ""
echo "Run on your Proxmox host:"
echo " curl -sSL \"%s/api/setup-script?type=pve&host=%s&pulse_url=%s\" | bash"
echo ""
echo "Manual setup steps:"
echo " 1. On Proxmox host, create API token:"
echo " pveum user add pulse-monitor@pam --comment \"Pulse monitoring service\""
echo " pveum aclmod / -user pulse-monitor@pam -role PVEAuditor"
echo " pveum user token add pulse-monitor@pam %s --privsep 0"
echo ""
echo " 2. In Pulse: Settings → Nodes → Add Node (enter token from above)"
echo ""
echo " 3. (Optional) For temperature monitoring on containerized Pulse:"
echo " %s/api/install/install-sensor-proxy.sh --ctid <your-pulse-container-id> --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

View file

@ -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(),

View file

@ -717,7 +717,7 @@ func DefaultSystemSettings() *SystemSettings {
PBSPollingInterval: 60,
PMGPollingInterval: 60,
AutoUpdateEnabled: false,
DiscoveryEnabled: true,
DiscoveryEnabled: false,
DiscoverySubnet: "auto",
DiscoveryConfig: defaultDiscovery,
AllowEmbedding: false,

View file

@ -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()
}

50
scripts/codex-router.sh Executable file
View file

@ -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 <prompt>" >&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}"

View file

@ -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

View file

@ -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"