feat: AI features, agent improvements, and host monitoring enhancements

AI Chat Integration:
- Multi-provider support (Anthropic, OpenAI, Ollama)
- Streaming responses with markdown rendering
- Agent command execution for remote troubleshooting
- Context-aware conversations with host/container metadata

Agent Updates:
- Add --enable-proxmox flag for automatic PVE/PBS token setup
- Improve auto-update with semver comparison (prevents downgrades)
- Add updatedFrom tracking to report previous version after update
- Reduce initial update check delay from 30s to 5s
- Add agent version column to Hosts page table

Host Metrics:
- Add DiskIO stats collection (read/write bytes, ops, time)
- Improve disk filtering to exclude Docker overlay mounts
- Add RAID array monitoring via mdadm
- Enhanced temperature sensor parsing

Frontend:
- New Agent Version column on Hosts overview table
- Improved node modal with agent-first installation flow
- Add DiskIO display in host drawer
- Better responsive handling for metric bars
This commit is contained in:
rcourtman 2025-12-05 10:37:02 +00:00
parent 3f08bfe8d0
commit cc3c0187a0
45 changed files with 2038 additions and 353 deletions

View file

@ -78,6 +78,7 @@ func main() {
Str("pulse_url", cfg.PulseURL).
Bool("host_agent", cfg.EnableHost).
Bool("docker_agent", cfg.EnableDocker).
Bool("proxmox_mode", cfg.EnableProxmox).
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse Unified Agent")
@ -126,6 +127,8 @@ func main() {
InsecureSkipVerify: cfg.InsecureSkipVerify,
LogLevel: cfg.LogLevel,
Logger: &logger,
EnableProxmox: cfg.EnableProxmox,
ProxmoxType: cfg.ProxmoxType,
}
agent, err := hostagent.New(hostCfg)
@ -259,8 +262,10 @@ type Config struct {
Logger *zerolog.Logger
// Module flags
EnableHost bool
EnableDocker bool
EnableHost bool
EnableDocker bool
EnableProxmox bool
ProxmoxType string // "pve", "pbs", or "" for auto-detect
// Auto-update
DisableAutoUpdate bool
@ -281,6 +286,8 @@ func loadConfig() Config {
envLogLevel := utils.GetenvTrim("LOG_LEVEL")
envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST")
envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER")
envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX")
envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE")
envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE")
envHealthAddr := utils.GetenvTrim("PULSE_HEALTH_ADDR")
@ -302,6 +309,11 @@ func loadConfig() Config {
defaultEnableDocker = utils.ParseBool(envEnableDocker)
}
defaultEnableProxmox := false
if envEnableProxmox != "" {
defaultEnableProxmox = utils.ParseBool(envEnableProxmox)
}
defaultHealthAddr := envHealthAddr
if defaultHealthAddr == "" {
defaultHealthAddr = ":9191"
@ -318,6 +330,8 @@ func loadConfig() Config {
enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module")
enableProxmoxFlag := flag.Bool("enable-proxmox", defaultEnableProxmox, "Enable Proxmox mode (creates API token, registers node)")
proxmoxTypeFlag := flag.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)")
disableAutoUpdateFlag := flag.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates")
healthAddrFlag := flag.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)")
showVersion := flag.Bool("version", false, "Print the agent version and exit")
@ -362,6 +376,8 @@ func loadConfig() Config {
LogLevel: logLevel,
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
EnableProxmox: *enableProxmoxFlag,
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
DisableAutoUpdate: *disableAutoUpdateFlag,
HealthAddr: strings.TrimSpace(*healthAddrFlag),
}

View file

@ -1,5 +1,10 @@
# pulse-sensor-proxy
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead. The agent provides
> temperature monitoring plus additional features (AI, automatic Proxmox API setup).
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md) for details.
The sensor proxy keeps SSH identities and temperature polling logic on the
Proxmox host while presenting a small RPC surface (Unix socket or HTTPS) to the
Pulse server. It protects SSH keys from container breakouts, enforces per-UID

View file

@ -175,12 +175,16 @@ func runServer() {
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port
// NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting
// WebSocket connections. ReadTimeout sets a deadline on the underlying connection
// that persists even after WebSocket upgrade, causing premature disconnections.
// ReadHeaderTimeout only applies during header reading, not the full request body.
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data)
IdleTimeout: 60 * time.Second,
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(),
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data)
IdleTimeout: 60 * time.Second,
}
// Start config watcher for .env file changes

View file

@ -1,5 +1,9 @@
# ⚙️ Sensor Proxy Configuration
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Safe configuration management using the CLI (v4.31.1+).
## 📂 Files

View file

@ -1,5 +1,9 @@
# 📝 Sensor Proxy Log Forwarding
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Forward `audit.log` and `proxy.log` to a central SIEM via RELP + TLS.
## 🚀 Quick Start

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Secure `pulse-sensor-proxy` with AppArmor and Seccomp.
## 🛡️ AppArmor

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
The `pulse-sensor-proxy` runs on the host to securely collect temperatures, keeping SSH keys out of containers.
## 🏗️ Architecture

View file

@ -1,5 +1,9 @@
# 🌐 Sensor Proxy Network Segmentation
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Isolate the proxy to prevent lateral movement.
## 🚧 Zones

View file

@ -1,29 +1,57 @@
# 🌡️ Temperature Monitoring Security
# 🌡️ Temperature Monitoring
Secure architecture for collecting hardware temperatures.
Pulse supports two methods for collecting hardware temperatures from Proxmox nodes.
## 🛡️ Security Model
## Recommended: Pulse Agent
The simplest and most feature-rich method is installing the Pulse agent on your Proxmox nodes:
```bash
curl -fsSL http://your-pulse-server:7655/api/download/install.sh | bash -s -- \
--url http://your-pulse-server:7655 \
--token YOUR_TOKEN \
--enable-proxmox
```
**Benefits:**
- ✅ One-command setup
- ✅ Automatic API token creation
- ✅ Temperature monitoring built-in
- ✅ Enables AI features for VM/container management
- ✅ No SSH keys or proxy configuration required
The agent runs `sensors -j` locally and reports temperatures directly to Pulse.
---
## Legacy: Sensor Proxy (SSH-based)
For users who prefer not to install an agent on their hypervisor, the sensor-proxy method is still available.
> **Note:** This method is deprecated and will be removed in a future release. Consider migrating to the agent-based approach.
### 🛡️ Security Model
* **Isolation**: SSH keys live on the host, not in the container.
* **Least Privilege**: Proxy runs as `pulse-sensor-proxy` (no shell).
* **Verification**: Container identity verified via `SO_PEERCRED`.
## 🏗️ Components
### 🏗️ Components
1. **Pulse Backend**: Connects to Unix socket `/mnt/pulse-proxy/pulse-sensor-proxy.sock`.
2. **Sensor Proxy**: Validates request, executes SSH to node.
3. **Target Node**: Accepts SSH key restricted to `sensors -j`.
## 🔒 Key Restrictions
### 🔒 Key Restrictions
SSH keys deployed to nodes are locked down:
```
command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty
```
## 🚦 Rate Limiting
### 🚦 Rate Limiting
* **Per Peer**: ~12 req/min.
* **Concurrency**: Max 2 parallel requests per peer.
* **Global**: Max 8 concurrent requests.
## 📝 Auditing
### 📝 Auditing
All requests logged to system journal:
```bash
journalctl -u pulse-sensor-proxy

View file

@ -272,12 +272,27 @@ export const AIChat: Component<AIChatProps> = (props) => {
const previousMessages = messages();
// Build conversation history from previous messages (before we add new ones)
// Include tool call outputs so the AI remembers what commands were run
const history = previousMessages
.filter((m) => m.content && !m.isStreaming) // Only include completed messages with content
.map((m) => ({
role: m.role,
content: m.content,
}));
.filter((m) => !m.isStreaming) // Only include completed messages
.filter((m) => m.content || (m.toolCalls && m.toolCalls.length > 0)) // Must have content or tool calls
.map((m) => {
let content = m.content || '';
// For assistant messages, prepend tool call outputs so AI has full context
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
const toolSummary = m.toolCalls
.map((tc) => `[Executed: ${tc.input}]\n${tc.output}`)
.join('\n\n');
content = toolSummary + (content ? '\n\n' + content : '');
}
return {
role: m.role,
content,
};
})
.filter((m) => m.content); // Filter out any empty messages
// Add user message
const userMessage: Message = {
@ -333,11 +348,16 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
case 'tool_end': {
const data = event.data as AIStreamToolEndData;
// Remove matching pending tool (by name since input won't match output)
const updatedPending = (msg.pendingTools || []).slice(0, -1); // Remove last pending
// Remove one pending tool with matching name
const pendingTools = msg.pendingTools || [];
const matchingIndex = pendingTools.findIndex((t) => t.name === data.name);
const updatedPending = matchingIndex >= 0
? [...pendingTools.slice(0, matchingIndex), ...pendingTools.slice(matchingIndex + 1)]
: pendingTools;
// Use input directly from tool_end event (authoritative)
const newToolCall: AIToolExecution = {
name: data.name,
input: (msg.pendingTools || []).find((t) => t.name === data.name)?.input || data.name,
input: data.input,
output: data.output,
success: data.success,
};
@ -656,7 +676,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
{/* Show completed tool calls */}
{/* Show completed tool calls FIRST - chronological order */}
<Show when={message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0}>
<div class="mb-3 space-y-2">
<For each={message.toolCalls}>
@ -683,20 +703,17 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
</Show>
{/* Show subtle analyzing indicator when running commands */}
<Show when={message.role === 'assistant' && message.pendingTools && message.pendingTools.length > 0}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-2">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Analyzing...</span>
</div>
{/* Show AI's response text AFTER tool calls */}
<Show when={message.content}>
<div
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
innerHTML={renderMarkdown(message.content)}
/>
</Show>
{/* Show commands awaiting approval */}
<Show when={message.role === 'assistant' && message.pendingApprovals && message.pendingApprovals.length > 0}>
<div class="mb-3 space-y-2">
<div class="mt-3 space-y-2">
<For each={message.pendingApprovals}>
{(approval) => (
<div class="rounded border border-amber-400 dark:border-amber-600 overflow-hidden">
@ -756,24 +773,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
</For>
</div>
</Show>
{/* Show streaming indicator if no content yet but streaming */}
<Show when={message.role === 'assistant' && message.isStreaming && !message.content && (!message.pendingTools || message.pendingTools.length === 0)}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Analyzing...</span>
</div>
</Show>
<Show when={message.content}>
<div
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
innerHTML={renderMarkdown(message.content)}
/>
</Show>
{/* Minimal footer - no model/token info shown */}
</div>
</div>
@ -783,6 +782,17 @@ export const AIChat: Component<AIChatProps> = (props) => {
<div ref={messagesEndRef} />
</div>
{/* Processing indicator - sticky above input */}
<Show when={isLoading()}>
<div class="px-4 py-2 bg-purple-50 dark:bg-purple-900/20 border-t border-purple-200 dark:border-purple-800 flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Analyzing...</span>
</div>
</Show>
{/* Input Area */}
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
{/* Context section - always show with Add button */}

View file

@ -2,7 +2,7 @@ import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { Host } from '@/types/api';
import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format';
import { formatBytes, formatNumber, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar';
@ -328,9 +328,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<th class={thClass} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<th class={`${thClass} text-right pr-4`} onClick={() => handleSort('uptime')}>
<th class={thClass} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={`${thClass} text-right pr-4`}>
Agent
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@ -413,6 +416,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const hasDrawerContent = createMemo(() => {
return (
(host.disks && host.disks.length > 0) ||
(host.diskIO && host.diskIO.length > 0) ||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
(host.raid && host.raid.length > 0) ||
host.loadAverage ||
@ -571,8 +575,8 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</td>
{/* Uptime */}
<td class="px-2 py-1 pr-4 align-middle">
<div class="flex justify-end">
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-400"></span>}
@ -583,12 +587,26 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</Show>
</div>
</td>
{/* Agent Version */}
<td class="px-2 py-1 pr-4 align-middle">
<div class="flex justify-end">
<Show
when={host.agentVersion}
fallback={<span class="text-xs text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{host.agentVersion}
</span>
</Show>
</div>
</td>
</tr>
{/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}>
<tr>
<td colspan={7} class="p-0">
<td colspan={8} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
<div class="flex flex-wrap justify-start gap-3">
{/* System Info */}
@ -689,6 +707,40 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</div>
</Show>
{/* Disk I/O */}
<Show when={host.diskIO && host.diskIO.length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disk I/O</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.diskIO?.slice(0, 4)}>
{(io) => (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="font-medium text-gray-700 dark:text-gray-200">{io.device}</div>
<div class="mt-1 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Read:</span>
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.readBytes ?? 0, 1)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Write:</span>
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.writeBytes ?? 0, 1)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Read Ops:</span>
<span class="text-gray-600 dark:text-gray-300">{formatNumber(io.readOps ?? 0)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Write Ops:</span>
<span class="text-gray-600 dark:text-gray-300">{formatNumber(io.writeOps ?? 0)}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">

View file

@ -20,9 +20,16 @@ type TemperatureSocketCooldownInfo = {
lastError?: string;
};
// Host agent info passed from state
interface HostAgentInfo {
hostname: string;
status: string;
}
interface PveNodesTableProps {
nodes: NodeConfigWithStatus[];
stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
stateHosts?: HostAgentInfo[];
globalTemperatureMonitoringEnabled?: boolean;
temperatureTransports?: TemperatureTransportInfo | null;
onTestConnection: (nodeId: string) => void;
@ -104,6 +111,7 @@ const resolveTemperatureTransport = (
node: NodeConfigWithStatus,
info: TemperatureTransportInfo | null | undefined,
globalEnabled: boolean,
hostAgent?: HostAgentInfo,
): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
@ -125,6 +133,15 @@ const resolveTemperatureTransport = (
};
}
// If a host agent is connected and online, it provides temperatures directly
if (hostAgent?.status === 'online') {
return {
label: 'Via agent',
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
description: 'Temperature data from Pulse agent',
};
}
const key = nodeKey;
const httpEntry = key ? info?.httpMap?.[key] : undefined;
const socketStatus = info?.socketStatus;
@ -185,9 +202,9 @@ const resolveTemperatureTransport = (
return buildSocketBadge();
case 'ssh-blocked':
return {
label: 'Proxy required',
label: 'Agent required',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Containerized Pulse requires pulse-sensor-proxy',
description: 'Install agent on node for temperature monitoring',
};
case 'ssh':
return {
@ -271,6 +288,16 @@ const resolvePveStatusMeta = (
}
};
// Helper to find matching host agent for a node by hostname matching
const findMatchingHostAgent = (
nodeName: string,
hosts: HostAgentInfo[] | undefined,
): HostAgentInfo | undefined => {
if (!hosts || hosts.length === 0) return undefined;
const nodeNameLower = nodeName.toLowerCase().trim();
return hosts.find((h) => h.hostname.toLowerCase().trim() === nodeNameLower);
};
export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
@ -304,11 +331,13 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
const clusterName = createMemo(() =>
'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown',
);
const hostAgent = createMemo(() => findMatchingHostAgent(node.name, props.stateHosts));
const transportMeta = createMemo(() =>
resolveTemperatureTransport(
node,
props.temperatureTransports,
props.globalTemperatureMonitoringEnabled ?? true,
hostAgent(),
),
);
return (
@ -410,9 +439,22 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
@ -603,9 +645,22 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
@ -786,9 +841,22 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">

View file

@ -80,7 +80,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
host: '',
guestURL: '',
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'),
setupMode: 'auto' as 'auto' | 'manual',
setupMode: 'agent' as 'agent' | 'auto' | 'manual', // Default to agent install (recommended)
user: '',
password: '',
tokenName: '',
@ -101,6 +101,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [proxyInstallCommand, setProxyInstallCommand] = createSignal('');
const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false);
const [proxyCommandError, setProxyCommandError] = createSignal<string | null>(null);
const [agentInstallCommand, setAgentInstallCommand] = createSignal('');
const [loadingAgentCommand, setLoadingAgentCommand] = createSignal(false);
const [agentCommandError, setAgentCommandError] = createSignal<string | null>(null);
const showTemperatureMonitoringSection = () =>
typeof props.temperatureMonitoringEnabled === 'boolean';
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
@ -114,7 +117,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
case 'socket-proxy':
return {
tone: 'success',
message: 'Temperatures flow through the host sensor proxy mounted at /run/pulse-sensor-proxy.',
message: 'Temperatures flow through the socket proxy mounted at /run/pulse-sensor-proxy.',
};
case 'https-proxy':
return {
@ -126,7 +129,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
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.',
'Pulse is running in a container. Install the Pulse agent on this node for temperature monitoring (Settings → Agents).',
};
case 'ssh':
return {
@ -400,8 +403,13 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (field === 'setupMode' && value !== 'auto') {
setQuickSetupCommand('');
if (field === 'setupMode') {
if (value !== 'auto') {
setQuickSetupCommand('');
}
if (value !== 'agent') {
setAgentInstallCommand('');
}
}
};
@ -764,7 +772,21 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pve'}>
<div class="space-y-3 text-xs">
{/* Tab buttons */}
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => updateField('setupMode', 'agent')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'agent'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Agent Install
<span class="ml-1.5 px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 rounded">
Recommended
</span>
</button>
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
@ -774,7 +796,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Quick Setup
API Only
</button>
<button
type="button"
@ -785,17 +807,106 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Manual Setup
Manual
</button>
</div>
{/* Quick Setup Tab */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
The command below creates the monitoring user, applies read-only
access, and adds the storage permissions Pulse needs to display
backups.
</p>
{/* Agent Install Tab (Recommended) */}
<Show when={formData().setupMode === 'agent'}>
<div class="space-y-3">
<p class="text-xs text-gray-600 dark:text-gray-400">
Install the Pulse agent on your Proxmox node. This single command sets everything up:
</p>
<ul class="text-xs text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>Creates monitoring user and API token automatically</li>
<li>Registers the node with Pulse</li>
<li>Enables temperature monitoring (no SSH required)</li>
<li>Enables AI features for managing VMs/containers</li>
</ul>
<p class="text-blue-800 dark:text-blue-200 font-medium">
Run this command on your Proxmox VE node:
</p>
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
<button
type="button"
disabled={loadingAgentCommand()}
onClick={async () => {
logger.debug('[Agent Install] Copy button clicked');
try {
setLoadingAgentCommand(true);
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/agent-install-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'pve',
enableProxmox: true,
}),
});
if (response.ok) {
const data = await response.json();
if (data.command) {
setAgentInstallCommand(data.command);
const copied = await copyToClipboard(data.command);
if (copied) {
showSuccess('Command copied! Run it on your Proxmox node.');
} else {
showError('Failed to copy to clipboard');
}
}
} else {
showError('Failed to generate install command');
}
} catch (error) {
logger.error('[Agent Install] Error:', error);
showError('Failed to generate install command');
} finally {
setLoadingAgentCommand(false);
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-gray-200 bg-gray-700 rounded-md transition-colors disabled:opacity-50"
title="Copy command"
>
<Show when={loadingAgentCommand()} fallback={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
}>
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"></path>
</svg>
</Show>
</button>
<Show
when={agentInstallCommand().length > 0}
fallback={
<code class="text-blue-400">
Click the button above to copy the install command
</code>
}
>
<code class="block text-blue-100 whitespace-pre-wrap break-words">
{agentInstallCommand()}
</code>
</Show>
</div>
<p class="text-[11px] text-gray-500 dark:text-gray-400 italic">
The node will appear in Pulse automatically after the agent starts.
</p>
</div>
</Show>
{/* API Only Tab (formerly Quick Setup) */}
<Show when={formData().setupMode === 'auto'}>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 mb-3 dark:border-amber-700 dark:bg-amber-900/20">
<p class="text-xs text-amber-800 dark:text-amber-200">
<strong>Limited functionality:</strong> API-only mode does not include temperature monitoring or AI features.
For full functionality, use the Agent Install tab instead.
</p>
</div>
<p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox VE server:
</p>
@ -1259,17 +1370,29 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pbs'}>
<div class="space-y-3 text-xs">
{/* Tab buttons for PBS */}
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'auto' || !formData().setupMode
onClick={() => updateField('setupMode', 'agent')}
class={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'agent'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Quick Setup
Agent Install
<span class="text-[10px] px-1.5 py-0.5 bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 rounded">Recommended</span>
</button>
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'auto'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
API Only
</button>
<button
type="button"
@ -1284,8 +1407,83 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</button>
</div>
{/* Quick Setup Tab for PBS */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
{/* Agent Install Tab for PBS */}
<Show when={formData().setupMode === 'agent'}>
<div class="space-y-3">
<p class="text-xs text-gray-600 dark:text-gray-400">
Install the Pulse agent on your Proxmox Backup Server. This is the recommended method as it provides:
</p>
<ul class="text-xs text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>One-command setup (creates API user and token automatically)</li>
<li>Built-in temperature monitoring (no SSH required)</li>
<li>AI features (execute commands via Pulse AI)</li>
<li>Automatic reconnection on network issues</li>
</ul>
<p class="text-blue-800 dark:text-blue-200 text-xs mt-3">
Run this command on your PBS node:
</p>
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
<button
type="button"
onClick={async () => {
try {
setLoadingAgentCommand(true);
setAgentCommandError(null);
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/agent-install-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'pbs' }),
});
if (!response.ok) {
throw new Error(`Failed to generate command: ${response.status}`);
}
const data = await response.json();
if (data.command) {
setAgentInstallCommand(data.command);
const copied = await copyToClipboard(data.command);
if (copied) {
showSuccess('Command copied to clipboard');
}
}
} catch (error) {
logger.error('[Agent Install] Error:', error);
setAgentCommandError(error instanceof Error ? error.message : 'Failed to generate command');
showError('Failed to generate install command');
} finally {
setLoadingAgentCommand(false);
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white rounded bg-gray-800 hover:bg-gray-700 transition-colors"
title="Copy to clipboard"
disabled={loadingAgentCommand()}
>
<Show when={loadingAgentCommand()} fallback={
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}>
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</Show>
</button>
<code class="text-green-400 whitespace-pre-wrap break-all pr-10">
{agentInstallCommand() || 'Click the copy button to generate and copy the install command'}
</code>
</div>
<Show when={agentCommandError()}>
<p class="text-xs text-red-500">{agentCommandError()}</p>
</Show>
<p class="text-xs text-gray-500 dark:text-gray-500">
The node will automatically appear in Pulse once the agent connects.
</p>
</div>
</Show>
{/* Quick Setup Tab for PBS (API Only) */}
<Show when={formData().setupMode === 'auto'}>
<p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox Backup Server:
</p>
@ -1915,8 +2113,11 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</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="font-semibold">Temperature proxy (legacy)</div>
<div class="text-amber-700 dark:text-amber-300 text-[11px]">
Recommended: Install the Pulse agent instead (Settings Agents) for temperatures + AI features.
</div>
<div>Or generate a one-line installer command for the standalone temperature proxy:</div>
<div class="flex flex-wrap gap-2">
<button
type="button"

View file

@ -2709,6 +2709,40 @@ const Settings: Component<SettingsProps> = (props) => {
class="mb-6"
/>
</Show>
{/* Recommendation banner for Proxmox tab */}
<Show when={activeTab() === 'proxmox'}>
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 mb-6 dark:border-blue-800 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="flex-1">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Recommended:</strong> Install the Pulse agent on your Proxmox nodes for automatic setup, temperature monitoring, and AI features.
</p>
<button
type="button"
onClick={() => navigate('/settings/agents')}
class="mt-2 text-sm font-medium text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200 underline"
>
Go to Agents tab
</button>
</div>
</div>
</div>
</Show>
{/* PVE Nodes Tab */}
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'pve'}>
<div class="space-y-6 mt-6">
@ -2824,6 +2858,7 @@ const Settings: Component<SettingsProps> = (props) => {
<PveNodesTable
nodes={pveNodes()}
stateNodes={state.nodes ?? []}
stateHosts={state.hosts ?? []}
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
temperatureTransports={temperatureTransportInfo()}
onTestConnection={testNodeConnection}
@ -5783,9 +5818,8 @@ const Settings: Component<SettingsProps> = (props) => {
fallback={
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Check proxy nodes is only available when the proxy socket
grants admin access. Run this diagnostic on the Proxmox host
or reinstall pulse-sensor-proxy in HTTP mode to manage nodes
remotely.
grants admin access. For best results, install the Pulse agent
on each Proxmox node (Settings Agents).
</div>
}
>

View file

@ -108,6 +108,7 @@ export const UnifiedAgents: Component = () => {
const [lookupError, setLookupError] = createSignal<string | null>(null);
const [lookupLoading, setLookupLoading] = createSignal(false);
const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring
const [enableProxmox, setEnableProxmox] = createSignal(false); // For Proxmox VE/PBS nodes - creates API token and auto-registers
const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
createEffect(() => {
@ -236,6 +237,7 @@ export const UnifiedAgents: Component = () => {
};
const getDockerFlag = () => enableDocker() ? ' --enable-docker' : '';
const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : '';
const getInsecureFlag = () => insecureMode() ? ' --insecure' : '';
const getCurlInsecureFlag = () => insecureMode() ? '-k' : '';
@ -451,26 +453,48 @@ export const UnifiedAgents: Component = () => {
<Show when={commandsUnlocked()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableDocker()}
onChange={(e) => setEnableDocker(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Enable Docker monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="Skip TLS certificate verification (for self-signed certificates)">
<input
type="checkbox"
checked={insecureMode()}
onChange={(e) => setInsecureMode(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Skip certificate verification
</label>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableDocker()}
onChange={(e) => setEnableDocker(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Docker monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="For Proxmox VE/PBS nodes - auto-creates API token and registers the node">
<input
type="checkbox"
checked={enableProxmox()}
onChange={(e) => setEnableProxmox(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Proxmox setup
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="Skip TLS certificate verification (for self-signed certificates)">
<input
type="checkbox"
checked={insecureMode()}
onChange={(e) => setInsecureMode(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Skip TLS verify
</label>
</div>
</div>
<Show when={enableProxmox()}>
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<p class="font-medium">Proxmox auto-setup enabled</p>
<p class="text-xs mt-1 text-blue-700 dark:text-blue-300">
The agent will create a <code class="bg-blue-100 dark:bg-blue-900/40 px-1 rounded">pulse-monitor</code> user and API token on the Proxmox node,
then register it with Pulse automatically. Includes temperature monitoring.
</p>
</div>
</Show>
</div>
<div class="space-y-4">
@ -490,25 +514,22 @@ export const UnifiedAgents: Component = () => {
if (insecureMode() && cmd.includes('curl -fsSL')) {
cmd = cmd.replace('curl -fsSL', 'curl -kfsSL');
}
// For bash scripts (not PowerShell), append flags directly
const isBashScript = !cmd.includes('$env:') && !cmd.includes('irm');
// Append docker flag if enabled
if (enableDocker()) {
// For PowerShell, we need to handle the env var or args differently
if (cmd.includes('$env:PULSE_URL')) {
// Env var style: add $env:PULSE_ENABLE_DOCKER="true";
cmd = `$env:PULSE_ENABLE_DOCKER="true"; ` + cmd;
} else if (cmd.includes('irm')) {
// Simple irm style: no args passed to script directly in this snippet style
// Actually, the simple irm style relies on prompts, so flags don't apply directly unless we change the snippet
// But for the bash script, we append flags
if (!cmd.includes('irm')) {
cmd += getDockerFlag();
}
} else {
} else if (isBashScript) {
cmd += getDockerFlag();
}
}
// Append proxmox flag if enabled (Linux only - Proxmox doesn't run on Windows/macOS)
if (enableProxmox() && isBashScript) {
cmd += getProxmoxFlag();
}
// Append insecure flag for agent if enabled
if (insecureMode() && !cmd.includes('$env:') && !cmd.includes('irm')) {
if (insecureMode() && isBashScript) {
cmd += getInsecureFlag();
}
return cmd;

View file

@ -91,6 +91,7 @@ export interface AIStreamToolStartData {
export interface AIStreamToolEndData {
name: string;
input: string;
output: string;
success: boolean;
}

View file

@ -329,6 +329,7 @@ export interface Host {
loadAverage?: number[];
memory: Memory;
disks?: Disk[];
diskIO?: HostDiskIO[];
networkInterfaces?: HostNetworkInterface[];
sensors?: HostSensorSummary;
raid?: HostRAIDArray[];
@ -384,6 +385,17 @@ export interface HostRAIDDevice {
slot: number;
}
export interface HostDiskIO {
device: string;
readBytes?: number;
writeBytes?: number;
readOps?: number;
writeOps?: number;
readTimeMs?: number;
writeTimeMs?: number;
ioTimeMs?: number;
}
export interface HostLookupResponse {
success: boolean;
host: {

View file

@ -91,6 +91,7 @@ export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & {
temperature?: Temperature;
displayName?: string;
temperatureTransport?: TemperatureTransport;
source?: 'agent' | 'script' | ''; // How this node was registered
};
export interface NodesResponse {

View file

@ -25,6 +25,11 @@ export function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) return '0';
return value.toLocaleString();
}
export function formatUptime(seconds: number, condensed = false): string {
if (!seconds || seconds < 0) return '0s';

View file

@ -51,6 +51,24 @@ export default defineConfig({
target: backendWsUrl,
ws: true,
changeOrigin: true,
// Browser WebSocket connections are long-lived, disable timeouts
proxyTimeout: 0,
timeout: 0,
configure: (proxy, _options) => {
proxy.options.timeout = 0;
proxy.options.proxyTimeout = 0;
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
});
proxy.on('open', (proxySocket) => {
proxySocket.setTimeout(0);
proxySocket.setNoDelay(true);
proxySocket.setKeepAlive(true, 30000);
});
},
},
'/api/ai/execute/stream': {
target: backendUrl,
@ -86,6 +104,32 @@ export default defineConfig({
target: backendWsUrl,
ws: true,
changeOrigin: true,
// Agent WebSocket connections are long-lived, disable timeouts
// proxyTimeout: 0 disables the proxy-to-target timeout
// timeout: 0 disables the client-to-proxy timeout
proxyTimeout: 0,
timeout: 0,
configure: (proxy, _options) => {
// Disable http-proxy's internal timeout (default is 2 minutes but seems to be 10s)
proxy.options.timeout = 0;
proxy.options.proxyTimeout = 0;
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
// Disable socket timeouts for WebSocket connections
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
});
proxy.on('open', (proxySocket) => {
// Also disable timeout on the proxy socket
proxySocket.setTimeout(0);
proxySocket.setNoDelay(true);
proxySocket.setKeepAlive(true, 30000);
});
proxy.on('error', (err, req, res) => {
console.error('[Agent WS Proxy Error]', err.message);
});
},
},
'/api': {
target: backendUrl,

View file

@ -46,12 +46,31 @@ func NewServer(validateToken func(token string) bool) *Server {
// HandleWebSocket handles incoming WebSocket connections from agents
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// CRITICAL: Clear http.Server deadlines BEFORE WebSocket upgrade.
// The http.Server.ReadTimeout sets a deadline on the underlying connection when
// the request starts. We must clear it before the upgrade or the connection will
// be closed when that deadline fires (typically ~15 seconds after connection).
// Use http.ResponseController (Go 1.20+) to clear the deadline.
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Time{}); err != nil {
log.Debug().Err(err).Msg("Failed to clear read deadline via ResponseController")
}
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
log.Debug().Err(err).Msg("Failed to clear write deadline via ResponseController")
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to upgrade WebSocket connection")
return
}
// Also clear on the WebSocket's underlying connection as a safety net
if netConn := conn.NetConn(); netConn != nil {
netConn.SetReadDeadline(time.Time{})
netConn.SetWriteDeadline(time.Time{})
}
// Read first message (must be agent_register)
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
_, msgBytes, err := conn.ReadMessage()
@ -139,8 +158,27 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Payload: RegisteredPayload{Success: true, Message: "Registered"},
})
// Clear deadline for normal operation
// Clear deadline for normal operation - both on the WebSocket and underlying connection
// This is critical because Go's http.Server.ReadTimeout may have set a deadline
// on the underlying connection before the WebSocket upgrade occurred.
conn.SetReadDeadline(time.Time{})
conn.SetWriteDeadline(time.Time{})
if netConn := conn.NetConn(); netConn != nil {
netConn.SetReadDeadline(time.Time{})
netConn.SetWriteDeadline(time.Time{})
}
// Set up ping/pong handlers to keep connection alive
conn.SetPongHandler(func(appData string) error {
// Reset read deadline on pong received
conn.SetReadDeadline(time.Time{})
return nil
})
// Start server-side ping loop to keep connection alive
pingDone := make(chan struct{})
go s.pingLoop(ac, pingDone)
defer close(pingDone)
// Run read loop (blocking) - don't use goroutine, or HTTP handler will close connection
s.readLoop(ac)
@ -214,6 +252,28 @@ func (s *Server) readLoop(ac *agentConn) {
}
}
func (s *Server) pingLoop(ac *agentConn, done chan struct{}) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ac.done:
return
case <-ticker.C:
ac.writeMu.Lock()
err := ac.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second))
ac.writeMu.Unlock()
if err != nil {
log.Debug().Err(err).Str("agent_id", ac.agent.AgentID).Msg("Failed to send ping to agent")
return
}
}
}
}
func (s *Server) sendMessage(conn *websocket.Conn, msg Message) error {
msgBytes, err := json.Marshal(msg)
if err != nil {

View file

@ -106,11 +106,11 @@ func (u *Updater) RunLoop(ctx context.Context) {
return
}
// Initial check after a short delay
// Initial check after a short delay (5s to quickly update outdated agents)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
case <-time.After(5 * time.Second):
u.CheckAndUpdate(ctx)
}
@ -156,13 +156,23 @@ func (u *Updater) CheckAndUpdate(ctx context.Context) {
return
}
// Normalize both versions by stripping "v" prefix for comparison.
// Server returns version without prefix (e.g., "4.33.1"), but agent's
// CurrentVersion may include it (e.g., "v4.33.1") depending on build.
if utils.NormalizeVersion(serverVersion) == utils.NormalizeVersion(u.cfg.CurrentVersion) {
// Compare versions using semver comparison to only update to newer versions.
// This prevents accidental downgrades if the server temporarily has an older version.
currentNorm := utils.NormalizeVersion(u.cfg.CurrentVersion)
serverNorm := utils.NormalizeVersion(serverVersion)
cmp := utils.CompareVersions(serverNorm, currentNorm)
if cmp == 0 {
u.logger.Debug().Str("version", u.cfg.CurrentVersion).Msg("Agent is up to date")
return
}
if cmp < 0 {
u.logger.Debug().
Str("currentVersion", currentNorm).
Str("serverVersion", serverNorm).
Msg("Server has older version, skipping downgrade")
return
}
u.logger.Info().
Str("currentVersion", u.cfg.CurrentVersion).
@ -402,6 +412,10 @@ func (u *Updater) performUpdate(ctx context.Context) error {
// Remove backup on success
os.Remove(backupPath)
// Write previous version to a file so the agent can report "updated from X" on next start
updateInfoPath := filepath.Join(targetDir, ".pulse-update-info")
_ = os.WriteFile(updateInfoPath, []byte(u.cfg.CurrentVersion), 0644)
// On Unraid, also update the persistent copy on the flash drive
// This ensures the update survives reboots
if isUnraid() {
@ -433,6 +447,32 @@ func (u *Updater) performUpdate(ctx context.Context) error {
return restartProcess(execPath)
}
// GetUpdatedFromVersion checks if the agent was recently updated and returns the previous version.
// Returns empty string if no update info exists. Clears the info file after reading.
func GetUpdatedFromVersion() string {
execPath, err := os.Executable()
if err != nil {
return ""
}
// Resolve symlinks to get the real path
realExecPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
realExecPath = execPath
}
updateInfoPath := filepath.Join(filepath.Dir(realExecPath), ".pulse-update-info")
data, err := os.ReadFile(updateInfoPath)
if err != nil {
return ""
}
// Clear the file after reading (only report once)
os.Remove(updateInfoPath)
return strings.TrimSpace(string(data))
}
// determineArch returns the architecture string for download URLs (e.g., "linux-amd64", "darwin-arm64").
func determineArch() string {
os := runtime.GOOS

View file

@ -8,11 +8,15 @@ import (
"io"
"net/http"
"time"
"github.com/rs/zerolog/log"
)
const (
anthropicAPIURL = "https://api.anthropic.com/v1/messages"
anthropicAPIVersion = "2023-06-01"
maxRetries = 3
initialBackoff = 2 * time.Second
)
// AnthropicClient implements the Provider interface for Anthropic's Claude API
@ -195,32 +199,76 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Retry loop for transient errors (429, 529, 5xx)
var respBody []byte
var lastErr error
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 2s, 4s, 8s
backoff := initialBackoff * time.Duration(1<<(attempt-1))
log.Warn().
Int("attempt", attempt).
Dur("backoff", backoff).
Str("last_error", lastErr.Error()).
Msg("Retrying Anthropic API request after transient error")
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp anthropicError
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error.Message)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
httpReq, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
resp, err := c.client.Do(httpReq)
if err != nil {
lastErr = fmt.Errorf("request failed: %w", err)
continue
}
respBody, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("failed to read response: %w", err)
continue
}
// Check if this is a retryable error
if resp.StatusCode == 429 || resp.StatusCode == 529 || resp.StatusCode >= 500 {
var errResp anthropicError
errMsg := string(respBody)
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
errMsg = errResp.Error.Message
}
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, errMsg)
continue
}
// Non-retryable error
if resp.StatusCode != http.StatusOK {
var errResp anthropicError
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error.Message)
}
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
// Success - break out of retry loop
lastErr = nil
break
}
if lastErr != nil {
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}
var anthropicResp anthropicResponse

View file

@ -369,6 +369,7 @@ type ToolStartData struct {
// ToolEndData is sent when a tool execution completes
type ToolEndData struct {
Name string `json:"name"`
Input string `json:"input"`
Output string `json:"output"`
Success bool `json:"success"`
}
@ -663,7 +664,7 @@ Always execute the commands rather than telling the user how to do it.`
// Stream tool end event
callback(StreamEvent{
Type: "tool_end",
Data: ToolEndData{Name: tc.Name, Output: result, Success: execution.Success},
Data: ToolEndData{Name: tc.Name, Input: toolInput, Output: result, Success: execution.Success},
})
}
@ -931,14 +932,26 @@ func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, comman
// Fall back to context-based routing if VMID lookup didn't find anything
if targetNode == "" {
// Extract node info from target ID (e.g., "delly-135" -> "delly")
// For host targets, use the hostname directly from context
if req.TargetType == "host" {
if hostname, ok := req.Context["hostname"].(string); ok && hostname != "" {
targetNode = strings.ToLower(hostname)
log.Debug().
Str("hostname", hostname).
Str("target_type", req.TargetType).
Msg("Using hostname from context for host target routing")
}
}
// For VMs/containers, extract node info from target ID (e.g., "delly-135" -> "delly")
// or from context (guest_node field)
if node, ok := req.Context["guest_node"].(string); ok && node != "" {
targetNode = strings.ToLower(node)
} else if req.TargetID != "" {
parts := strings.Split(req.TargetID, "-")
if len(parts) >= 2 {
targetNode = strings.ToLower(parts[0])
if targetNode == "" {
if node, ok := req.Context["guest_node"].(string); ok && node != "" {
targetNode = strings.ToLower(node)
} else if req.TargetID != "" {
parts := strings.Split(req.TargetID, "-")
if len(parts) >= 2 {
targetNode = strings.ToLower(parts[0])
}
}
}
}

View file

@ -14688,7 +14688,6 @@ func TestLoadActiveAlerts(t *testing.T) {
t.Run("loads alerts from valid file", func(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
// Create an alert and save it
startTime := time.Now().Add(-30 * time.Minute)
@ -14714,8 +14713,10 @@ func TestLoadActiveAlerts(t *testing.T) {
// Save to disk
_ = m.SaveActiveAlerts()
// Clear and reload
m.ClearActiveAlerts()
// Clear in-memory map only (don't use ClearActiveAlerts which triggers async save)
m.mu.Lock()
m.activeAlerts = make(map[string]*Alert)
m.mu.Unlock()
err := m.LoadActiveAlerts()
if err != nil {

View file

@ -430,6 +430,7 @@ type NodeResponse struct {
IsCluster bool `json:"isCluster,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"`
Source string `json:"source,omitempty"` // "agent" or "script" - how this node was registered
}
func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string {
@ -834,6 +835,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
IsCluster: pve.IsCluster,
ClusterName: pve.ClusterName,
ClusterEndpoints: pve.ClusterEndpoints,
Source: pve.Source,
}
nodes = append(nodes, node)
}
@ -859,6 +861,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorPruneJobs: pbs.MonitorPruneJobs,
MonitorGarbageJobs: pbs.MonitorGarbageJobs,
Status: h.getNodeStatus("pbs", pbs.Name),
Source: pbs.Source,
}
nodes = append(nodes, node)
}
@ -5539,7 +5542,7 @@ func (h *ConfigHandlers) HandleUpdateMockMode(w http.ResponseWriter, r *http.Req
}
}
// AutoRegisterRequest represents a request from the setup script to auto-register a node
// AutoRegisterRequest represents a request from the setup script or agent to auto-register a node
type AutoRegisterRequest struct {
Type string `json:"type"` // "pve" or "pbs"
Host string `json:"host"` // The host URL
@ -5548,6 +5551,7 @@ type AutoRegisterRequest struct {
ServerName string `json:"serverName"` // Hostname or IP
SetupCode string `json:"setupCode,omitempty"` // One-time setup code for authentication (deprecated)
AuthToken string `json:"authToken,omitempty"` // Direct auth token from URL (new approach)
Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered
// New secure fields
RequestToken bool `json:"requestToken,omitempty"` // If true, Pulse will generate and return a token
Username string `json:"username,omitempty"` // Username for creating token (e.g., "root@pam")
@ -5752,18 +5756,29 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
MonitorGarbageJobs: &boolFalse,
}
// Check if a node with this host already exists
// Check if a node with this host or name already exists
// Match by host URL or by node name (to handle hostname vs IP differences)
existingIndex := -1
if req.Type == "pve" {
for i, node := range h.config.PVEInstances {
if node.Host == host { // Use normalized host for comparison
if node.Host == host {
existingIndex = i
break
}
// Also match by name if ServerName matches existing node name
if req.ServerName != "" && strings.EqualFold(node.Name, req.ServerName) {
existingIndex = i
break
}
}
} else {
for i, node := range h.config.PBSInstances {
if node.Host == host { // Use normalized host for comparison
if node.Host == host {
existingIndex = i
break
}
// Also match by name if ServerName matches existing node name
if req.ServerName != "" && strings.EqualFold(node.Name, req.ServerName) {
existingIndex = i
break
}
@ -5780,6 +5795,10 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
instance.Password = ""
instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue
// Update source if provided (allows upgrade from script to agent)
if req.Source != "" {
instance.Source = req.Source
}
// Check for cluster if not already detected
if !instance.IsCluster {
@ -5809,6 +5828,10 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
instance.Password = ""
instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue
// Update source if provided (allows upgrade from script to agent)
if req.Source != "" {
instance.Source = req.Source
}
// Keep other settings as they were
}
log.Info().
@ -5864,6 +5887,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
IsCluster: isCluster,
ClusterName: clusterName,
ClusterEndpoints: clusterEndpoints,
Source: req.Source, // Track how this node was registered
}
h.config.PVEInstances = append(h.config.PVEInstances, newInstance)
@ -5911,6 +5935,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
MonitorVerifyJobs: monitorVerifyJobs,
MonitorPruneJobs: monitorPruneJobs,
MonitorGarbageJobs: monitorGarbageJobs,
Source: req.Source, // Track how this node was registered
}
h.config.PBSInstances = append(h.config.PBSInstances, newInstance)
}
@ -6272,3 +6297,111 @@ func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyP
return publicKeyString
}
// AgentInstallCommandRequest represents a request for an agent install command
type AgentInstallCommandRequest struct {
Type string `json:"type"` // "pve" or "pbs"
}
// AgentInstallCommandResponse contains the generated install command
type AgentInstallCommandResponse struct {
Command string `json:"command"`
Token string `json:"token"`
}
// HandleAgentInstallCommand generates an API token and install command for agent-based Proxmox setup
func (h *ConfigHandlers) HandleAgentInstallCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AgentInstallCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate type
if req.Type != "pve" && req.Type != "pbs" {
http.Error(w, "Type must be 'pve' or 'pbs'", http.StatusBadRequest)
return
}
// Generate a new API token with host report and host manage scopes
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
log.Error().Err(err).Msg("Failed to generate API token for agent install")
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
return
}
tokenName := fmt.Sprintf("proxmox-agent-%s-%d", req.Type, time.Now().Unix())
scopes := []string{
config.ScopeHostReport,
config.ScopeHostManage,
}
record, err := config.NewAPITokenRecord(rawToken, tokenName, scopes)
if err != nil {
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Persist the token
config.Mu.Lock()
h.config.APITokens = append(h.config.APITokens, *record)
h.config.SortAPITokens()
h.config.APITokenEnabled = true
if h.persistence != nil {
if err := h.persistence.SaveAPITokens(h.config.APITokens); err != nil {
// Rollback the in-memory addition
h.config.APITokens = h.config.APITokens[:len(h.config.APITokens)-1]
config.Mu.Unlock()
log.Error().Err(err).Msg("Failed to persist API tokens after creation")
http.Error(w, "Failed to save token to disk: "+err.Error(), http.StatusInternalServerError)
return
}
}
config.Mu.Unlock()
// Derive Pulse URL from the request
host := r.Host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
if (parsedHost == "127.0.0.1" || parsedHost == "localhost") && parsedPort == strconv.Itoa(h.config.FrontendPort) {
// Prefer a user-configured public URL when we're running on loopback
if publicURL := strings.TrimSpace(h.config.PublicURL); publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Host != "" {
host = parsedURL.Host
}
}
}
}
// Detect protocol - check both TLS and proxy headers
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
pulseURL := fmt.Sprintf("%s://%s", scheme, host)
// Generate the install command
command := fmt.Sprintf(`curl -fsSL %s/install.sh | bash -s -- \
--url %s \
--token %s \
--enable-proxmox`,
pulseURL, pulseURL, rawToken)
log.Info().
Str("token_name", tokenName).
Str("type", req.Type).
Msg("Generated agent install command with API token")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
Command: command,
Token: rawToken,
})
}

View file

@ -923,6 +923,9 @@ func (r *Router) setupRoutes() {
// Generate setup script URL with temporary token (for authenticated users)
r.mux.HandleFunc("/api/setup-script-url", r.configHandlers.HandleSetupScriptURL)
// Generate agent install command with API token (for authenticated users)
r.mux.HandleFunc("/api/agent-install-command", RequireAuth(r.config, r.configHandlers.HandleAgentInstallCommand))
// Auto-register route for setup scripts
r.mux.HandleFunc("/api/auto-register", r.configHandlers.HandleAutoRegister)
// Discovery endpoint

View file

@ -424,6 +424,9 @@ type PVEInstance struct {
IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes
// Agent tracking
Source string // "agent" or "script" - how this node was registered (empty = legacy/manual)
}
// ClusterEndpoint represents a single node in a cluster
@ -461,6 +464,9 @@ type PBSInstance struct {
MonitorGarbageJobs bool
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
SSHPort int // SSH port for temperature monitoring (0 = use global default)
// Agent tracking
Source string // "agent" or "script" - how this node was registered (empty = legacy/manual)
}
// PMGInstance represents a Proxmox Mail Gateway connection

View file

@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/buffer"
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
"github.com/rcourtman/pulse-go-rewrite/internal/mdadm"
@ -35,6 +36,10 @@ type Config struct {
RunOnce bool
LogLevel zerolog.Level
Logger *zerolog.Logger
// Proxmox integration
EnableProxmox bool // If true, creates Proxmox API token and registers node on startup
ProxmoxType string // "pve", "pbs", or "" for auto-detect
}
// Agent is responsible for collecting host metrics and shipping them to Pulse.
@ -54,6 +59,7 @@ type Agent struct {
machineID string
agentID string
agentVersion string
updatedFrom string // Previous version if recently auto-updated (reported once)
interval time.Duration
trimmedPulseURL string
reportBuffer *buffer.Queue[agentshost.Report]
@ -169,6 +175,15 @@ func New(cfg Config) (*Agent, error) {
const bufferCapacity = 60
// Check if agent was recently auto-updated (only reported once per restart)
updatedFrom := agentupdate.GetUpdatedFromVersion()
if updatedFrom != "" {
logger.Info().
Str("previousVersion", updatedFrom).
Str("currentVersion", agentVersion).
Msg("Agent was auto-updated")
}
agent := &Agent{
cfg: cfg,
logger: logger,
@ -184,6 +199,7 @@ func New(cfg Config) (*Agent, error) {
machineID: machineID,
agentID: agentID,
agentVersion: agentVersion,
updatedFrom: updatedFrom,
interval: cfg.Interval,
trimmedPulseURL: pulseURL,
reportBuffer: buffer.New[agentshost.Report](bufferCapacity),
@ -201,6 +217,11 @@ func (a *Agent) Run(ctx context.Context) error {
return a.runOnce(ctx)
}
// Proxmox setup (if enabled)
if a.cfg.EnableProxmox {
a.runProxmoxSetup(ctx)
}
// Start command client in background for AI command execution
if a.commandClient != nil {
go func() {
@ -304,6 +325,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Type: a.cfg.AgentType,
IntervalSeconds: int(a.interval / time.Second),
Hostname: a.hostname,
UpdatedFrom: a.updatedFrom,
},
Host: agentshost.HostInfo{
ID: a.machineID,
@ -325,6 +347,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Memory: snapshot.Memory,
},
Disks: append([]agentshost.Disk(nil), snapshot.Disks...),
DiskIO: append([]agentshost.DiskIO(nil), snapshot.DiskIO...),
Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...),
Sensors: sensorData,
RAID: raidData,
@ -458,3 +481,42 @@ func (a *Agent) collectRAIDArrays(ctx context.Context) []agentshost.RAIDArray {
return arrays
}
// runProxmoxSetup performs one-time Proxmox API token setup and node registration.
func (a *Agent) runProxmoxSetup(ctx context.Context) {
a.logger.Info().Msg("Proxmox mode enabled, checking setup...")
setup := NewProxmoxSetup(
a.logger,
a.httpClient,
a.trimmedPulseURL,
a.cfg.APIToken,
a.cfg.ProxmoxType,
a.hostname,
a.cfg.InsecureSkipVerify,
)
result, err := setup.Run(ctx)
if err != nil {
a.logger.Error().Err(err).Msg("Proxmox setup failed")
return
}
if result == nil {
// Already registered
return
}
if result.Registered {
a.logger.Info().
Str("type", result.ProxmoxType).
Str("host", result.NodeHost).
Str("token_id", result.TokenID).
Msg("Proxmox node registered successfully")
} else {
a.logger.Warn().
Str("type", result.ProxmoxType).
Str("host", result.NodeHost).
Msg("Proxmox token created but registration failed (node may need manual configuration)")
}
}

View file

@ -140,7 +140,7 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: c.insecureSkipVerify,
},
HandshakeTimeout: 10 * time.Second,
HandshakeTimeout: 45 * time.Second,
}
// Connect
@ -172,6 +172,15 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
c.logger.Info().Msg("Connected and registered with Pulse command server")
// Clear any deadlines that may have been set during handshake
// The HandshakeTimeout in the Dialer may have set a deadline on the underlying connection
conn.SetReadDeadline(time.Time{})
conn.SetWriteDeadline(time.Time{})
if netConn := conn.NetConn(); netConn != nil {
netConn.SetReadDeadline(time.Time{})
netConn.SetWriteDeadline(time.Time{})
}
// Start ping loop
pingDone := make(chan struct{})
go c.pingLoop(ctx, conn, pingDone)

View file

@ -0,0 +1,344 @@
package hostagent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/rs/zerolog"
)
// ProxmoxSetup handles one-time Proxmox API token creation and node registration.
type ProxmoxSetup struct {
logger zerolog.Logger
httpClient *http.Client
pulseURL string
apiToken string
proxmoxType string // "pve", "pbs", or "" for auto-detect
hostname string
insecureSkipVerify bool
}
// ProxmoxSetupResult contains the result of a successful Proxmox setup.
type ProxmoxSetupResult struct {
ProxmoxType string // "pve" or "pbs"
TokenID string // Full token ID (e.g., "pulse-monitor@pam!pulse-1234567890")
TokenValue string // The secret token value
NodeHost string // The host URL for auto-registration
Registered bool // Whether the node was successfully registered with Pulse
}
const (
proxmoxUser = "pulse-monitor"
proxmoxUserPVE = "pulse-monitor@pam"
proxmoxUserPBS = "pulse-monitor@pbs"
proxmoxComment = "Pulse monitoring service"
stateFilePath = "/var/lib/pulse-agent/proxmox-registered"
stateFileDir = "/var/lib/pulse-agent"
)
// NewProxmoxSetup creates a new ProxmoxSetup instance.
func NewProxmoxSetup(logger zerolog.Logger, httpClient *http.Client, pulseURL, apiToken, proxmoxType, hostname string, insecure bool) *ProxmoxSetup {
return &ProxmoxSetup{
logger: logger,
httpClient: httpClient,
pulseURL: strings.TrimRight(pulseURL, "/"),
apiToken: apiToken,
proxmoxType: proxmoxType,
hostname: hostname,
insecureSkipVerify: insecure,
}
}
// Run executes the Proxmox setup process:
// 1. Detects Proxmox type (if not specified)
// 2. Creates the monitoring user and API token
// 3. Registers the node with Pulse via auto-register
func (p *ProxmoxSetup) Run(ctx context.Context) (*ProxmoxSetupResult, error) {
// Check if already registered (idempotency)
if p.isAlreadyRegistered() {
p.logger.Info().Msg("Proxmox node already registered, skipping setup")
return nil, nil
}
// Detect Proxmox type
ptype := p.proxmoxType
if ptype == "" {
detected := p.detectProxmoxType()
if detected == "" {
return nil, fmt.Errorf("this system does not appear to be a Proxmox VE or PBS node")
}
ptype = detected
p.logger.Info().Str("type", ptype).Msg("Auto-detected Proxmox type")
}
// Create monitoring user and token
tokenID, tokenValue, err := p.setupToken(ctx, ptype)
if err != nil {
return nil, fmt.Errorf("failed to create Proxmox API token: %w", err)
}
p.logger.Info().Str("token_id", tokenID).Msg("Created Proxmox API token")
// Get the host URL for registration
hostURL := p.getHostURL(ptype)
// Register with Pulse
registered := false
if err := p.registerWithPulse(ctx, ptype, hostURL, tokenID, tokenValue); err != nil {
p.logger.Warn().Err(err).Msg("Failed to register with Pulse (node may already exist)")
} else {
registered = true
p.markAsRegistered()
p.logger.Info().Str("host", hostURL).Msg("Successfully registered Proxmox node with Pulse")
}
return &ProxmoxSetupResult{
ProxmoxType: ptype,
TokenID: tokenID,
TokenValue: tokenValue,
NodeHost: hostURL,
Registered: registered,
}, nil
}
// detectProxmoxType checks for pvesh (PVE) or proxmox-backup-manager (PBS).
func (p *ProxmoxSetup) detectProxmoxType() string {
// Check for PVE first
if _, err := exec.LookPath("pvesh"); err == nil {
return "pve"
}
// Check for PBS
if _, err := exec.LookPath("proxmox-backup-manager"); err == nil {
return "pbs"
}
return ""
}
// setupToken creates the monitoring user and API token.
func (p *ProxmoxSetup) setupToken(ctx context.Context, ptype string) (string, string, error) {
tokenName := fmt.Sprintf("pulse-%d", time.Now().Unix())
if ptype == "pve" {
return p.setupPVEToken(ctx, tokenName)
}
return p.setupPBSToken(ctx, tokenName)
}
// setupPVEToken creates a PVE monitoring user and token.
func (p *ProxmoxSetup) setupPVEToken(ctx context.Context, tokenName string) (string, string, error) {
// Create user (ignore error if already exists)
_ = runCommand(ctx, "pveum", "user", "add", proxmoxUserPVE, "--comment", proxmoxComment)
// Add PVEAuditor role
if err := runCommand(ctx, "pveum", "aclmod", "/", "-user", proxmoxUserPVE, "-role", "PVEAuditor"); err != nil {
p.logger.Warn().Err(err).Msg("Failed to add PVEAuditor role (may already exist)")
}
// Try to create PulseMonitor role with additional privileges
_ = runCommand(ctx, "pveum", "role", "add", "PulseMonitor", "-privs", "Sys.Audit,VM.Monitor,Datastore.Audit")
_ = runCommand(ctx, "pveum", "aclmod", "/", "-user", proxmoxUserPVE, "-role", "PulseMonitor")
// Create token with privilege separation disabled
output, err := runCommandOutput(ctx, "pveum", "user", "token", "add", proxmoxUserPVE, tokenName, "--privsep", "0")
if err != nil {
return "", "", fmt.Errorf("failed to create token: %w", err)
}
// Parse token value from output
tokenValue := p.parseTokenValue(output)
if tokenValue == "" {
return "", "", fmt.Errorf("failed to parse token value from pveum output")
}
tokenID := fmt.Sprintf("%s!%s", proxmoxUserPVE, tokenName)
return tokenID, tokenValue, nil
}
// setupPBSToken creates a PBS monitoring user and token.
func (p *ProxmoxSetup) setupPBSToken(ctx context.Context, tokenName string) (string, string, error) {
// Create user (ignore error if already exists)
_ = runCommand(ctx, "proxmox-backup-manager", "user", "create", proxmoxUserPBS)
// Add Audit role
_ = runCommand(ctx, "proxmox-backup-manager", "acl", "update", "/", "Audit", "--auth-id", proxmoxUserPBS)
// Create token
output, err := runCommandOutput(ctx, "proxmox-backup-manager", "user", "generate-token", proxmoxUserPBS, tokenName)
if err != nil {
return "", "", fmt.Errorf("failed to create token: %w", err)
}
// Parse token value from JSON output
tokenValue := p.parsePBSTokenValue(output)
if tokenValue == "" {
return "", "", fmt.Errorf("failed to parse token value from PBS output")
}
// Add Audit role for the token itself
tokenID := fmt.Sprintf("%s!%s", proxmoxUserPBS, tokenName)
_ = runCommand(ctx, "proxmox-backup-manager", "acl", "update", "/", "Audit", "--auth-id", tokenID)
return tokenID, tokenValue, nil
}
// parseTokenValue extracts the token value from PVE pveum output.
// The output format is typically a table with columns: │ key │ value │
// Example output:
// ┌──────────────┬──────────────────────────────────────┐
// │ key │ value │
// ╞══════════════╪══════════════════════════════════════╡
// │ full-tokenid │ pulse-monitor@pam!pulse-token │
// ├──────────────┼──────────────────────────────────────┤
// │ info │ {"privsep":"0"} │
// ├──────────────┼──────────────────────────────────────┤
// │ value │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │
// └──────────────┴──────────────────────────────────────┘
func (p *ProxmoxSetup) parseTokenValue(output string) string {
// Look for the "value" row in the table output - where "value" is the KEY column
// We need to find the row where parts[1] (key column) is "value", not any row
// containing the word "value" (which would match the header row)
lines := strings.Split(output, "\n")
for _, line := range lines {
// Split by the box drawing character │ (U+2502)
parts := strings.Split(line, "│")
if len(parts) >= 3 {
key := strings.TrimSpace(parts[1])
if key == "value" {
return strings.TrimSpace(parts[2])
}
}
}
// Fallback: try regex for UUID-like token
re := regexp.MustCompile(`[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}`)
if match := re.FindString(output); match != "" {
return match
}
return ""
}
// parsePBSTokenValue extracts the token value from PBS JSON output.
func (p *ProxmoxSetup) parsePBSTokenValue(output string) string {
// PBS outputs JSON like: {"tokenid": "...", "value": "..."}
var result struct {
Value string `json:"value"`
}
if err := json.Unmarshal([]byte(output), &result); err == nil && result.Value != "" {
return result.Value
}
// Fallback: extract from "value": "..." pattern
re := regexp.MustCompile(`"value"\s*:\s*"([^"]+)"`)
if matches := re.FindStringSubmatch(output); len(matches) >= 2 {
return matches[1]
}
return ""
}
// getHostURL constructs the host URL for this Proxmox node.
// Prefers IP address over hostname since hostnames are often not DNS-resolvable.
func (p *ProxmoxSetup) getHostURL(ptype string) string {
port := "8006"
if ptype == "pbs" {
port = "8007"
}
// Always prefer IP address since hostnames often can't be resolved by Pulse
// (e.g., "pi" hostname won't resolve via DNS)
if out, err := exec.Command("hostname", "-I").Output(); err == nil {
ips := strings.Fields(string(out))
if len(ips) > 0 {
// Use first non-loopback IP
for _, ip := range ips {
if ip != "127.0.0.1" && ip != "::1" {
return fmt.Sprintf("https://%s:%s", ip, port)
}
}
}
}
// Fallback to hostname if IP detection failed
hostname := p.hostname
if hostname == "" {
hostname = "localhost"
}
return fmt.Sprintf("https://%s:%s", hostname, port)
}
// registerWithPulse calls the auto-register endpoint to add the node.
func (p *ProxmoxSetup) registerWithPulse(ctx context.Context, ptype, hostURL, tokenID, tokenValue string) error {
payload := map[string]interface{}{
"type": ptype,
"host": hostURL,
"serverName": p.hostname,
"tokenId": tokenID,
"tokenValue": tokenValue,
"source": "agent", // Indicates this was registered via agent
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.pulseURL+"/api/auto-register", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Token", p.apiToken)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("auto-register returned %d", resp.StatusCode)
}
return nil
}
// isAlreadyRegistered checks if we've already done Proxmox setup.
func (p *ProxmoxSetup) isAlreadyRegistered() bool {
_, err := os.Stat(stateFilePath)
return err == nil
}
// markAsRegistered creates a state file to indicate setup is complete.
func (p *ProxmoxSetup) markAsRegistered() {
if err := os.MkdirAll(stateFileDir, 0755); err != nil {
p.logger.Warn().Err(err).Msg("Failed to create state directory")
return
}
if err := os.WriteFile(stateFilePath, []byte(time.Now().Format(time.RFC3339)), 0644); err != nil {
p.logger.Warn().Err(err).Msg("Failed to write state file")
}
}
// runCommand executes a command and returns any error.
func runCommand(ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
return cmd.Run()
}
// runCommandOutput executes a command and returns the output.
func runCommandOutput(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.CombinedOutput()
return string(output), err
}

View file

@ -18,14 +18,15 @@ import (
// System call wrappers for testing
var (
cpuCounts = gocpu.CountsWithContext
cpuPercent = gocpu.PercentWithContext
loadAvg = goload.AvgWithContext
virtualMemory = gomem.VirtualMemoryWithContext
diskPartitions = godisk.PartitionsWithContext
diskUsage = godisk.UsageWithContext
netInterfaces = gonet.InterfacesWithContext
netIOCounters = gonet.IOCountersWithContext
cpuCounts = gocpu.CountsWithContext
cpuPercent = gocpu.PercentWithContext
loadAvg = goload.AvgWithContext
virtualMemory = gomem.VirtualMemoryWithContext
diskPartitions = godisk.PartitionsWithContext
diskUsage = godisk.UsageWithContext
diskIOCounters = godisk.IOCountersWithContext
netInterfaces = gonet.InterfacesWithContext
netIOCounters = gonet.IOCountersWithContext
)
// Snapshot represents a host resource utilisation sample.
@ -35,6 +36,7 @@ type Snapshot struct {
LoadAverage []float64
Memory agentshost.MemoryMetric
Disks []agentshost.Disk
DiskIO []agentshost.DiskIO
Network []agentshost.NetworkInterface
}
@ -77,6 +79,7 @@ func Collect(ctx context.Context) (Snapshot, error) {
}
snapshot.Disks = collectDisks(collectCtx)
snapshot.DiskIO = collectDiskIO(collectCtx)
snapshot.Network = collectNetwork(collectCtx)
return snapshot, nil
@ -234,3 +237,78 @@ func isLoopback(flags []string) bool {
}
return false
}
// collectDiskIO gathers I/O statistics for physical block devices.
// Only reports whole disks (nvme0n1, sda), not partitions (nvme0n1p1, sda1).
func collectDiskIO(ctx context.Context) []agentshost.DiskIO {
counters, err := diskIOCounters(ctx)
if err != nil {
return nil
}
devices := make([]agentshost.DiskIO, 0, len(counters))
for name, stats := range counters {
// Skip partitions - only report whole devices
if isPartition(name) {
continue
}
// Skip loop devices and ram disks
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") {
continue
}
// Skip device-mapper and md devices (report at physical level)
if strings.HasPrefix(name, "dm-") {
continue
}
devices = append(devices, agentshost.DiskIO{
Device: name,
ReadBytes: stats.ReadBytes,
WriteBytes: stats.WriteBytes,
ReadOps: stats.ReadCount,
WriteOps: stats.WriteCount,
ReadTime: stats.ReadTime,
WriteTime: stats.WriteTime,
IOTime: stats.IoTime,
})
}
sort.Slice(devices, func(i, j int) bool { return devices[i].Device < devices[j].Device })
return devices
}
// isPartition returns true if the device name looks like a partition
// e.g., sda1, nvme0n1p1, vda2
func isPartition(name string) bool {
// NVMe partitions: nvme0n1p1, nvme0n1p2
if strings.Contains(name, "n") && strings.Contains(name, "p") {
// Check if it ends with pN where N is a digit
idx := strings.LastIndex(name, "p")
if idx > 0 && idx < len(name)-1 {
rest := name[idx+1:]
if len(rest) > 0 && rest[0] >= '0' && rest[0] <= '9' {
return true
}
}
}
// Traditional partitions: sda1, vda2, hda1
if len(name) > 2 {
last := name[len(name)-1]
if last >= '0' && last <= '9' {
// Check if second-to-last is a letter (sda1) or also a digit (sda10)
secondLast := name[len(name)-2]
if (secondLast >= 'a' && secondLast <= 'z') || (secondLast >= '0' && secondLast <= '9') {
// Exclude things like "md0" (whole device) - check for common prefixes
if strings.HasPrefix(name, "sd") || strings.HasPrefix(name, "vd") ||
strings.HasPrefix(name, "hd") || strings.HasPrefix(name, "xvd") {
return true
}
}
}
}
// ZFS devices: zd0p1, zd16p1
if strings.HasPrefix(name, "zd") && strings.Contains(name, "p") {
return true
}
return false
}

View file

@ -1,133 +1,25 @@
package hostmetrics
import (
"context"
"testing"
godisk "github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
"context"
"encoding/json"
"testing"
)
func TestCollectDisks_ZFS_Deduplication(t *testing.T) {
// Save original functions and restore after test
origDiskPartitions := diskPartitions
origDiskUsage := diskUsage
defer func() {
diskPartitions = origDiskPartitions
diskUsage = origDiskUsage
}()
// Mock partitions
diskPartitions = func(ctx context.Context, all bool) ([]godisk.PartitionStat, error) {
return []godisk.PartitionStat{
{Device: "tank/dataset1", Mountpoint: "/mnt/dataset1", Fstype: "zfs"},
{Device: "tank/dataset2", Mountpoint: "/mnt/dataset2", Fstype: "zfs"},
{Device: "tank/dataset3", Mountpoint: "/mnt/dataset3", Fstype: "zfs"},
}, nil
}
// Mock usage
diskUsage = func(ctx context.Context, path string) (*godisk.UsageStat, error) {
// All datasets on the same pool share the same free space
return &godisk.UsageStat{
Total: 1000,
Used: 500,
Free: 500,
UsedPercent: 50.0,
}, nil
}
// Mock zpool stats query to fail so we test the fallback logic (which uses the datasets)
// We can't easily mock queryZpoolStats because it's in zfs.go and not exported/variable-ized in the same way,
// but we can rely on the fact that `exec.LookPath("zpool")` will likely fail or `zpool list` will fail in the test environment.
// However, to be sure, we should probably mock it.
// But wait, `summarizeZFSPools` calls `queryZpoolStats`.
// `queryZpoolStats` is a variable in zfs.go! `var queryZpoolStats = fetchZpoolStats`
// Let's mock queryZpoolStats too.
origQueryZpoolStats := queryZpoolStats
defer func() { queryZpoolStats = origQueryZpoolStats }()
queryZpoolStats = func(ctx context.Context, pools []string) (map[string]zpoolStats, error) {
return nil, assert.AnError // Simulate failure
}
ctx := context.Background()
disks := collectDisks(ctx)
// Should return 1 disk (the pool), not 3
assert.Len(t, disks, 1)
assert.Equal(t, "tank", disks[0].Device)
assert.Equal(t, int64(1000), disks[0].TotalBytes)
}
func TestCollectDisks_ZFS_CaseInsensitive(t *testing.T) {
// Save original functions
origDiskPartitions := diskPartitions
origDiskUsage := diskUsage
defer func() {
diskPartitions = origDiskPartitions
diskUsage = origDiskUsage
}()
// Mock partitions with "ZFS" (uppercase)
diskPartitions = func(ctx context.Context, all bool) ([]godisk.PartitionStat, error) {
return []godisk.PartitionStat{
{Device: "tank/dataset1", Mountpoint: "/mnt/dataset1", Fstype: "ZFS"},
{Device: "tank/dataset2", Mountpoint: "/mnt/dataset2", Fstype: "ZFS"},
}, nil
}
diskUsage = func(ctx context.Context, path string) (*godisk.UsageStat, error) {
return &godisk.UsageStat{Total: 1000, Used: 500, Free: 500}, nil
}
// Mock queryZpoolStats to fail
origQueryZpoolStats := queryZpoolStats
defer func() { queryZpoolStats = origQueryZpoolStats }()
queryZpoolStats = func(ctx context.Context, pools []string) (map[string]zpoolStats, error) {
return nil, assert.AnError
}
ctx := context.Background()
disks := collectDisks(ctx)
// If case-sensitive check fails, it will return 2 disks
// If we fix it, it should return 1
assert.Len(t, disks, 1)
}
func TestCollectDisks_ZFS_Fuse(t *testing.T) {
// Save original functions
origDiskPartitions := diskPartitions
origDiskUsage := diskUsage
defer func() {
diskPartitions = origDiskPartitions
diskUsage = origDiskUsage
}()
// Mock partitions with "fuse.zfs"
diskPartitions = func(ctx context.Context, all bool) ([]godisk.PartitionStat, error) {
return []godisk.PartitionStat{
{Device: "tank/dataset1", Mountpoint: "/mnt/dataset1", Fstype: "fuse.zfs"},
{Device: "tank/dataset2", Mountpoint: "/mnt/dataset2", Fstype: "fuse.zfs"},
}, nil
}
diskUsage = func(ctx context.Context, path string) (*godisk.UsageStat, error) {
return &godisk.UsageStat{Total: 1000, Used: 500, Free: 500}, nil
}
// Mock queryZpoolStats to fail
origQueryZpoolStats := queryZpoolStats
defer func() { queryZpoolStats = origQueryZpoolStats }()
queryZpoolStats = func(ctx context.Context, pools []string) (map[string]zpoolStats, error) {
return nil, assert.AnError
}
ctx := context.Background()
disks := collectDisks(ctx)
// If check fails, it will return 2 disks
assert.Len(t, disks, 1)
func TestCollectDiskIO(t *testing.T) {
ctx := context.Background()
snapshot, err := Collect(ctx)
if err != nil {
t.Fatalf("Collect failed: %v", err)
}
t.Logf("DiskIO count: %d", len(snapshot.DiskIO))
if len(snapshot.DiskIO) == 0 {
t.Error("Expected disk IO data but got none")
}
data, _ := json.MarshalIndent(snapshot.DiskIO, "", " ")
t.Logf("DiskIO data:\n%s", string(data))
}

View file

@ -336,6 +336,10 @@ func (h Host) ToFrontend() HostFrontend {
host.Disks = append([]Disk(nil), h.Disks...)
}
if len(h.DiskIO) > 0 {
host.DiskIO = append([]DiskIO(nil), h.DiskIO...)
}
if len(h.NetworkInterfaces) > 0 {
host.NetworkInterfaces = make([]HostNetworkInterface, len(h.NetworkInterfaces))
copy(host.NetworkInterfaces, h.NetworkInterfaces)

View file

@ -162,6 +162,7 @@ type Host struct {
Memory Memory `json:"memory"`
LoadAverage []float64 `json:"loadAverage,omitempty"`
Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
Sensors HostSensorSummary `json:"sensors,omitempty"`
RAID []HostRAIDArray `json:"raid,omitempty"`
@ -219,6 +220,19 @@ type HostRAIDDevice struct {
Slot int `json:"slot"`
}
// DiskIO represents I/O statistics for a block device.
// Counters are cumulative since boot.
type DiskIO struct {
Device string `json:"device"`
ReadBytes uint64 `json:"readBytes,omitempty"`
WriteBytes uint64 `json:"writeBytes,omitempty"`
ReadOps uint64 `json:"readOps,omitempty"`
WriteOps uint64 `json:"writeOps,omitempty"`
ReadTime uint64 `json:"readTimeMs,omitempty"`
WriteTime uint64 `json:"writeTimeMs,omitempty"`
IOTime uint64 `json:"ioTimeMs,omitempty"`
}
// DockerHost represents a Docker host reporting metrics via the external agent.
type DockerHost struct {
ID string `json:"id"`

View file

@ -315,6 +315,7 @@ type HostFrontend struct {
LoadAverage []float64 `json:"loadAverage,omitempty"`
Memory *Memory `json:"memory,omitempty"`
Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
Sensors *HostSensorSummaryFrontend `json:"sensors,omitempty"`
Status string `json:"status"`

View file

@ -0,0 +1,266 @@
package monitoring
import (
"math"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
// getHostAgentTemperature looks for a matching host agent by hostname and converts
// its sensor data to the Temperature model used by Proxmox nodes.
// Returns nil if no matching host agent is found or if no temperature data is available.
func (m *Monitor) getHostAgentTemperature(nodeName string) *models.Temperature {
if m.state == nil {
return nil
}
hosts := m.state.GetHosts()
if len(hosts) == 0 {
return nil
}
// Find a host agent whose hostname matches the Proxmox node name
// The agent on a Proxmox node will have the same hostname as the node
nodeLower := strings.ToLower(strings.TrimSpace(nodeName))
var matchedHost *models.Host
for i := range hosts {
hostnameLower := strings.ToLower(strings.TrimSpace(hosts[i].Hostname))
if hostnameLower == nodeLower {
matchedHost = &hosts[i]
break
}
}
if matchedHost == nil {
return nil
}
// Check if the host agent has temperature data
if len(matchedHost.Sensors.TemperatureCelsius) == 0 {
return nil
}
// Convert host agent sensor data to Temperature model
return convertHostSensorsToTemperature(matchedHost.Sensors, matchedHost.LastSeen)
}
// convertHostSensorsToTemperature converts HostSensorSummary to the Temperature model.
// The host agent reports temperatures in a flat map with keys like:
// - "cpu_package" -> CPU package temperature
// - "cpu_core_0", "cpu_core_1", etc. -> individual core temperatures
// - "nvme0", "nvme1", etc. -> NVMe temperatures
// - "gpu_edge", "gpu_junction", etc. -> GPU temperatures
func convertHostSensorsToTemperature(sensors models.HostSensorSummary, lastSeen time.Time) *models.Temperature {
if len(sensors.TemperatureCelsius) == 0 {
return nil
}
temp := &models.Temperature{
Available: true,
LastUpdate: lastSeen,
Cores: []models.CoreTemp{},
NVMe: []models.NVMeTemp{},
GPU: []models.GPUTemp{},
}
var coreTemps []float64
corePattern := regexp.MustCompile(`^cpu_core_(\d+)$`)
nvmePattern := regexp.MustCompile(`^(nvme\d+)$`)
gpuPattern := regexp.MustCompile(`^gpu_(.+)$`)
for key, value := range sensors.TemperatureCelsius {
keyLower := strings.ToLower(key)
// CPU package temperature
if keyLower == "cpu_package" {
temp.CPUPackage = value
temp.HasCPU = true
continue
}
// CPU core temperatures
if matches := corePattern.FindStringSubmatch(keyLower); len(matches) == 2 {
coreNum, err := strconv.Atoi(matches[1])
if err == nil {
temp.Cores = append(temp.Cores, models.CoreTemp{
Core: coreNum,
Temp: value,
})
coreTemps = append(coreTemps, value)
temp.HasCPU = true
}
continue
}
// NVMe temperatures
if matches := nvmePattern.FindStringSubmatch(keyLower); len(matches) == 2 {
temp.NVMe = append(temp.NVMe, models.NVMeTemp{
Device: matches[1],
Temp: value,
})
temp.HasNVMe = true
continue
}
// GPU temperatures (gpu_edge, gpu_junction, gpu_mem, or generic gpu_<device>)
if matches := gpuPattern.FindStringSubmatch(keyLower); len(matches) == 2 {
gpuKey := matches[1]
// Handle specific GPU temp types
if gpuKey == "edge" || gpuKey == "junction" || gpuKey == "mem" {
// Find or create GPU entry for the default device
found := false
for i := range temp.GPU {
if temp.GPU[i].Device == "gpu0" {
switch gpuKey {
case "edge":
temp.GPU[i].Edge = value
case "junction":
temp.GPU[i].Junction = value
case "mem":
temp.GPU[i].Mem = value
}
found = true
break
}
}
if !found {
gpu := models.GPUTemp{Device: "gpu0"}
switch gpuKey {
case "edge":
gpu.Edge = value
case "junction":
gpu.Junction = value
case "mem":
gpu.Mem = value
}
temp.GPU = append(temp.GPU, gpu)
}
} else {
// Generic GPU entry with edge temp
temp.GPU = append(temp.GPU, models.GPUTemp{
Device: gpuKey,
Edge: value,
})
}
temp.HasGPU = true
continue
}
}
// Sort cores by core number
sort.Slice(temp.Cores, func(i, j int) bool {
return temp.Cores[i].Core < temp.Cores[j].Core
})
// Sort NVMe by device name
sort.Slice(temp.NVMe, func(i, j int) bool {
return temp.NVMe[i].Device < temp.NVMe[j].Device
})
// Calculate CPUMax from core temperatures if package temp wasn't available
if len(coreTemps) > 0 {
maxTemp := 0.0
for _, t := range coreTemps {
if t > maxTemp {
maxTemp = t
}
}
temp.CPUMax = maxTemp
// If no package temp, use max core temp as package
if temp.CPUPackage == 0 {
temp.CPUPackage = maxTemp
}
}
// Validate we have at least some data
if !temp.HasCPU && !temp.HasGPU && !temp.HasNVMe {
return nil
}
log.Debug().
Str("source", "host-agent").
Float64("cpuPackage", temp.CPUPackage).
Float64("cpuMax", temp.CPUMax).
Int("coreCount", len(temp.Cores)).
Int("nvmeCount", len(temp.NVMe)).
Int("gpuCount", len(temp.GPU)).
Msg("Converted host agent sensors to temperature data")
return temp
}
// isHostAgentTemperatureRecent checks if the host agent temperature data is recent enough to use.
// We consider data stale if the host hasn't reported in more than 2 minutes.
func isHostAgentTemperatureRecent(lastSeen time.Time) bool {
const staleDuration = 2 * time.Minute
return time.Since(lastSeen) < staleDuration
}
// mergeTemperatureData merges host agent temperature with existing/proxy temperature data.
// Host agent data takes priority for CPU temperatures since it's more reliable (no SSH required).
// NVMe/SMART data is merged - host agent NVMe data supplements proxy SMART data.
func mergeTemperatureData(hostAgentTemp, proxyTemp *models.Temperature) *models.Temperature {
if hostAgentTemp == nil {
return proxyTemp
}
if proxyTemp == nil {
return hostAgentTemp
}
// Start with host agent data as base since it's more reliable
result := &models.Temperature{
CPUPackage: hostAgentTemp.CPUPackage,
CPUMax: hostAgentTemp.CPUMax,
CPUMin: proxyTemp.CPUMin, // Preserve historical min
CPUMaxRecord: math.Max(hostAgentTemp.CPUPackage, proxyTemp.CPUMaxRecord), // Update historical max
MinRecorded: proxyTemp.MinRecorded,
MaxRecorded: proxyTemp.MaxRecorded,
Cores: hostAgentTemp.Cores,
GPU: hostAgentTemp.GPU,
NVMe: hostAgentTemp.NVMe,
Available: true,
HasCPU: hostAgentTemp.HasCPU,
HasGPU: hostAgentTemp.HasGPU,
HasNVMe: hostAgentTemp.HasNVMe,
HasSMART: proxyTemp.HasSMART, // SMART data only comes from proxy
LastUpdate: hostAgentTemp.LastUpdate,
}
// Use host agent CPU data if available, fall back to proxy
if !hostAgentTemp.HasCPU && proxyTemp.HasCPU {
result.CPUPackage = proxyTemp.CPUPackage
result.CPUMax = proxyTemp.CPUMax
result.Cores = proxyTemp.Cores
result.HasCPU = true
}
// Keep proxy SMART data (host agent doesn't have smartctl access currently)
if proxyTemp.HasSMART {
result.SMART = proxyTemp.SMART
}
// Merge GPU data - prefer host agent if available
if !hostAgentTemp.HasGPU && proxyTemp.HasGPU {
result.GPU = proxyTemp.GPU
result.HasGPU = true
}
// Update historical max if current is higher
currentTemp := result.CPUPackage
if currentTemp == 0 && result.CPUMax > 0 {
currentTemp = result.CPUMax
}
if currentTemp > result.CPUMaxRecord {
result.CPUMaxRecord = currentTemp
result.MaxRecorded = time.Now()
}
return result
}

View file

@ -1875,6 +1875,20 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
})
}
diskIO := make([]models.DiskIO, 0, len(report.DiskIO))
for _, io := range report.DiskIO {
diskIO = append(diskIO, models.DiskIO{
Device: io.Device,
ReadBytes: io.ReadBytes,
WriteBytes: io.WriteBytes,
ReadOps: io.ReadOps,
WriteOps: io.WriteOps,
ReadTime: io.ReadTime,
WriteTime: io.WriteTime,
IOTime: io.IOTime,
})
}
network := make([]models.HostNetworkInterface, 0, len(report.Network))
for _, nic := range report.Network {
network = append(network, models.HostNetworkInterface{
@ -1928,6 +1942,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
LoadAverage: append([]float64(nil), report.Host.LoadAverage...),
Memory: memory,
Disks: disks,
DiskIO: diskIO,
NetworkInterfaces: network,
Sensors: models.HostSensorSummary{
TemperatureCelsius: cloneStringFloatMap(report.Sensors.TemperatureCelsius),
@ -1950,6 +1965,9 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
if len(host.Disks) == 0 {
host.Disks = nil
}
if len(host.DiskIO) == 0 {
host.DiskIO = nil
}
if len(host.NetworkInterfaces) == 0 {
host.NetworkInterfaces = nil
}

View file

@ -2086,48 +2086,84 @@ func (m *Monitor) pollPVENode(
if instanceCfg.TemperatureMonitoringEnabled != nil {
tempMonitoringEnabled = *instanceCfg.TemperatureMonitoringEnabled
}
if effectiveStatus == "online" && m.tempCollector != nil && tempMonitoringEnabled {
tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy
defer tempCancel()
if effectiveStatus == "online" && tempMonitoringEnabled {
// First, check if there's a matching host agent with temperature data.
// Host agent temperatures are preferred because they don't require SSH access.
hostAgentTemp := m.getHostAgentTemperature(node.Node)
if hostAgentTemp != nil {
log.Debug().
Str("node", node.Node).
Float64("cpuPackage", hostAgentTemp.CPUPackage).
Float64("cpuMax", hostAgentTemp.CPUMax).
Int("nvmeCount", len(hostAgentTemp.NVMe)).
Msg("Using temperature data from host agent")
}
// Determine SSH hostname to use (most robust approach):
// Prefer the resolved host for this node, with cluster overrides when available.
sshHost := modelNode.Host
foundNodeEndpoint := false
// If no host agent temp or we need additional data (SMART), try SSH/proxy collection
var proxyTemp *models.Temperature
var err error
if m.tempCollector != nil {
tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy
defer tempCancel()
if modelNode.IsClusterMember && instanceCfg.IsCluster {
// Try to find specific endpoint configuration for this node
if len(instanceCfg.ClusterEndpoints) > 0 {
hasFingerprint := instanceCfg.Fingerprint != ""
for _, ep := range instanceCfg.ClusterEndpoints {
if strings.EqualFold(ep.NodeName, node.Node) {
if effective := clusterEndpointEffectiveURL(ep, instanceCfg.VerifySSL, hasFingerprint); effective != "" {
sshHost = effective
foundNodeEndpoint = true
// Determine SSH hostname to use (most robust approach):
// Prefer the resolved host for this node, with cluster overrides when available.
sshHost := modelNode.Host
foundNodeEndpoint := false
if modelNode.IsClusterMember && instanceCfg.IsCluster {
// Try to find specific endpoint configuration for this node
if len(instanceCfg.ClusterEndpoints) > 0 {
hasFingerprint := instanceCfg.Fingerprint != ""
for _, ep := range instanceCfg.ClusterEndpoints {
if strings.EqualFold(ep.NodeName, node.Node) {
if effective := clusterEndpointEffectiveURL(ep, instanceCfg.VerifySSL, hasFingerprint); effective != "" {
sshHost = effective
foundNodeEndpoint = true
}
break
}
break
}
}
// If no specific endpoint found, fall back to node name
if !foundNodeEndpoint {
sshHost = node.Node
log.Debug().
Str("node", node.Node).
Str("instance", instanceCfg.Name).
Msg("Node endpoint not found in cluster metadata - falling back to node name for temperature collection")
}
}
// If no specific endpoint found, fall back to node name
if !foundNodeEndpoint {
if strings.TrimSpace(sshHost) == "" {
sshHost = node.Node
log.Debug().
Str("node", node.Node).
Str("instance", instanceCfg.Name).
Msg("Node endpoint not found in cluster metadata - falling back to node name for temperature collection")
}
// Skip SSH/proxy collection if we already have host agent data and no proxy is configured
// (proxy might provide additional SMART data that host agent doesn't have)
skipProxyCollection := hostAgentTemp != nil &&
strings.TrimSpace(instanceCfg.TemperatureProxyURL) == "" &&
!m.HasSocketTemperatureProxy()
if !skipProxyCollection {
// Use HTTP proxy if configured for this instance, otherwise fall back to socket/SSH
proxyTemp, err = m.tempCollector.CollectTemperatureWithProxy(tempCtx, sshHost, node.Node, instanceCfg.TemperatureProxyURL, instanceCfg.TemperatureProxyToken)
if err != nil && hostAgentTemp == nil {
log.Debug().
Str("node", node.Node).
Str("sshHost", sshHost).
Bool("isCluster", modelNode.IsClusterMember).
Int("endpointCount", len(instanceCfg.ClusterEndpoints)).
Msg("Temperature collection failed - check SSH access")
}
}
}
if strings.TrimSpace(sshHost) == "" {
sshHost = node.Node
}
// Merge host agent and proxy temperatures
temp := mergeTemperatureData(hostAgentTemp, proxyTemp)
// Use HTTP proxy if configured for this instance, otherwise fall back to socket/SSH
temp, err := m.tempCollector.CollectTemperatureWithProxy(tempCtx, sshHost, node.Node, instanceCfg.TemperatureProxyURL, instanceCfg.TemperatureProxyToken)
if err == nil && temp != nil && temp.Available {
if temp != nil && temp.Available {
// Get the current CPU temperature (prefer package, fall back to max)
currentTemp := temp.CPUPackage
if currentTemp == 0 && temp.CPUMax > 0 {
@ -2171,28 +2207,29 @@ func (m *Monitor) pollPVENode(
}
modelNode.Temperature = temp
// Determine source for logging
tempSource := "proxy/ssh"
if hostAgentTemp != nil && proxyTemp == nil {
tempSource = "host-agent"
} else if hostAgentTemp != nil && proxyTemp != nil {
tempSource = "host-agent+proxy"
}
log.Debug().
Str("node", node.Node).
Str("sshHost", sshHost).
Str("source", tempSource).
Float64("cpuPackage", temp.CPUPackage).
Float64("cpuMax", temp.CPUMax).
Float64("cpuMin", temp.CPUMin).
Float64("cpuMaxRecord", temp.CPUMaxRecord).
Int("nvmeCount", len(temp.NVMe)).
Msg("Collected temperature data")
} else if err != nil {
} else if hostAgentTemp == nil && proxyTemp == nil {
log.Debug().
Str("node", node.Node).
Str("sshHost", sshHost).
Bool("isCluster", modelNode.IsClusterMember).
Int("endpointCount", len(instanceCfg.ClusterEndpoints)).
Msg("Temperature collection failed - check SSH access")
} else if temp != nil {
log.Debug().
Str("node", node.Node).
Str("sshHost", sshHost).
Bool("available", temp.Available).
Msg("Temperature data unavailable after collection")
Msg("No temperature data available (no host agent or proxy/SSH)")
}
}

View file

@ -109,6 +109,7 @@ func isCPUChip(chipLower string) bool {
func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) {
foundPackageTemp := false
var chipletTemps []float64
var genericTemp float64 // For chips that only report temp1
for sensorName, sensorData := range chipMap {
sensorMap, ok := sensorData.(map[string]interface{})
@ -131,6 +132,14 @@ func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) {
}
}
// Capture generic temp1 for chips like cpu_thermal (RPi, ARM SoCs)
// that don't have labeled sensors
if sensorNameLower == "temp1" {
if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 {
genericTemp = tempVal
}
}
// Look for AMD chiplet temperatures
if strings.HasPrefix(sensorName, "Tccd") {
if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 {
@ -175,6 +184,14 @@ func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) {
}
}
}
// Fallback: use generic temp1 for chips like cpu_thermal (RPi, ARM SoCs)
if !foundPackageTemp && data.CPUPackage == 0 && genericTemp > 0 {
data.CPUPackage = genericTemp
if genericTemp > data.CPUMax {
data.CPUMax = genericTemp
}
}
}
func parseNVMeTemps(chipName string, chipMap map[string]interface{}, data *TemperatureData) {

View file

@ -48,3 +48,48 @@ func GetenvTrim(key string) string {
func NormalizeVersion(version string) string {
return strings.TrimPrefix(strings.TrimSpace(version), "v")
}
// CompareVersions compares two semver-like version strings.
// Returns:
//
// 1 if a > b (a is newer)
// 0 if a == b
//
// -1 if a < b (b is newer)
//
// Handles versions like "4.33.1", "v4.33.1", "4.33" gracefully.
func CompareVersions(a, b string) int {
// Normalize both versions
a = NormalizeVersion(a)
b = NormalizeVersion(b)
// Split into parts
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
// Compare each part numerically
maxLen := len(partsA)
if len(partsB) > maxLen {
maxLen = len(partsB)
}
for i := 0; i < maxLen; i++ {
var numA, numB int
if i < len(partsA) {
fmt.Sscanf(partsA[i], "%d", &numA)
}
if i < len(partsB) {
fmt.Sscanf(partsB[i], "%d", &numB)
}
if numA > numB {
return 1
}
if numA < numB {
return -1
}
}
return 0
}

View file

@ -318,3 +318,50 @@ func TestNormalizeVersion(t *testing.T) {
})
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
a string
b string
expected int
}{
// Equal versions
{"4.33.1", "4.33.1", 0},
{"v4.33.1", "4.33.1", 0},
{"4.33.1", "v4.33.1", 0},
{"1.0.0", "1.0.0", 0},
// a > b (a is newer)
{"4.33.2", "4.33.1", 1},
{"4.34.0", "4.33.1", 1},
{"5.0.0", "4.33.1", 1},
{"4.33.10", "4.33.9", 1},
{"4.33.1", "4.33", 1}, // Missing patch = 0
// a < b (b is newer)
{"4.33.1", "4.33.2", -1},
{"4.33.1", "4.34.0", -1},
{"4.33.1", "5.0.0", -1},
{"4.33.9", "4.33.10", -1},
{"4.33", "4.33.1", -1},
// With v prefix
{"v4.34.0", "v4.33.1", 1},
{"v4.33.1", "v4.34.0", -1},
// Edge cases
{"0.0.1", "0.0.0", 1},
{"0.0.0", "0.0.1", -1},
{"1.0", "0.9.9", 1},
}
for _, tc := range tests {
name := tc.a + "_vs_" + tc.b
t.Run(name, func(t *testing.T) {
result := CompareVersions(tc.a, tc.b)
if result != tc.expected {
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tc.a, tc.b, result, tc.expected)
}
})
}
}

View file

@ -8,6 +8,7 @@ type Report struct {
Host HostInfo `json:"host"`
Metrics Metrics `json:"metrics"`
Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
Network []NetworkInterface `json:"network,omitempty"`
Sensors Sensors `json:"sensors,omitempty"`
RAID []RAIDArray `json:"raid,omitempty"`
@ -23,6 +24,7 @@ type AgentInfo struct {
Type string `json:"type,omitempty"` // "unified", "host", or "docker" - empty means legacy
IntervalSeconds int `json:"intervalSeconds,omitempty"`
Hostname string `json:"hostname,omitempty"`
UpdatedFrom string `json:"updatedFrom,omitempty"` // Previous version if recently auto-updated
}
// HostInfo contains platform and identification details about the monitored host.
@ -70,6 +72,19 @@ type Disk struct {
Usage float64 `json:"usage,omitempty"`
}
// DiskIO represents disk I/O statistics for a block device.
// These are cumulative counters since boot - the backend calculates rates.
type DiskIO struct {
Device string `json:"device"` // e.g., "nvme0n1", "sda"
ReadBytes uint64 `json:"readBytes,omitempty"` // Total bytes read
WriteBytes uint64 `json:"writeBytes,omitempty"` // Total bytes written
ReadOps uint64 `json:"readOps,omitempty"` // Total read operations
WriteOps uint64 `json:"writeOps,omitempty"` // Total write operations
ReadTime uint64 `json:"readTimeMs,omitempty"` // Total time spent reading (ms)
WriteTime uint64 `json:"writeTimeMs,omitempty"`// Total time spent writing (ms)
IOTime uint64 `json:"ioTimeMs,omitempty"` // Total time spent doing I/O (ms)
}
// NetworkInterface summarises network adapter statistics.
type NetworkInterface struct {
Name string `json:"name"`

View file

@ -343,8 +343,8 @@ func (cc *ClusterClient) getHealthyClient(ctx context.Context) (*Client, error)
return nil, fmt.Errorf("failed to create client for %s: %w", selectedEndpoint, err)
}
// Quick connectivity test
testCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
// Connectivity test - 5 seconds to allow for TLS handshake (~3s typical)
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
testNodes, testErr := testClient.GetNodes(testCtx)
cancel()
@ -489,10 +489,12 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
cc.lastHealthCheck[ep] = now
cc.mu.Unlock()
// Try to create a client and test connection with shorter timeout
// Try to create a client and test connection
// Note: 5-second timeout needed because TLS handshake to Proxmox API
// typically takes ~3 seconds on local networks
cfg := cc.config
cfg.Host = ep
cfg.Timeout = 2 * time.Second // Use shorter timeout for recovery attempts
cfg.Timeout = 5 * time.Second
testClient, err := NewClient(cfg)
if err != nil {
@ -504,8 +506,8 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
return
}
// Try a simple API call with short timeout
testCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
// Try a simple API call
testCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, err = testClient.GetNodes(testCtx)
cancel()

View file

@ -54,6 +54,8 @@ PULSE_TOKEN=""
INTERVAL="30s"
ENABLE_HOST="true"
ENABLE_DOCKER="false"
ENABLE_PROXMOX="false"
PROXMOX_TYPE=""
UNINSTALL="false"
INSECURE="false"
AGENT_ID=""
@ -83,6 +85,8 @@ build_exec_args() {
EXEC_ARGS="$EXEC_ARGS --enable-host=false"
fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi
if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-proxmox"; fi
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS="$EXEC_ARGS --agent-id ${AGENT_ID}"; fi
}
@ -98,6 +102,8 @@ build_exec_args_array() {
EXEC_ARGS_ARRAY+=(--enable-host=false)
fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); fi
if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-proxmox); fi
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi
if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS_ARRAY+=(--agent-id "$AGENT_ID"); fi
}
@ -112,6 +118,8 @@ while [[ $# -gt 0 ]]; do
--disable-host) ENABLE_HOST="false"; shift ;;
--enable-docker) ENABLE_DOCKER="true"; shift ;;
--disable-docker) ENABLE_DOCKER="false"; shift ;;
--enable-proxmox) ENABLE_PROXMOX="true"; shift ;;
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
--insecure) INSECURE="true"; shift ;;
--uninstall) UNINSTALL="true"; shift ;;
--agent-id) AGENT_ID="$2"; shift 2 ;;