mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Improve temperature proxy workflow
This commit is contained in:
parent
eca1f272ca
commit
f9341ae1fc
17 changed files with 1217 additions and 34 deletions
|
|
@ -125,6 +125,12 @@ Your infrastructure data is yours alone.
|
|||
| Kubernetes/Helm | Clusters needing HA, ingress, GitOps | Kubernetes cluster with storage class + Helm 3 | [docs/KUBERNETES.md](docs/KUBERNETES.md) |
|
||||
| Bare metal/systemd | Minimal installs or environments without containers | Go-supported Linux host, systemd access | [docs/INSTALL.md](docs/INSTALL.md) and `scripts/build-release.sh` |
|
||||
|
||||
### Bootstrap vs Node Setup
|
||||
|
||||
- Run `install.sh` on the Proxmox host to create the Pulse LXC and (optionally) install `pulse-sensor-proxy`. The installer records the decision in `/etc/pulse/install_summary.json`.
|
||||
- After Pulse is running, head to **Settings → Nodes** and run the Quick Setup script per PVE/PBS/PMG instance. The script reads the summary so it can skip redundant prompts when the host proxy already exists, and it only asks you to deploy HTTPS proxies for remote/standalone nodes.
|
||||
- If you skipped the proxy during bootstrap, the Quick Setup script (and the Settings UI) now remind you and provide a copy/paste HTTPS installer command so you can enable temperatures later.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@ When Pulse cannot share the `/run/pulse-sensor-proxy` socket (for example, you r
|
|||
|
||||
This HTTP path complements the socket path—you can run both simultaneously. Containerised Pulse stacks still need the socket for their own host, while HTTP mode covers every additional Proxmox node on the LAN or across sites.
|
||||
|
||||
Pulse now isolates transport failures per node: when a proxy reports that a node is invalid or unreachable, Pulse cools down polling for that node only instead of tearing down the shared socket. You will see a cooldown note in the diagnostics card if a node keeps failing; fix the proxy or disable temperature monitoring for that node to resume collection.
|
||||
|
||||
> **Tip:** When Pulse is running inside a container and temperatures are blocked, open **Settings → Nodes → Edit node → Temperature monitoring**. The UI now offers a one-click “Generate HTTPS proxy command” button that produces the exact `install-sensor-proxy.sh --standalone --http-mode --pulse-server …` command for that node, so you can copy it straight to the host shell without rebuilding the instructions manually.
|
||||
|
||||
---
|
||||
|
||||
## Disable Temperature Monitoring
|
||||
|
|
@ -359,6 +363,8 @@ When run on a Proxmox host with Pulse in an LXC container:
|
|||
5. Sets up SSH keys and cluster discovery
|
||||
6. **Fully turnkey - no manual steps required!**
|
||||
|
||||
> **Note:** The main `install.sh` already installs the host-side proxy when you opt-in during bootstrap, so the Quick Setup script simply verifies it and moves on—you won’t be prompted a second time. Remote/standalone nodes still prompt to deploy their own HTTPS proxy.
|
||||
|
||||
### For Docker Deployments (Manual Steps Required)
|
||||
|
||||
When Pulse runs in Docker, the setup script will show you manual steps:
|
||||
|
|
|
|||
|
|
@ -10,8 +10,15 @@ type NodeConfigWithStatus = NodeConfig & {
|
|||
export interface TemperatureTransportInfo {
|
||||
httpMap: Record<string, { reachable: boolean; error?: string; url?: string }>;
|
||||
socketStatus: 'healthy' | 'error' | 'missing';
|
||||
socketCooldowns?: Record<string, TemperatureSocketCooldownInfo>;
|
||||
}
|
||||
|
||||
type TemperatureSocketCooldownInfo = {
|
||||
secondsRemaining?: number;
|
||||
until?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
interface PveNodesTableProps {
|
||||
nodes: NodeConfigWithStatus[];
|
||||
stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
|
||||
|
|
@ -30,6 +37,39 @@ type TemperatureTransportBadge = {
|
|||
description?: string;
|
||||
};
|
||||
|
||||
const normalizeHostKey = (value?: string) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
let result = value.trim().toLowerCase();
|
||||
if (!result) {
|
||||
return '';
|
||||
}
|
||||
result = result.replace(/^https?:\/\//, '');
|
||||
const slashIndex = result.indexOf('/');
|
||||
if (slashIndex !== -1) {
|
||||
result = result.slice(0, slashIndex);
|
||||
}
|
||||
const colonIndex = result.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
result = result.slice(0, colonIndex);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatCooldown = (seconds?: number) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return '0s';
|
||||
}
|
||||
if (seconds >= 3600) {
|
||||
return `${Math.round(seconds / 3600)}h`;
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
return `${Math.round(seconds)}s`;
|
||||
};
|
||||
|
||||
const STATUS_META: Record<string, StatusMeta> = {
|
||||
online: {
|
||||
dotClass: 'bg-green-500',
|
||||
|
|
@ -65,6 +105,11 @@ const resolveTemperatureTransport = (
|
|||
): TemperatureTransportBadge => {
|
||||
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
|
||||
const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
|
||||
const nodeKey = normalizeHostKey(node.name);
|
||||
const hostKey = normalizeHostKey(node.host);
|
||||
const socketCooldownEntry =
|
||||
(nodeKey && info?.socketCooldowns?.[nodeKey]) ||
|
||||
(hostKey && info?.socketCooldowns?.[hostKey]);
|
||||
if (!monitoringEnabled) {
|
||||
return {
|
||||
label: 'Temp disabled',
|
||||
|
|
@ -78,11 +123,21 @@ const resolveTemperatureTransport = (
|
|||
};
|
||||
}
|
||||
|
||||
const key = (node.name || '').toLowerCase();
|
||||
const httpEntry = info?.httpMap?.[key];
|
||||
const key = nodeKey;
|
||||
const httpEntry = key ? info?.httpMap?.[key] : undefined;
|
||||
const socketStatus = info?.socketStatus;
|
||||
|
||||
const buildSocketBadge = (): TemperatureTransportBadge => {
|
||||
if (socketCooldownEntry) {
|
||||
const retryText = `Retrying in ${formatCooldown(socketCooldownEntry.secondsRemaining)}`;
|
||||
return {
|
||||
label: 'Socket cooldown',
|
||||
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
|
||||
description: socketCooldownEntry.lastError
|
||||
? `${socketCooldownEntry.lastError} (${retryText})`
|
||||
: retryText,
|
||||
};
|
||||
}
|
||||
if (socketStatus === 'error') {
|
||||
return {
|
||||
label: 'Socket error',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, Show, For, createSignal, createEffect } from 'solid-js';
|
||||
import { Component, Show, For, createSignal, createEffect, createMemo } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
|
|
@ -6,6 +6,7 @@ import { copyToClipboard } from '@/utils/clipboard';
|
|||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { getPulseBaseUrl } from '@/utils/url';
|
||||
import { NodesAPI } from '@/api/nodes';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import {
|
||||
formField,
|
||||
|
|
@ -33,6 +34,12 @@ interface NodeModalProps {
|
|||
onToggleTemperatureMonitoring?: (enabled: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type TemperatureTransportDetail = {
|
||||
tone: 'info' | 'success' | 'warning' | 'danger';
|
||||
message: string;
|
||||
disable?: boolean;
|
||||
};
|
||||
|
||||
const deriveNameFromHost = (host: string): string => {
|
||||
let value = host.trim();
|
||||
if (!value) {
|
||||
|
|
@ -85,9 +92,97 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
const [quickSetupCommand, setQuickSetupCommand] = createSignal('');
|
||||
const [quickSetupToken, setQuickSetupToken] = createSignal('');
|
||||
const [quickSetupExpiry, setQuickSetupExpiry] = createSignal<number | null>(null);
|
||||
const [proxyInstallCommand, setProxyInstallCommand] = createSignal('');
|
||||
const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false);
|
||||
const [proxyCommandError, setProxyCommandError] = createSignal<string | null>(null);
|
||||
const showTemperatureMonitoringSection = () =>
|
||||
typeof props.temperatureMonitoringEnabled === 'boolean';
|
||||
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
|
||||
const temperatureTransportDetail = createMemo<TemperatureTransportDetail | null>(() => {
|
||||
const transport = props.editingNode?.temperatureTransport;
|
||||
if (!transport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (transport.toLowerCase()) {
|
||||
case 'socket-proxy':
|
||||
return {
|
||||
tone: 'success',
|
||||
message: 'Temperatures flow through the host sensor proxy mounted at /run/pulse-sensor-proxy.',
|
||||
};
|
||||
case 'https-proxy':
|
||||
return {
|
||||
tone: 'success',
|
||||
message: 'Temperatures are collected via the HTTPS proxy registered for this node.',
|
||||
};
|
||||
case 'ssh-blocked':
|
||||
return {
|
||||
tone: 'danger',
|
||||
disable: true,
|
||||
message:
|
||||
'Pulse is running in a container without the pulse-sensor-proxy bind mount. Install the proxy on the host or register an HTTPS proxy before enabling temperatures.',
|
||||
};
|
||||
case 'ssh':
|
||||
return {
|
||||
tone: 'info',
|
||||
message: 'Pulse will SSH directly into this node for temperature collection.',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const temperatureToggleDisabled = () =>
|
||||
props.temperatureMonitoringLocked ||
|
||||
props.savingTemperatureSetting ||
|
||||
Boolean(temperatureTransportDetail()?.disable);
|
||||
const temperatureTransportMessageClass = () => {
|
||||
const tone = temperatureTransportDetail()?.tone ?? 'info';
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'text-green-600 dark:text-green-300';
|
||||
case 'warning':
|
||||
return 'text-amber-600 dark:text-amber-300';
|
||||
case 'danger':
|
||||
return 'text-red-600 dark:text-red-300';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
const temperatureToggleTitle = () => {
|
||||
const detail = temperatureTransportDetail();
|
||||
if (detail?.disable) {
|
||||
return detail.message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const shouldOfferProxyCommand = () =>
|
||||
props.nodeType === 'pve' && Boolean(props.editingNode?.id) && Boolean(temperatureTransportDetail()?.disable);
|
||||
const fetchProxyInstallCommand = async () => {
|
||||
if (loadingProxyCommand()) {
|
||||
return;
|
||||
}
|
||||
setLoadingProxyCommand(true);
|
||||
setProxyCommandError(null);
|
||||
setProxyInstallCommand('');
|
||||
try {
|
||||
const nodeName = props.editingNode?.name ? encodeURIComponent(props.editingNode!.name) : '';
|
||||
const query = nodeName ? `?node=${nodeName}` : '';
|
||||
const response = await apiFetchJSON(`/api/temperature-proxy/install-command${query}`);
|
||||
if (!response || typeof response.command !== 'string') {
|
||||
throw new Error('Proxy installer command unavailable');
|
||||
}
|
||||
setProxyInstallCommand(response.command);
|
||||
showSuccess('HTTPS proxy command ready', 2000);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to generate HTTPS proxy command';
|
||||
setProxyCommandError(message);
|
||||
showError(message);
|
||||
logger.error('Failed to load proxy install command', error);
|
||||
} finally {
|
||||
setLoadingProxyCommand(false);
|
||||
}
|
||||
};
|
||||
const quickSetupExpiryLabel = () => {
|
||||
const expiry = quickSetupExpiry();
|
||||
if (!expiry) {
|
||||
|
|
@ -1781,7 +1876,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
onChange={(event) => {
|
||||
props.onToggleTemperatureMonitoring?.(event.currentTarget.checked);
|
||||
}}
|
||||
disabled={props.temperatureMonitoringLocked || props.savingTemperatureSetting}
|
||||
disabled={temperatureToggleDisabled()}
|
||||
title={temperatureToggleTitle()}
|
||||
ariaLabel={
|
||||
temperatureMonitoringEnabledValue()
|
||||
? 'Disable temperature monitoring'
|
||||
|
|
@ -1789,6 +1885,58 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<Show when={temperatureTransportDetail()}>
|
||||
<p class={`mt-2 text-xs ${temperatureTransportMessageClass()}`}>
|
||||
{temperatureTransportDetail()?.message}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={shouldOfferProxyCommand()}>
|
||||
<div class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-800 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-100 space-y-2">
|
||||
<div class="font-semibold">Install HTTPS proxy on this host</div>
|
||||
<div>Generate a one-line installer command to run on the Proxmox host:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700 disabled:opacity-70"
|
||||
onClick={() => void fetchProxyInstallCommand()}
|
||||
disabled={loadingProxyCommand()}
|
||||
>
|
||||
{loadingProxyCommand() ? 'Generating…' : 'Generate command'}
|
||||
</button>
|
||||
<a
|
||||
href="/api/install/install-sensor-proxy.sh"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="rounded border border-blue-400 px-2 py-1 text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:border-blue-300 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
Download installer script
|
||||
</a>
|
||||
</div>
|
||||
<Show when={proxyCommandError()}>
|
||||
<p class="text-xs text-red-600 dark:text-red-300">
|
||||
{proxyCommandError()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={proxyInstallCommand()}>
|
||||
<pre class="overflow-x-auto rounded bg-white/70 px-2 py-1 font-mono text-[0.65rem] text-gray-800 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
{proxyInstallCommand()}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
const command = proxyInstallCommand();
|
||||
if (command) {
|
||||
void copyToClipboard(command);
|
||||
showSuccess('Proxy installer command copied');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy command
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!temperatureMonitoringEnabledValue()}>
|
||||
<p class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
Pulse will skip SSH temperature polling for this node. Existing dashboard readings will stop refreshing.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getPulsePort, getPulseWebSocketUrl } from '@/utils/url';
|
|||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
apiFetch,
|
||||
apiFetchJSON,
|
||||
clearApiToken as clearApiClientToken,
|
||||
getApiToken as getApiClientToken,
|
||||
setApiToken as setApiClientToken,
|
||||
|
|
@ -147,6 +148,38 @@ interface TemperatureProxyControlPlaneState {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
interface TemperatureProxySocketHost {
|
||||
node?: string;
|
||||
host?: string;
|
||||
cooldownUntil?: string;
|
||||
secondsRemaining?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
type TemperatureSocketCooldownInfo = {
|
||||
secondsRemaining?: number;
|
||||
until?: string;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
interface HostProxySummary {
|
||||
requested?: boolean;
|
||||
installed?: boolean;
|
||||
hostSocketPresent?: boolean;
|
||||
containerSocketPresent?: boolean | null;
|
||||
lastUpdated?: string;
|
||||
ctid?: string;
|
||||
}
|
||||
|
||||
interface HostProxyStatusResponse {
|
||||
hostSocketPresent?: boolean;
|
||||
containerSocketPresent?: boolean;
|
||||
summary?: HostProxySummary | null;
|
||||
reinstallCommand?: string;
|
||||
installerURL?: string;
|
||||
lastChecked?: string;
|
||||
}
|
||||
|
||||
interface TemperatureProxyDiagnostic {
|
||||
legacySSHDetected: boolean;
|
||||
recommendProxyUpgrade: boolean;
|
||||
|
|
@ -165,6 +198,7 @@ interface TemperatureProxyDiagnostic {
|
|||
httpProxies?: TemperatureProxyHTTPStatus[];
|
||||
controlPlaneEnabled?: boolean;
|
||||
controlPlaneStates?: TemperatureProxyControlPlaneState[];
|
||||
socketHostCooldowns?: TemperatureProxySocketHost[];
|
||||
}
|
||||
|
||||
interface APITokenSummary {
|
||||
|
|
@ -588,6 +622,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
|
||||
const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true);
|
||||
const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false);
|
||||
const [hostProxyStatus, setHostProxyStatus] = createSignal<HostProxyStatusResponse | null>(null);
|
||||
const temperatureMonitoringLocked = () =>
|
||||
Boolean(
|
||||
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
|
||||
|
|
@ -850,6 +885,26 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return `${Math.floor(seconds)}s`;
|
||||
};
|
||||
|
||||
const normalizeHostKey = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
let result = value.trim().toLowerCase();
|
||||
if (!result) {
|
||||
return '';
|
||||
}
|
||||
result = result.replace(/^https?:\/\//, '');
|
||||
const slashIndex = result.indexOf('/');
|
||||
if (slashIndex !== -1) {
|
||||
result = result.slice(0, slashIndex);
|
||||
}
|
||||
const colonIndex = result.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
result = result.slice(0, colonIndex);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
|
||||
if (!diag?.temperatureProxy) {
|
||||
return;
|
||||
|
|
@ -872,6 +927,15 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
showWarning(`Temperature proxy control plane is behind on: ${names}`);
|
||||
}
|
||||
}
|
||||
if (diag.temperatureProxy.socketHostCooldowns) {
|
||||
const cooling = (diag.temperatureProxy.socketHostCooldowns as TemperatureProxySocketHost[]).filter(
|
||||
(entry) => entry && (entry.node || entry.host),
|
||||
);
|
||||
if (cooling.length > 0) {
|
||||
const hosts = cooling.map((entry) => entry.node || entry.host || 'proxy').join(', ');
|
||||
showWarning(`Temperature proxy is cooling down the following hosts: ${hosts}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const temperatureTransportInfo = createMemo<TemperatureTransportInfo | null>(() => {
|
||||
|
|
@ -901,7 +965,20 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
: diag.temperatureProxy.socketFound
|
||||
? 'error'
|
||||
: 'missing';
|
||||
return { httpMap, socketStatus };
|
||||
const cooldowns: Record<string, TemperatureSocketCooldownInfo> = {};
|
||||
const socketHosts = diag.temperatureProxy.socketHostCooldowns || [];
|
||||
(socketHosts as TemperatureProxySocketHost[]).forEach((entry) => {
|
||||
const key = normalizeHostKey(entry.node) || normalizeHostKey(entry.host);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
cooldowns[key] = {
|
||||
secondsRemaining: entry.secondsRemaining,
|
||||
until: entry.cooldownUntil,
|
||||
lastError: entry.lastError || undefined,
|
||||
};
|
||||
});
|
||||
return { httpMap, socketStatus, socketCooldowns: cooldowns };
|
||||
});
|
||||
|
||||
const proxyNodeChecksSupported = createMemo(() => {
|
||||
|
|
@ -921,6 +998,15 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const diag = await response.json();
|
||||
setDiagnosticsData(diag);
|
||||
emitTemperatureProxyWarnings(diag);
|
||||
if (diag?.temperatureProxy?.hostProxySummary) {
|
||||
setHostProxyStatus({
|
||||
hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound),
|
||||
containerSocketPresent: Boolean(
|
||||
diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? false,
|
||||
),
|
||||
summary: diag.temperatureProxy?.hostProxySummary ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch diagnostics', err);
|
||||
showError('Failed to run diagnostics');
|
||||
|
|
@ -929,6 +1015,40 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const refreshHostProxyStatus = async (notify = false) => {
|
||||
try {
|
||||
const status = (await apiFetchJSON(
|
||||
'/api/temperature-proxy/host-status',
|
||||
)) as HostProxyStatusResponse;
|
||||
setHostProxyStatus(status);
|
||||
if (notify) {
|
||||
showSuccess('Host proxy status refreshed', 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh host proxy status', err);
|
||||
showError('Failed to refresh host proxy status');
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const shouldPoll = currentTab() === 'proxmox' || currentTab() === 'diagnostics';
|
||||
if (!shouldPoll) {
|
||||
return;
|
||||
}
|
||||
void runDiagnostics();
|
||||
void refreshHostProxyStatus(false);
|
||||
const intervalId = window.setInterval(() => {
|
||||
void runDiagnostics();
|
||||
void refreshHostProxyStatus(false);
|
||||
}, 60000);
|
||||
onCleanup(() => {
|
||||
window.clearInterval(intervalId);
|
||||
});
|
||||
});
|
||||
|
||||
const handleRegisterProxyNodes = async () => {
|
||||
if (proxyActionLoading()) return;
|
||||
setProxyActionLoading('register-nodes');
|
||||
|
|
@ -1548,7 +1668,11 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update temperature monitoring setting', error);
|
||||
notificationStore.error('Failed to update temperature monitoring setting');
|
||||
notificationStore.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to update temperature monitoring setting',
|
||||
);
|
||||
setTemperatureMonitoringEnabled(previous);
|
||||
} finally {
|
||||
setSavingTemperatureSetting(false);
|
||||
|
|
@ -1589,7 +1713,11 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update node temperature monitoring setting', error);
|
||||
notificationStore.error('Failed to update temperature monitoring setting');
|
||||
notificationStore.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to update temperature monitoring setting',
|
||||
);
|
||||
// Revert on error
|
||||
setNodes(
|
||||
nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)),
|
||||
|
|
@ -5318,11 +5446,11 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
|
||||
}
|
||||
>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">
|
||||
HTTPS proxies
|
||||
|
|
@ -5358,7 +5486,61 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="mt-2 text-[0.65rem] text-blue-700 dark:text-blue-300">
|
||||
Learn more:{" "}
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/blob/main/docs/TEMPERATURE_MONITORING.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="underline hover:text-blue-500"
|
||||
>
|
||||
Temperature Monitoring docs
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
temp().socketHostCooldowns &&
|
||||
(temp().socketHostCooldowns as TemperatureProxySocketHost[]).length > 0
|
||||
}
|
||||
>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">
|
||||
Socket cooldowns
|
||||
</div>
|
||||
<For each={temp().socketHostCooldowns || []}>
|
||||
{(entry) => (
|
||||
<div class="rounded border border-amber-200 dark:border-amber-700 px-2 py-1.5 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{entry.node || entry.host || 'Host'}
|
||||
</div>
|
||||
<Show when={entry.cooldownUntil}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Until {entry.cooldownUntil}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded text-white text-xs bg-amber-500">
|
||||
Cooling
|
||||
</span>
|
||||
</div>
|
||||
<Show when={typeof entry.secondsRemaining === 'number'}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Retrying in ~{formatUptime(entry.secondsRemaining || 0)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={entry.lastError}>
|
||||
<div class="text-[0.65rem] text-red-500">
|
||||
{entry.lastError}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={proxyNodeChecksSupported()}
|
||||
fallback={
|
||||
|
|
@ -5386,6 +5568,68 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
: 'Check proxy nodes'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={hostProxyStatus()}>
|
||||
{(status) => (
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pulse host proxy
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs rounded bg-blue-600 text-white px-2 py-1 hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={() => void refreshHostProxyStatus(true)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-y-1">
|
||||
<div>Requested</div>
|
||||
<div>{status().summary?.requested ? 'Yes' : 'No'}</div>
|
||||
<div>Installed</div>
|
||||
<div>{status().summary?.installed ? 'Yes' : 'No'}</div>
|
||||
<div>Host socket</div>
|
||||
<div>{status().hostSocketPresent ? 'Present' : 'Missing'}</div>
|
||||
<div>Container socket</div>
|
||||
<div>{status().containerSocketPresent ? 'Present' : 'Missing'}</div>
|
||||
</div>
|
||||
<Show when={status().reinstallCommand}>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-blue-600 text-white px-2 py-1 hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
const command = status().reinstallCommand;
|
||||
if (command) {
|
||||
void copyToClipboard(command);
|
||||
showSuccess('Host proxy command copied', 2000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy reinstall command
|
||||
</button>
|
||||
<Show when={status().installerURL}>
|
||||
{(url) => (
|
||||
<a
|
||||
href={url()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="rounded border border-gray-300 px-2 py-1 text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
Download installer script
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={status().summary?.lastUpdated}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Summary updated {status().summary?.lastUpdated}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
|
|
@ -6222,6 +6466,30 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return sanitizedEntry;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(proxyDiag.socketHostCooldowns)) {
|
||||
proxyDiag.socketHostCooldowns = (
|
||||
proxyDiag.socketHostCooldowns as Array<Record<string, unknown>>
|
||||
).map((entry) => ({
|
||||
node: sanitizeHostname(
|
||||
typeof entry.node === 'string' ? (entry.node as string) : undefined,
|
||||
) as string,
|
||||
host: sanitizeHostname(
|
||||
typeof entry.host === 'string' ? (entry.host as string) : undefined,
|
||||
) as string,
|
||||
cooldownUntil:
|
||||
typeof entry.cooldownUntil === 'string'
|
||||
? (entry.cooldownUntil as string)
|
||||
: undefined,
|
||||
secondsRemaining:
|
||||
typeof entry.secondsRemaining === 'number'
|
||||
? (entry.secondsRemaining as number)
|
||||
: undefined,
|
||||
lastError:
|
||||
typeof entry.lastError === 'string'
|
||||
? sanitizeText(entry.lastError as string) ?? (entry.lastError as string)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') {
|
||||
|
|
|
|||
55
install.sh
55
install.sh
|
|
@ -32,6 +32,9 @@ CURRENT_INSTALL_CTID=""
|
|||
CONTAINER_CREATED_FOR_CLEANUP=false
|
||||
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
|
||||
DETECTED_CTID=""
|
||||
INSTALL_SUMMARY_FILE="/etc/pulse/install_summary.json"
|
||||
HOST_PROXY_REQUESTED=false
|
||||
HOST_PROXY_INSTALLED=false
|
||||
|
||||
DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst"
|
||||
DEBIAN_TEMPLATE=""
|
||||
|
|
@ -1513,6 +1516,7 @@ fi'; then
|
|||
case "$PROXY_MODE" in
|
||||
yes)
|
||||
install_proxy=true
|
||||
HOST_PROXY_REQUESTED=true
|
||||
;;
|
||||
no)
|
||||
install_proxy=false
|
||||
|
|
@ -1521,12 +1525,14 @@ fi'; then
|
|||
# Auto-detect: install if Docker is present
|
||||
if [[ "$docker_in_container" == "true" ]]; then
|
||||
install_proxy=true
|
||||
HOST_PROXY_REQUESTED=true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Empty/unset - reuse earlier user choice (defaults handled already)
|
||||
if [[ "$PROXY_USER_CHOICE" == "yes" ]]; then
|
||||
install_proxy=true
|
||||
HOST_PROXY_REQUESTED=true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1676,6 +1682,7 @@ fi'; then
|
|||
fi
|
||||
|
||||
print_success "Temperature proxy is healthy and ready"
|
||||
HOST_PROXY_INSTALLED=true
|
||||
fi # End of health checks
|
||||
|
||||
# Clean up temporary binary if it was copied
|
||||
|
|
@ -2879,6 +2886,43 @@ create_marker_file() {
|
|||
touch ~/.pulse 2>/dev/null || true
|
||||
}
|
||||
|
||||
write_install_summary() {
|
||||
local summary_dir="/etc/pulse"
|
||||
mkdir -p "$summary_dir"
|
||||
|
||||
local host_socket="false"
|
||||
if [[ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]]; then
|
||||
host_socket="true"
|
||||
fi
|
||||
|
||||
local container_socket="null"
|
||||
if [[ -n "${CTID:-}" ]] && command -v pct >/dev/null 2>&1; then
|
||||
if pct exec "$CTID" -- test -S /mnt/pulse-proxy/pulse-sensor-proxy.sock >/dev/null 2>&1; then
|
||||
container_socket="true"
|
||||
else
|
||||
container_socket="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
local timestamp=""
|
||||
if command -v date >/dev/null 2>&1; then
|
||||
timestamp=$(date -Is 2>/dev/null || date)
|
||||
fi
|
||||
|
||||
cat > "$INSTALL_SUMMARY_FILE" <<EOF
|
||||
{
|
||||
"generatedAt": "$timestamp",
|
||||
"ctid": "${CTID:-}",
|
||||
"proxy": {
|
||||
"requested": ${HOST_PROXY_REQUESTED},
|
||||
"installed": ${HOST_PROXY_INSTALLED},
|
||||
"hostSocketPresent": $host_socket,
|
||||
"containerSocketPresent": $container_socket
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
print_completion() {
|
||||
local IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
|
|
@ -2907,6 +2951,15 @@ print_completion() {
|
|||
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"
|
||||
|
||||
local proxy_status="Not installed"
|
||||
if [[ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]]; then
|
||||
proxy_status="Installed (host socket present)"
|
||||
elif [[ "$HOST_PROXY_REQUESTED" == true ]]; then
|
||||
proxy_status="Install requested (pending)"
|
||||
fi
|
||||
echo
|
||||
echo -e "${YELLOW}Temperature proxy:${NC} ${proxy_status}"
|
||||
|
||||
if [[ "$IN_CONTAINER" == "true" ]]; then
|
||||
local proxy_ctid="${DETECTED_CTID:-<your-container-id>}"
|
||||
echo
|
||||
|
|
@ -2949,6 +3002,8 @@ print_completion() {
|
|||
fi
|
||||
fi
|
||||
|
||||
write_install_summary
|
||||
|
||||
echo
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -455,6 +455,19 @@ func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, so
|
|||
return temperatureTransportSSHFallback
|
||||
}
|
||||
|
||||
func ensureTemperatureTransportAvailable(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
transport := determineTemperatureTransport(true, proxyURL, proxyToken, socketAvailable, containerSSHBlocked)
|
||||
if transport == temperatureTransportSSHBlocked {
|
||||
return fmt.Errorf("pulse is running in a container without access to pulse-sensor-proxy. Install the host proxy or register an HTTPS-mode sensor proxy for this node before enabling temperature monitoring")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isContainerSSHRestricted() bool {
|
||||
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
|
||||
if !isContainer {
|
||||
|
|
@ -1186,8 +1199,18 @@ func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
|
||||
containerSSHBlocked := isContainerSSHRestricted()
|
||||
|
||||
// Add to appropriate list
|
||||
if req.Type == "pve" {
|
||||
if req.TemperatureMonitoringEnabled != nil && *req.TemperatureMonitoringEnabled {
|
||||
if err := ensureTemperatureTransportAvailable(true, "", "", socketAvailable, containerSSHBlocked); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
||||
req.User = normalizePVEUser(req.User)
|
||||
}
|
||||
|
|
@ -1844,6 +1867,9 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
|
|||
Interface("temperatureMonitoringEnabled", req.TemperatureMonitoringEnabled).
|
||||
Msg("Received node update request")
|
||||
|
||||
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
|
||||
containerSSHBlocked := isContainerSSHRestricted()
|
||||
|
||||
// Parse node ID
|
||||
parts := strings.Split(nodeID, "-")
|
||||
if len(parts) != 2 {
|
||||
|
|
@ -1862,6 +1888,13 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
|
|||
if nodeType == "pve" && index < len(h.config.PVEInstances) {
|
||||
pve := &h.config.PVEInstances[index]
|
||||
|
||||
if req.TemperatureMonitoringEnabled != nil && *req.TemperatureMonitoringEnabled {
|
||||
if err := ensureTemperatureTransportAvailable(true, pve.TemperatureProxyURL, pve.TemperatureProxyToken, socketAvailable, containerSSHBlocked); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Only update name if provided
|
||||
if req.Name != "" {
|
||||
pve.Name = req.Name
|
||||
|
|
@ -3991,6 +4024,14 @@ echo "Temperature Monitoring Setup (Optional)"
|
|||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ "$SKIP_TEMPERATURE_PROMPT" != true ]; then
|
||||
if [ "${SUMMARY_PROXY_INSTALLED:-false}" != "true" ]; then
|
||||
echo "ℹ️ During the initial Pulse installation the host-side proxy was not installed."
|
||||
echo " Enabling it now lets the Pulse container read sensors securely via the host."
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# SSH public keys embedded from Pulse server
|
||||
# Proxy key: used for ProxyJump (unrestricted but limited to port forwarding)
|
||||
# Sensors key: used for temperature collection (restricted to sensors -j command)
|
||||
|
|
@ -4003,6 +4044,12 @@ TEMP_MONITORING_AVAILABLE=true
|
|||
MIN_PROXY_VERSION="%s"
|
||||
PULSE_VERSION_ENDPOINT="%s/api/version"
|
||||
STANDALONE_PROXY_DEPLOYED=false
|
||||
SKIP_TEMPERATURE_PROMPT=false
|
||||
|
||||
if [ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]; then
|
||||
TEMPERATURE_ENABLED=true
|
||||
SKIP_TEMPERATURE_PROMPT=true
|
||||
fi
|
||||
|
||||
version_ge() {
|
||||
if command -v dpkg >/dev/null 2>&1; then
|
||||
|
|
@ -4064,7 +4111,47 @@ fi
|
|||
# Determine if this node is standalone (not joined to a cluster)
|
||||
IS_STANDALONE_NODE=false
|
||||
if ! command -v pvecm >/dev/null 2>&1 || ! pvecm status >/dev/null 2>&1; then
|
||||
IS_STANDALONE_NODE=true
|
||||
IS_STANDALONE_NODE=true
|
||||
fi
|
||||
|
||||
INSTALL_SUMMARY_FILE="/etc/pulse/install_summary.json"
|
||||
SUMMARY_PROXY_REQUESTED="false"
|
||||
SUMMARY_PROXY_INSTALLED="false"
|
||||
SUMMARY_PROXY_SOCKET="false"
|
||||
if [ -f "$INSTALL_SUMMARY_FILE" ]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if SUMMARY_EVAL=$(python3 <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
path = Path("/etc/pulse/install_summary.json")
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
proxy = data.get("proxy") or {}
|
||||
def emit(key, value):
|
||||
print(f"{key}={'true' if value else 'false'}")
|
||||
emit("SUMMARY_PROXY_REQUESTED", proxy.get("requested"))
|
||||
emit("SUMMARY_PROXY_INSTALLED", proxy.get("installed"))
|
||||
emit("SUMMARY_PROXY_SOCKET", proxy.get("hostSocketPresent"))
|
||||
PY
|
||||
); then
|
||||
eval "$SUMMARY_EVAL"
|
||||
fi
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
if SUMMARY_EVAL=$(jq -r '
|
||||
[
|
||||
"\(.proxy.requested // false)",
|
||||
"\(.proxy.installed // false)",
|
||||
"\(.proxy.hostSocketPresent // false)"
|
||||
] | @tsv
|
||||
' "$INSTALL_SUMMARY_FILE" 2>/dev/null); then
|
||||
read -r requested installed host_socket <<<"$SUMMARY_EVAL"
|
||||
SUMMARY_PROXY_REQUESTED=$requested
|
||||
SUMMARY_PROXY_INSTALLED=$installed
|
||||
SUMMARY_PROXY_SOCKET=$host_socket
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Track whether temperature monitoring can work (may be disabled by checks above)
|
||||
|
|
@ -4095,8 +4182,8 @@ if [ "$PULSE_IS_CONTAINERIZED" = true ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# If Pulse is containerized, try to install proxy automatically
|
||||
if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then
|
||||
# If Pulse is containerized, try to install proxy automatically (unless already present)
|
||||
if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ] && [ "$SKIP_TEMPERATURE_PROMPT" != true ]; then
|
||||
# Try automatic installation - proxy keeps SSH credentials on the host for security
|
||||
if true; then
|
||||
# Download installer script from Pulse server
|
||||
|
|
@ -4154,6 +4241,10 @@ if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true
|
|||
fi
|
||||
|
||||
# Note: Mount configuration and container restart are handled by the installer
|
||||
if [ "$TEMP_MONITORING_AVAILABLE" = true ]; then
|
||||
TEMPERATURE_ENABLED=true
|
||||
SKIP_TEMPERATURE_PROMPT=true
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Proxy installation had issues - you may need to configure manually"
|
||||
|
|
@ -4196,7 +4287,11 @@ if [ -n "$SSH_PUBLIC_KEY" ] && [ -f /root/.ssh/authorized_keys ]; then
|
|||
fi
|
||||
|
||||
# Single temperature monitoring prompt
|
||||
if [ "$SSH_ALREADY_CONFIGURED" = true ]; then
|
||||
if [ "$SKIP_TEMPERATURE_PROMPT" = true ]; then
|
||||
echo "Temperature monitoring is already configured via pulse-sensor-proxy on this host."
|
||||
echo "Pulse will collect temperatures as soon as you finish the setup wizard."
|
||||
echo ""
|
||||
elif [ "$SSH_ALREADY_CONFIGURED" = true ]; then
|
||||
TEMPERATURE_ENABLED=true
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Temperature monitoring is currently ENABLED"
|
||||
|
|
|
|||
|
|
@ -56,3 +56,28 @@ func TestDetermineTemperatureTransport(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTemperatureTransportAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("allows socket transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ensureTemperatureTransportAvailable(true, "", "", true, true); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blocks container without proxy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ensureTemperatureTransportAvailable(true, "", "", false, true); err == nil {
|
||||
t.Fatal("expected error when no transport is available")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores disabled state", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ensureTemperatureTransportAvailable(false, "", "", false, true); err != nil {
|
||||
t.Fatalf("expected nil error when not enabling transport, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
55
internal/api/config_handlers_transport_guard_test.go
Normal file
55
internal/api/config_handlers_transport_guard_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleAddNodeRejectsTempsWithoutTransport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PULSE_DOCKER", "true")
|
||||
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
body := bytes.NewBufferString(`{"type":"pve","name":"node-a","host":"pve-a.local","user":"root@pam","password":"secret","temperatureMonitoringEnabled":true}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/nodes", body)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleAddNode(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "proxy") {
|
||||
t.Fatalf("expected proxy error, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateNodeRejectsTempsWithoutTransport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PULSE_DOCKER", "true")
|
||||
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir}
|
||||
cfg.PVEInstances = []config.PVEInstance{{
|
||||
Name: "pve-a",
|
||||
Host: "https://pve-a.local:8006",
|
||||
}}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
body := bytes.NewBufferString(`{"temperatureMonitoringEnabled":true}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/config/nodes/pve-0", body)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateNode(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "proxy") {
|
||||
t.Fatalf("expected proxy error, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +249,8 @@ type TemperatureProxyDiagnostic struct {
|
|||
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"`
|
||||
ControlPlaneEnabled bool `json:"controlPlaneEnabled"`
|
||||
ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"`
|
||||
SocketHostCooldowns []TemperatureProxySocketHost `json:"socketHostCooldowns,omitempty"`
|
||||
HostProxySummary *HostProxySummary `json:"hostProxySummary,omitempty"`
|
||||
}
|
||||
|
||||
type TemperatureProxyControlPlaneState struct {
|
||||
|
|
@ -266,6 +268,23 @@ type TemperatureProxyHTTPStatus struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TemperatureProxySocketHost struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
CooldownUntil string `json:"cooldownUntil,omitempty"`
|
||||
SecondsRemaining int `json:"secondsRemaining,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type HostProxySummary struct {
|
||||
Requested bool `json:"requested"`
|
||||
Installed bool `json:"installed"`
|
||||
HostSocketPresent bool `json:"hostSocketPresent"`
|
||||
ContainerSocketPresent *bool `json:"containerSocketPresent,omitempty"`
|
||||
LastUpdated string `json:"lastUpdated,omitempty"`
|
||||
CTID string `json:"ctid,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenDiagnostic reports on the state of the multi-token authentication system.
|
||||
type APITokenDiagnostic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
|
@ -413,12 +432,18 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
|
|||
MemoryMB: memStats.Alloc / 1024 / 1024,
|
||||
}
|
||||
|
||||
var proxySync map[string]proxySyncState
|
||||
var (
|
||||
proxySync map[string]proxySyncState
|
||||
socketHostState []monitoring.ProxyHostDiagnostics
|
||||
)
|
||||
if r.temperatureProxyHandlers != nil {
|
||||
proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus()
|
||||
}
|
||||
if r.monitor != nil {
|
||||
socketHostState = r.monitor.SocketProxyHostDiagnostics()
|
||||
}
|
||||
|
||||
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync)
|
||||
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync, socketHostState)
|
||||
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
|
||||
|
||||
// Test each configured node
|
||||
|
|
@ -674,7 +699,7 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *
|
|||
return discovery
|
||||
}
|
||||
|
||||
func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState) *TemperatureProxyDiagnostic {
|
||||
func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState, hostStates []monitoring.ProxyHostDiagnostics) *TemperatureProxyDiagnostic {
|
||||
diag := &TemperatureProxyDiagnostic{}
|
||||
|
||||
appendNote := func(note string) {
|
||||
|
|
@ -716,6 +741,9 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]p
|
|||
|
||||
if !diag.SocketFound {
|
||||
appendNote("No proxy socket detected inside the container. Remove the affected node in Pulse, then re-add it using the installer script from Settings → Nodes to regenerate the mount (or rerun the host installer script if you prefer).")
|
||||
if cfg != nil && cfg.TemperatureMonitoringEnabled {
|
||||
appendNote("Global temperature monitoring is enabled but the host proxy socket is missing; reinstall the proxy or disable temperatures until it is restored.")
|
||||
}
|
||||
} else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" {
|
||||
// Only warn about /run mount in LXC containers where /mnt/pulse-proxy is preferred
|
||||
// Docker deployments correctly use /run/pulse-sensor-proxy per docker-compose.yml
|
||||
|
|
@ -867,9 +895,108 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]p
|
|||
}
|
||||
}
|
||||
|
||||
if len(hostStates) > 0 && cfg != nil {
|
||||
now := time.Now()
|
||||
cooldowns := make([]TemperatureProxySocketHost, 0, len(hostStates))
|
||||
for _, state := range hostStates {
|
||||
if state.Host == "" || state.CooldownUntil.IsZero() {
|
||||
continue
|
||||
}
|
||||
if now.After(state.CooldownUntil) {
|
||||
continue
|
||||
}
|
||||
entry := TemperatureProxySocketHost{
|
||||
Host: state.Host,
|
||||
CooldownUntil: state.CooldownUntil.UTC().Format(time.RFC3339),
|
||||
SecondsRemaining: int(time.Until(state.CooldownUntil).Seconds()),
|
||||
LastError: state.LastError,
|
||||
}
|
||||
if entry.SecondsRemaining < 0 {
|
||||
entry.SecondsRemaining = 0
|
||||
}
|
||||
if name := matchInstanceNameByHost(cfg, state.Host); name != "" {
|
||||
entry.Node = name
|
||||
}
|
||||
cooldowns = append(cooldowns, entry)
|
||||
}
|
||||
if len(cooldowns) > 0 {
|
||||
diag.SocketHostCooldowns = cooldowns
|
||||
}
|
||||
}
|
||||
|
||||
if summary, err := loadHostProxySummary(); err == nil {
|
||||
diag.HostProxySummary = summary
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
func loadHostProxySummary() (*HostProxySummary, error) {
|
||||
const summaryPath = "/etc/pulse/install_summary.json"
|
||||
data, err := os.ReadFile(summaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
CTID string `json:"ctid"`
|
||||
Proxy struct {
|
||||
Requested bool `json:"requested"`
|
||||
Installed bool `json:"installed"`
|
||||
HostSocketPresent bool `json:"hostSocketPresent"`
|
||||
ContainerSocketPresent *bool `json:"containerSocketPresent"`
|
||||
} `json:"proxy"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := &HostProxySummary{
|
||||
Requested: raw.Proxy.Requested,
|
||||
Installed: raw.Proxy.Installed,
|
||||
HostSocketPresent: raw.Proxy.HostSocketPresent,
|
||||
LastUpdated: strings.TrimSpace(raw.GeneratedAt),
|
||||
CTID: strings.TrimSpace(raw.CTID),
|
||||
}
|
||||
if raw.Proxy.ContainerSocketPresent != nil {
|
||||
value := *raw.Proxy.ContainerSocketPresent
|
||||
summary.ContainerSocketPresent = &value
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func matchInstanceNameByHost(cfg *config.Config, host string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
needle := normalizeHostForComparison(host)
|
||||
if needle == "" {
|
||||
return ""
|
||||
}
|
||||
for _, inst := range cfg.PVEInstances {
|
||||
candidate := normalizeHostForComparison(inst.Host)
|
||||
if candidate != "" && strings.EqualFold(candidate, needle) {
|
||||
return strings.TrimSpace(inst.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeHostForComparison(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
trimmed = strings.TrimPrefix(trimmed, "https://")
|
||||
trimmed = strings.TrimPrefix(trimmed, "http://")
|
||||
if idx := strings.IndexByte(trimmed, '/'); idx != -1 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
if idx := strings.IndexByte(trimmed, ':'); idx != -1 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(trimmed))
|
||||
}
|
||||
|
||||
func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister)
|
||||
r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.temperatureProxyHandlers.HandleAuthorizedNodes)
|
||||
r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister))
|
||||
r.mux.HandleFunc("/api/temperature-proxy/install-command", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleTemperatureProxyInstallCommand)))
|
||||
r.mux.HandleFunc("/api/temperature-proxy/host-status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleHostProxyStatus)))
|
||||
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck)))
|
||||
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
||||
r.mux.HandleFunc("/api/version", r.handleVersion)
|
||||
|
|
@ -3823,6 +3825,75 @@ func (r *Router) handleDownloadTemperatureProxyMigrationScript(w http.ResponseWr
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleTemperatureProxyInstallCommand(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSpace(r.resolvePublicURL(req))
|
||||
if baseURL == "" {
|
||||
http.Error(w, "Pulse public URL is not configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
node := strings.TrimSpace(req.URL.Query().Get("node"))
|
||||
command := fmt.Sprintf(
|
||||
"curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | sudo bash -s -- --standalone --http-mode --pulse-server %s",
|
||||
baseURL,
|
||||
)
|
||||
|
||||
response := map[string]string{
|
||||
"command": command,
|
||||
"pulseURL": baseURL,
|
||||
}
|
||||
if node != "" {
|
||||
response["node"] = node
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize proxy install command response")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleHostProxyStatus(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
hostSocket := fileExists("/run/pulse-sensor-proxy/pulse-sensor-proxy.sock")
|
||||
containerSocket := fileExists("/mnt/pulse-proxy/pulse-sensor-proxy.sock")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"hostSocketPresent": hostSocket,
|
||||
"containerSocketPresent": containerSocket,
|
||||
"lastChecked": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if summary, err := loadHostProxySummary(); err == nil && summary != nil {
|
||||
resp["summary"] = summary
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(r.resolvePublicURL(req), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:7655"
|
||||
}
|
||||
|
||||
ctid := "<ctid>"
|
||||
if summary, ok := resp["summary"].(*HostProxySummary); ok && summary != nil && summary.CTID != "" {
|
||||
ctid = summary.CTID
|
||||
}
|
||||
|
||||
resp["reinstallCommand"] = fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | sudo bash -s -- --ctid %s --pulse-server %s", ctid, baseURL)
|
||||
resp["installerURL"] = fmt.Sprintf("%s/api/install/install-sensor-proxy.sh", baseURL)
|
||||
|
||||
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize host proxy status response")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) resolvePublicURL(req *http.Request) string {
|
||||
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
|
||||
return strings.TrimRight(publicURL, "/")
|
||||
|
|
@ -3852,6 +3923,11 @@ func (r *Router) resolvePublicURL(req *http.Request) string {
|
|||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizeDockerAgentArch(arch string) string {
|
||||
if arch == "" {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ type SystemSettingsHandler struct {
|
|||
EnableTemperatureMonitoring()
|
||||
DisableTemperatureMonitoring()
|
||||
GetNotificationManager() *notifications.NotificationManager
|
||||
HasSocketTemperatureProxy() bool
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
|
|||
EnableTemperatureMonitoring()
|
||||
DisableTemperatureMonitoring()
|
||||
GetNotificationManager() *notifications.NotificationManager
|
||||
HasSocketTemperatureProxy() bool
|
||||
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
|
||||
return &SystemSettingsHandler{
|
||||
config: cfg,
|
||||
|
|
@ -64,6 +66,7 @@ func (h *SystemSettingsHandler) SetMonitor(m interface {
|
|||
EnableTemperatureMonitoring()
|
||||
DisableTemperatureMonitoring()
|
||||
GetNotificationManager() *notifications.NotificationManager
|
||||
HasSocketTemperatureProxy() bool
|
||||
}) {
|
||||
h.monitor = m
|
||||
}
|
||||
|
|
@ -572,6 +575,34 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
|||
settings.BackupPollingEnabled = updates.BackupPollingEnabled
|
||||
}
|
||||
if _, ok := rawRequest["temperatureMonitoringEnabled"]; ok {
|
||||
if updates.TemperatureMonitoringEnabled {
|
||||
socketAvailable := false
|
||||
if h.monitor != nil {
|
||||
socketAvailable = h.monitor.HasSocketTemperatureProxy()
|
||||
}
|
||||
if !socketAvailable {
|
||||
missing := make([]string, 0)
|
||||
if h.config != nil {
|
||||
for _, inst := range h.config.PVEInstances {
|
||||
if strings.TrimSpace(inst.TemperatureProxyURL) == "" || strings.TrimSpace(inst.TemperatureProxyToken) == "" {
|
||||
name := strings.TrimSpace(inst.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(inst.Host)
|
||||
}
|
||||
if name == "" {
|
||||
name = "unnamed node"
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
message := fmt.Sprintf("Cannot enable temperature monitoring: proxy socket is not available and the following nodes do not have HTTPS proxies configured: %s", strings.Join(missing, ", "))
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
settings.TemperatureMonitoringEnabled = updates.TemperatureMonitoringEnabled
|
||||
tempToggleRequested = true
|
||||
}
|
||||
|
|
|
|||
27
internal/api/system_settings_temperature_test.go
Normal file
27
internal/api/system_settings_temperature_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleUpdateSystemSettingsRejectsTempsWithoutTransport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PULSE_DOCKER", "true")
|
||||
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir, PVEInstances: []config.PVEInstance{{Name: "pve-a"}}}
|
||||
persistence := config.NewConfigPersistence(tempDir)
|
||||
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", bytes.NewBufferString(`{"temperatureMonitoringEnabled":true}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateSystemSettings(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
45
internal/api/temperature_proxy_command_test.go
Normal file
45
internal/api/temperature_proxy_command_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleTemperatureProxyInstallCommand(t *testing.T) {
|
||||
cfg := &config.Config{PublicURL: "https://pulse.example:7655"}
|
||||
router := &Router{config: cfg}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/temperature-proxy/install-command?node=pve-a", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.handleTemperatureProxyInstallCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp["node"] != "pve-a" {
|
||||
t.Fatalf("expected node pve-a, got %s", resp["node"])
|
||||
}
|
||||
|
||||
command := resp["command"]
|
||||
if command == "" {
|
||||
t.Fatalf("command missing in response")
|
||||
}
|
||||
if !strings.Contains(command, cfg.PublicURL) {
|
||||
t.Fatalf("command does not include pulse URL: %s", command)
|
||||
}
|
||||
if !strings.Contains(command, "--standalone --http-mode") {
|
||||
t.Fatalf("command missing expected flags: %s", command)
|
||||
}
|
||||
}
|
||||
|
|
@ -3472,6 +3472,19 @@ func (m *Monitor) HasSocketTemperatureProxy() bool {
|
|||
return m.tempCollector.SocketProxyAvailable()
|
||||
}
|
||||
|
||||
// SocketProxyHostDiagnostics exposes per-host proxy cooldown state for diagnostics.
|
||||
func (m *Monitor) SocketProxyHostDiagnostics() []ProxyHostDiagnostics {
|
||||
m.mu.RLock()
|
||||
collector := m.tempCollector
|
||||
m.mu.RUnlock()
|
||||
|
||||
if collector == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return collector.ProxyHostDiagnostics()
|
||||
}
|
||||
|
||||
// checkContainerizedTempMonitoring logs a security warning if Pulse is running
|
||||
// in a container with SSH-based temperature monitoring enabled
|
||||
func checkContainerizedTempMonitoring() {
|
||||
|
|
@ -3514,6 +3527,13 @@ func New(cfg *config.Config) (*Monitor, error) {
|
|||
// Security warning if running in container with SSH temperature monitoring
|
||||
checkContainerizedTempMonitoring()
|
||||
|
||||
if cfg != nil && cfg.TemperatureMonitoringEnabled {
|
||||
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
|
||||
if isContainer && tempCollector != nil && !tempCollector.SocketProxyAvailable() {
|
||||
log.Warn().Msg("Temperature monitoring is enabled but the container does not have access to pulse-sensor-proxy. Install the proxy on the host or disable temperatures until it is available.")
|
||||
}
|
||||
}
|
||||
|
||||
stalenessTracker := NewStalenessTracker(getPollMetrics())
|
||||
stalenessTracker.SetBounds(cfg.AdaptivePollingBaseInterval, cfg.AdaptivePollingMaxInterval)
|
||||
taskQueue := NewTaskQueue()
|
||||
|
|
|
|||
|
|
@ -43,10 +43,25 @@ type TemperatureCollector struct {
|
|||
proxyMu sync.Mutex
|
||||
proxyFailures int
|
||||
proxyCooldownUntil time.Time
|
||||
proxyHostStates map[string]*proxyHostState
|
||||
missingKeyWarned atomic.Bool
|
||||
legacySSHDisabled atomic.Bool
|
||||
}
|
||||
|
||||
type proxyHostState struct {
|
||||
failures int
|
||||
cooldownUntil time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
// ProxyHostDiagnostics describes the proxy transport state for a host.
|
||||
type ProxyHostDiagnostics struct {
|
||||
Host string
|
||||
Failures int
|
||||
CooldownUntil time.Time
|
||||
LastError string
|
||||
}
|
||||
|
||||
// NewTemperatureCollector creates a new temperature collector with default SSH port (22)
|
||||
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
|
||||
return NewTemperatureCollectorWithPort(sshUser, sshKeyPath, 22)
|
||||
|
|
@ -59,9 +74,10 @@ func NewTemperatureCollectorWithPort(sshUser, sshKeyPath string, sshPort int) *T
|
|||
}
|
||||
|
||||
tc := &TemperatureCollector{
|
||||
sshUser: sshUser,
|
||||
sshKeyPath: sshKeyPath,
|
||||
sshPort: sshPort,
|
||||
sshUser: sshUser,
|
||||
sshKeyPath: sshKeyPath,
|
||||
sshPort: sshPort,
|
||||
proxyHostStates: make(map[string]*proxyHostState),
|
||||
}
|
||||
|
||||
homeDir := os.Getenv("HOME")
|
||||
|
|
@ -126,9 +142,17 @@ func (tc *TemperatureCollector) CollectTemperatureWithProxy(ctx context.Context,
|
|||
|
||||
// Use Unix socket proxy if available (local deployment)
|
||||
if tc.isProxyEnabled() {
|
||||
if tc.shouldSkipProxyHost(host) {
|
||||
log.Debug().
|
||||
Str("node", nodeName).
|
||||
Str("host", host).
|
||||
Msg("Skipping temperature proxy request while host is in cooldown")
|
||||
return &models.Temperature{Available: false}, nil
|
||||
}
|
||||
|
||||
output, err = tc.proxyClient.GetTemperature(host)
|
||||
if err != nil {
|
||||
tc.handleProxyFailure(err)
|
||||
tc.handleProxyFailure(host, err)
|
||||
log.Debug().
|
||||
Str("node", nodeName).
|
||||
Str("host", host).
|
||||
|
|
@ -137,6 +161,7 @@ func (tc *TemperatureCollector) CollectTemperatureWithProxy(ctx context.Context,
|
|||
return &models.Temperature{Available: false}, nil
|
||||
}
|
||||
tc.handleProxySuccess()
|
||||
tc.handleProxyHostSuccess(host)
|
||||
} else {
|
||||
// SECURITY: Block SSH fallback when running in containers (unless dev mode)
|
||||
// Container compromise = SSH key compromise = root access to infrastructure
|
||||
|
|
@ -817,6 +842,33 @@ func (tc *TemperatureCollector) isProxyEnabled() bool {
|
|||
return useProxy
|
||||
}
|
||||
|
||||
func (tc *TemperatureCollector) shouldSkipProxyHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
state, ok := tc.proxyHostStates[host]
|
||||
if !ok || state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if state.cooldownUntil.IsZero() || now.After(state.cooldownUntil) {
|
||||
// Cooldown expired; reset state so we can retry this host.
|
||||
state.cooldownUntil = time.Time{}
|
||||
state.failures = 0
|
||||
if state.cooldownUntil.IsZero() && state.failures == 0 {
|
||||
delete(tc.proxyHostStates, host)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SocketProxyAvailable reports whether the unix socket proxy can currently be used.
|
||||
func (tc *TemperatureCollector) SocketProxyAvailable() bool {
|
||||
return tc != nil && tc.isProxyEnabled()
|
||||
|
|
@ -831,26 +883,71 @@ func (tc *TemperatureCollector) handleProxySuccess() {
|
|||
tc.proxyMu.Unlock()
|
||||
}
|
||||
|
||||
func (tc *TemperatureCollector) handleProxyFailure(err error) {
|
||||
if tc.proxyClient == nil || !tc.shouldDisableProxy(err) {
|
||||
func (tc *TemperatureCollector) handleProxyHostSuccess(host string) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
tc.proxyMu.Lock()
|
||||
delete(tc.proxyHostStates, host)
|
||||
tc.proxyMu.Unlock()
|
||||
}
|
||||
|
||||
func (tc *TemperatureCollector) handleProxyFailure(host string, err error) {
|
||||
if tc.proxyClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if tc.shouldDisableProxy(err) {
|
||||
tc.proxyMu.Lock()
|
||||
tc.proxyFailures++
|
||||
disable := tc.proxyFailures >= proxyFailureThreshold && tc.useProxy
|
||||
if disable {
|
||||
tc.useProxy = false
|
||||
tc.proxyCooldownUntil = time.Now().Add(proxyRetryInterval)
|
||||
tc.proxyFailures = 0
|
||||
}
|
||||
tc.proxyMu.Unlock()
|
||||
|
||||
if disable {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Dur("cooldown", proxyRetryInterval).
|
||||
Msg("Temperature proxy disabled after repeated failures; will retry later")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tc.handleProxyHostFailure(host, err)
|
||||
}
|
||||
|
||||
func (tc *TemperatureCollector) handleProxyHostFailure(host string, err error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
tc.proxyFailures++
|
||||
disable := tc.proxyFailures >= proxyFailureThreshold && tc.useProxy
|
||||
if disable {
|
||||
tc.useProxy = false
|
||||
tc.proxyCooldownUntil = time.Now().Add(proxyRetryInterval)
|
||||
tc.proxyFailures = 0
|
||||
state, ok := tc.proxyHostStates[host]
|
||||
if !ok || state == nil {
|
||||
state = &proxyHostState{}
|
||||
tc.proxyHostStates[host] = state
|
||||
}
|
||||
state.failures++
|
||||
state.lastError = strings.TrimSpace(err.Error())
|
||||
trip := state.failures >= proxyFailureThreshold
|
||||
if trip {
|
||||
state.failures = 0
|
||||
state.cooldownUntil = time.Now().Add(proxyRetryInterval)
|
||||
}
|
||||
tc.proxyMu.Unlock()
|
||||
|
||||
if disable {
|
||||
if trip {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("host", host).
|
||||
Dur("cooldown", proxyRetryInterval).
|
||||
Msg("Temperature proxy disabled after repeated failures; will retry later")
|
||||
Msg("Temperature proxy host in cooldown after repeated failures")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -866,3 +963,31 @@ func (tc *TemperatureCollector) shouldDisableProxy(err error) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ProxyHostDiagnostics returns a snapshot of per-host proxy error state.
|
||||
func (tc *TemperatureCollector) ProxyHostDiagnostics() []ProxyHostDiagnostics {
|
||||
if tc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tc.proxyMu.Lock()
|
||||
defer tc.proxyMu.Unlock()
|
||||
|
||||
if len(tc.proxyHostStates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]ProxyHostDiagnostics, 0, len(tc.proxyHostStates))
|
||||
for host, state := range tc.proxyHostStates {
|
||||
if state == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, ProxyHostDiagnostics{
|
||||
Host: host,
|
||||
Failures: state.failures,
|
||||
CooldownUntil: state.cooldownUntil,
|
||||
LastError: state.lastError,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const (
|
|||
ErrorTypeSSH // SSH connectivity issues
|
||||
ErrorTypeSensor // Sensor command failures
|
||||
ErrorTypeTimeout // Operation timeout
|
||||
ErrorTypeNode // Node allowlist or validation failures
|
||||
)
|
||||
|
||||
// ProxyError wraps errors with classification
|
||||
|
|
@ -125,6 +126,16 @@ func classifyError(err error, respError string) *ProxyError {
|
|||
// Check response error messages first (even if err is nil)
|
||||
// This handles cases where the socket succeeds but the proxy returns an application error
|
||||
if respError != "" {
|
||||
// Node validator/allowlist rejections should not disable the proxy globally
|
||||
if contains(respError, "rejected by validator", "not in allowlist", "node \"") {
|
||||
return &ProxyError{
|
||||
Type: ErrorTypeNode,
|
||||
Message: respError,
|
||||
Retryable: false,
|
||||
Wrapped: fmt.Errorf("%s", respError),
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting - never retry
|
||||
if contains(respError, "rate limit") {
|
||||
return &ProxyError{
|
||||
|
|
@ -412,7 +423,15 @@ func (c *Client) GetTemperature(nodeHost string) (string, error) {
|
|||
}
|
||||
|
||||
if !resp.Success {
|
||||
return "", fmt.Errorf("proxy error: %s", resp.Error)
|
||||
if proxyErr := classifyError(nil, resp.Error); proxyErr != nil {
|
||||
return "", proxyErr
|
||||
}
|
||||
return "", &ProxyError{
|
||||
Type: ErrorTypeUnknown,
|
||||
Message: resp.Error,
|
||||
Retryable: false,
|
||||
Wrapped: fmt.Errorf("%s", resp.Error),
|
||||
}
|
||||
}
|
||||
|
||||
// Extract temperature JSON string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue