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

View file

@ -1,5 +1,10 @@
# pulse-sensor-proxy # 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 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 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 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 // Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port // 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{ srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort), Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(), Handler: router.Handler(),
ReadTimeout: 15 * time.Second, ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data) WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data)
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
// Start config watcher for .env file changes // Start config watcher for .env file changes

View file

@ -1,5 +1,9 @@
# ⚙️ Sensor Proxy Configuration # ⚙️ 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+). Safe configuration management using the CLI (v4.31.1+).
## 📂 Files ## 📂 Files

View file

@ -1,5 +1,9 @@
# 📝 Sensor Proxy Log Forwarding # 📝 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. Forward `audit.log` and `proxy.log` to a central SIEM via RELP + TLS.
## 🚀 Quick Start ## 🚀 Quick Start

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening # 🛡️ 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. Secure `pulse-sensor-proxy` with AppArmor and Seccomp.
## 🛡️ AppArmor ## 🛡️ AppArmor

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening # 🛡️ 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. The `pulse-sensor-proxy` runs on the host to securely collect temperatures, keeping SSH keys out of containers.
## 🏗️ Architecture ## 🏗️ Architecture

View file

@ -1,5 +1,9 @@
# 🌐 Sensor Proxy Network Segmentation # 🌐 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. Isolate the proxy to prevent lateral movement.
## 🚧 Zones ## 🚧 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. * **Isolation**: SSH keys live on the host, not in the container.
* **Least Privilege**: Proxy runs as `pulse-sensor-proxy` (no shell). * **Least Privilege**: Proxy runs as `pulse-sensor-proxy` (no shell).
* **Verification**: Container identity verified via `SO_PEERCRED`. * **Verification**: Container identity verified via `SO_PEERCRED`.
## 🏗️ Components ### 🏗️ Components
1. **Pulse Backend**: Connects to Unix socket `/mnt/pulse-proxy/pulse-sensor-proxy.sock`. 1. **Pulse Backend**: Connects to Unix socket `/mnt/pulse-proxy/pulse-sensor-proxy.sock`.
2. **Sensor Proxy**: Validates request, executes SSH to node. 2. **Sensor Proxy**: Validates request, executes SSH to node.
3. **Target Node**: Accepts SSH key restricted to `sensors -j`. 3. **Target Node**: Accepts SSH key restricted to `sensors -j`.
## 🔒 Key Restrictions ### 🔒 Key Restrictions
SSH keys deployed to nodes are locked down: SSH keys deployed to nodes are locked down:
``` ```
command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty
``` ```
## 🚦 Rate Limiting ### 🚦 Rate Limiting
* **Per Peer**: ~12 req/min. * **Per Peer**: ~12 req/min.
* **Concurrency**: Max 2 parallel requests per peer. * **Concurrency**: Max 2 parallel requests per peer.
* **Global**: Max 8 concurrent requests. * **Global**: Max 8 concurrent requests.
## 📝 Auditing ### 📝 Auditing
All requests logged to system journal: All requests logged to system journal:
```bash ```bash
journalctl -u pulse-sensor-proxy journalctl -u pulse-sensor-proxy

View file

@ -272,12 +272,27 @@ export const AIChat: Component<AIChatProps> = (props) => {
const previousMessages = messages(); const previousMessages = messages();
// Build conversation history from previous messages (before we add new ones) // 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 const history = previousMessages
.filter((m) => m.content && !m.isStreaming) // Only include completed messages with content .filter((m) => !m.isStreaming) // Only include completed messages
.map((m) => ({ .filter((m) => m.content || (m.toolCalls && m.toolCalls.length > 0)) // Must have content or tool calls
role: m.role, .map((m) => {
content: m.content, 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 // Add user message
const userMessage: Message = { const userMessage: Message = {
@ -333,11 +348,16 @@ export const AIChat: Component<AIChatProps> = (props) => {
} }
case 'tool_end': { case 'tool_end': {
const data = event.data as AIStreamToolEndData; const data = event.data as AIStreamToolEndData;
// Remove matching pending tool (by name since input won't match output) // Remove one pending tool with matching name
const updatedPending = (msg.pendingTools || []).slice(0, -1); // Remove last pending 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 = { const newToolCall: AIToolExecution = {
name: data.name, name: data.name,
input: (msg.pendingTools || []).find((t) => t.name === data.name)?.input || data.name, input: data.input,
output: data.output, output: data.output,
success: data.success, 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' : '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}> <Show when={message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0}>
<div class="mb-3 space-y-2"> <div class="mb-3 space-y-2">
<For each={message.toolCalls}> <For each={message.toolCalls}>
@ -683,20 +703,17 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div> </div>
</Show> </Show>
{/* Show subtle analyzing indicator when running commands */} {/* Show AI's response text AFTER tool calls */}
<Show when={message.role === 'assistant' && message.pendingTools && message.pendingTools.length > 0}> <Show when={message.content}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-2"> <div
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"> 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"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> innerHTML={renderMarkdown(message.content)}
<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>
{/* Show commands awaiting approval */} {/* Show commands awaiting approval */}
<Show when={message.role === 'assistant' && message.pendingApprovals && message.pendingApprovals.length > 0}> <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}> <For each={message.pendingApprovals}>
{(approval) => ( {(approval) => (
<div class="rounded border border-amber-400 dark:border-amber-600 overflow-hidden"> <div class="rounded border border-amber-400 dark:border-amber-600 overflow-hidden">
@ -756,24 +773,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
</For> </For>
</div> </div>
</Show> </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 */} {/* Minimal footer - no model/token info shown */}
</div> </div>
</div> </div>
@ -783,6 +782,17 @@ export const AIChat: Component<AIChatProps> = (props) => {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </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 */} {/* Input Area */}
<div class="border-t border-gray-200 dark:border-gray-700 p-4"> <div class="border-t border-gray-200 dark:border-gray-700 p-4">
{/* Context section - always show with Add button */} {/* 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 { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import type { Host } from '@/types/api'; 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 { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar'; import { MetricBar } from '@/components/Dashboard/MetricBar';
@ -328,9 +328,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<th class={thClass} onClick={() => handleSort('disk')}> <th class={thClass} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')} Disk {renderSortIndicator('disk')}
</th> </th>
<th class={`${thClass} text-right pr-4`} onClick={() => handleSort('uptime')}> <th class={thClass} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')} Uptime {renderSortIndicator('uptime')}
</th> </th>
<th class={`${thClass} text-right pr-4`}>
Agent
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@ -413,6 +416,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const hasDrawerContent = createMemo(() => { const hasDrawerContent = createMemo(() => {
return ( return (
(host.disks && host.disks.length > 0) || (host.disks && host.disks.length > 0) ||
(host.diskIO && host.diskIO.length > 0) ||
(host.networkInterfaces && host.networkInterfaces.length > 0) || (host.networkInterfaces && host.networkInterfaces.length > 0) ||
(host.raid && host.raid.length > 0) || (host.raid && host.raid.length > 0) ||
host.loadAverage || host.loadAverage ||
@ -571,8 +575,8 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</td> </td>
{/* Uptime */} {/* Uptime */}
<td class="px-2 py-1 pr-4 align-middle"> <td class="px-2 py-1 align-middle">
<div class="flex justify-end"> <div class="flex justify-center">
<Show <Show
when={host.uptimeSeconds} when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-400"></span>} fallback={<span class="text-xs text-gray-400"></span>}
@ -583,12 +587,26 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</Show> </Show>
</div> </div>
</td> </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> </tr>
{/* Drawer - Additional Info */} {/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}> <Show when={drawerOpen() && hasDrawerContent()}>
<tr> <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="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"> <div class="flex flex-wrap justify-start gap-3">
{/* System Info */} {/* System Info */}
@ -689,6 +707,40 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</div> </div>
</Show> </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 */} {/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}> <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"> <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; lastError?: string;
}; };
// Host agent info passed from state
interface HostAgentInfo {
hostname: string;
status: string;
}
interface PveNodesTableProps { interface PveNodesTableProps {
nodes: NodeConfigWithStatus[]; nodes: NodeConfigWithStatus[];
stateNodes: { instance: string; status?: string; connectionHealth?: string }[]; stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
stateHosts?: HostAgentInfo[];
globalTemperatureMonitoringEnabled?: boolean; globalTemperatureMonitoringEnabled?: boolean;
temperatureTransports?: TemperatureTransportInfo | null; temperatureTransports?: TemperatureTransportInfo | null;
onTestConnection: (nodeId: string) => void; onTestConnection: (nodeId: string) => void;
@ -104,6 +111,7 @@ const resolveTemperatureTransport = (
node: NodeConfigWithStatus, node: NodeConfigWithStatus,
info: TemperatureTransportInfo | null | undefined, info: TemperatureTransportInfo | null | undefined,
globalEnabled: boolean, globalEnabled: boolean,
hostAgent?: HostAgentInfo,
): TemperatureTransportBadge => { ): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled); const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase(); 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 key = nodeKey;
const httpEntry = key ? info?.httpMap?.[key] : undefined; const httpEntry = key ? info?.httpMap?.[key] : undefined;
const socketStatus = info?.socketStatus; const socketStatus = info?.socketStatus;
@ -185,9 +202,9 @@ const resolveTemperatureTransport = (
return buildSocketBadge(); return buildSocketBadge();
case 'ssh-blocked': case 'ssh-blocked':
return { return {
label: 'Proxy required', label: 'Agent required',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300', 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': case 'ssh':
return { 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) => { export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
return ( return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg"> <Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
@ -304,11 +331,13 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
const clusterName = createMemo(() => const clusterName = createMemo(() =>
'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown', 'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown',
); );
const hostAgent = createMemo(() => findMatchingHostAgent(node.name, props.stateHosts));
const transportMeta = createMemo(() => const transportMeta = createMemo(() =>
resolveTemperatureTransport( resolveTemperatureTransport(
node, node,
props.temperatureTransports, props.temperatureTransports,
props.globalTemperatureMonitoringEnabled ?? true, props.globalTemperatureMonitoringEnabled ?? true,
hostAgent(),
), ),
); );
return ( return (
@ -410,9 +439,22 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</div> </div>
</td> </td>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400"> <div class="flex flex-col gap-1">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} <span class="text-xs text-gray-600 dark:text-gray-400">
</span> {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>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@ -603,9 +645,22 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
</div> </div>
</td> </td>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400"> <div class="flex flex-col gap-1">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} <span class="text-xs text-gray-600 dark:text-gray-400">
</span> {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>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@ -786,9 +841,22 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
</div> </div>
</td> </td>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400"> <div class="flex flex-col gap-1">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} <span class="text-xs text-gray-600 dark:text-gray-400">
</span> {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>
<td class="align-top px-3 py-3"> <td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">

View file

@ -80,7 +80,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
host: '', host: '',
guestURL: '', guestURL: '',
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'), 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: '', user: '',
password: '', password: '',
tokenName: '', tokenName: '',
@ -101,6 +101,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [proxyInstallCommand, setProxyInstallCommand] = createSignal(''); const [proxyInstallCommand, setProxyInstallCommand] = createSignal('');
const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false); const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false);
const [proxyCommandError, setProxyCommandError] = createSignal<string | null>(null); 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 = () => const showTemperatureMonitoringSection = () =>
typeof props.temperatureMonitoringEnabled === 'boolean'; typeof props.temperatureMonitoringEnabled === 'boolean';
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true; const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
@ -114,7 +117,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
case 'socket-proxy': case 'socket-proxy':
return { return {
tone: 'success', 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': case 'https-proxy':
return { return {
@ -126,7 +129,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
tone: 'danger', tone: 'danger',
disable: true, disable: true,
message: 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': case 'ssh':
return { return {
@ -400,8 +403,13 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
if (field === 'setupMode' && value !== 'auto') { if (field === 'setupMode') {
setQuickSetupCommand(''); if (value !== 'auto') {
setQuickSetupCommand('');
}
if (value !== 'agent') {
setAgentInstallCommand('');
}
} }
}; };
@ -764,7 +772,21 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pve'}> <Show when={props.nodeType === 'pve'}>
<div class="space-y-3 text-xs"> <div class="space-y-3 text-xs">
{/* Tab buttons */} {/* 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 <button
type="button" type="button"
onClick={() => updateField('setupMode', 'auto')} 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' : '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>
<button <button
type="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' : '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> </button>
</div> </div>
{/* Quick Setup Tab */} {/* Agent Install Tab (Recommended) */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}> <Show when={formData().setupMode === 'agent'}>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3"> <div class="space-y-3">
The command below creates the monitoring user, applies read-only <p class="text-xs text-gray-600 dark:text-gray-400">
access, and adds the storage permissions Pulse needs to display Install the Pulse agent on your Proxmox node. This single command sets everything up:
backups. </p>
</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"> <p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox VE server: Just copy and run this one command on your Proxmox VE server:
</p> </p>
@ -1259,17 +1370,29 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pbs'}> <Show when={props.nodeType === 'pbs'}>
<div class="space-y-3 text-xs"> <div class="space-y-3 text-xs">
{/* Tab buttons for PBS */} {/* Tab buttons for PBS */}
<div class="flex gap-2"> <div class="flex gap-2 flex-wrap">
<button <button
type="button" type="button"
onClick={() => updateField('setupMode', 'auto')} 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 ${ 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 === 'auto' || !formData().setupMode 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' ? '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' : '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>
<button <button
type="button" type="button"
@ -1284,8 +1407,83 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</button> </button>
</div> </div>
{/* Quick Setup Tab for PBS */} {/* Agent Install Tab for PBS */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}> <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"> <p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox Backup Server: Just copy and run this one command on your Proxmox Backup Server:
</p> </p>
@ -1915,8 +2113,11 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</Show> </Show>
<Show when={shouldOfferProxyCommand()}> <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="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 class="font-semibold">Temperature proxy (legacy)</div>
<div>Generate a one-line installer command to run on the Proxmox host:</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"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"

View file

@ -2709,6 +2709,40 @@ const Settings: Component<SettingsProps> = (props) => {
class="mb-6" class="mb-6"
/> />
</Show> </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 */} {/* PVE Nodes Tab */}
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'pve'}> <Show when={activeTab() === 'proxmox' && selectedAgent() === 'pve'}>
<div class="space-y-6 mt-6"> <div class="space-y-6 mt-6">
@ -2824,6 +2858,7 @@ const Settings: Component<SettingsProps> = (props) => {
<PveNodesTable <PveNodesTable
nodes={pveNodes()} nodes={pveNodes()}
stateNodes={state.nodes ?? []} stateNodes={state.nodes ?? []}
stateHosts={state.hosts ?? []}
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()} globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
temperatureTransports={temperatureTransportInfo()} temperatureTransports={temperatureTransportInfo()}
onTestConnection={testNodeConnection} onTestConnection={testNodeConnection}
@ -5783,9 +5818,8 @@ const Settings: Component<SettingsProps> = (props) => {
fallback={ fallback={
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-3 text-xs text-gray-500 dark:text-gray-400">
Check proxy nodes is only available when the proxy socket Check proxy nodes is only available when the proxy socket
grants admin access. Run this diagnostic on the Proxmox host grants admin access. For best results, install the Pulse agent
or reinstall pulse-sensor-proxy in HTTP mode to manage nodes on each Proxmox node (Settings Agents).
remotely.
</div> </div>
} }
> >

View file

@ -108,6 +108,7 @@ export const UnifiedAgents: Component = () => {
const [lookupError, setLookupError] = createSignal<string | null>(null); const [lookupError, setLookupError] = createSignal<string | null>(null);
const [lookupLoading, setLookupLoading] = createSignal(false); const [lookupLoading, setLookupLoading] = createSignal(false);
const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring 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) const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
createEffect(() => { createEffect(() => {
@ -236,6 +237,7 @@ export const UnifiedAgents: Component = () => {
}; };
const getDockerFlag = () => enableDocker() ? ' --enable-docker' : ''; const getDockerFlag = () => enableDocker() ? ' --enable-docker' : '';
const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : '';
const getInsecureFlag = () => insecureMode() ? ' --insecure' : ''; const getInsecureFlag = () => insecureMode() ? ' --insecure' : '';
const getCurlInsecureFlag = () => insecureMode() ? '-k' : ''; const getCurlInsecureFlag = () => insecureMode() ? '-k' : '';
@ -451,26 +453,48 @@ export const UnifiedAgents: Component = () => {
<Show when={commandsUnlocked()}> <Show when={commandsUnlocked()}>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4> <div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"> <h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<input <div class="flex items-center gap-4">
type="checkbox" <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
checked={enableDocker()} <input
onChange={(e) => setEnableDocker(e.currentTarget.checked)} type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" checked={enableDocker()}
/> onChange={(e) => setEnableDocker(e.currentTarget.checked)}
Enable Docker monitoring class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
</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)"> Docker monitoring
<input </label>
type="checkbox" <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">
checked={insecureMode()} <input
onChange={(e) => setInsecureMode(e.currentTarget.checked)} type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" checked={enableProxmox()}
/> onChange={(e) => setEnableProxmox(e.currentTarget.checked)}
Skip certificate verification class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
</label> />
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>
<div class="space-y-4"> <div class="space-y-4">
@ -490,25 +514,22 @@ export const UnifiedAgents: Component = () => {
if (insecureMode() && cmd.includes('curl -fsSL')) { if (insecureMode() && cmd.includes('curl -fsSL')) {
cmd = cmd.replace('curl -fsSL', 'curl -kfsSL'); 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 // Append docker flag if enabled
if (enableDocker()) { if (enableDocker()) {
// For PowerShell, we need to handle the env var or args differently
if (cmd.includes('$env:PULSE_URL')) { if (cmd.includes('$env:PULSE_URL')) {
// Env var style: add $env:PULSE_ENABLE_DOCKER="true";
cmd = `$env:PULSE_ENABLE_DOCKER="true"; ` + cmd; cmd = `$env:PULSE_ENABLE_DOCKER="true"; ` + cmd;
} else if (cmd.includes('irm')) { } else if (isBashScript) {
// 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 {
cmd += getDockerFlag(); 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 // Append insecure flag for agent if enabled
if (insecureMode() && !cmd.includes('$env:') && !cmd.includes('irm')) { if (insecureMode() && isBashScript) {
cmd += getInsecureFlag(); cmd += getInsecureFlag();
} }
return cmd; return cmd;

View file

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

View file

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

View file

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

View file

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

View file

@ -51,6 +51,24 @@ export default defineConfig({
target: backendWsUrl, target: backendWsUrl,
ws: true, ws: true,
changeOrigin: 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': { '/api/ai/execute/stream': {
target: backendUrl, target: backendUrl,
@ -86,6 +104,32 @@ export default defineConfig({
target: backendWsUrl, target: backendWsUrl,
ws: true, ws: true,
changeOrigin: 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': { '/api': {
target: backendUrl, target: backendUrl,

View file

@ -46,12 +46,31 @@ func NewServer(validateToken func(token string) bool) *Server {
// HandleWebSocket handles incoming WebSocket connections from agents // HandleWebSocket handles incoming WebSocket connections from agents
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { 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) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to upgrade WebSocket connection") log.Error().Err(err).Msg("Failed to upgrade WebSocket connection")
return 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) // Read first message (must be agent_register)
conn.SetReadDeadline(time.Now().Add(30 * time.Second)) conn.SetReadDeadline(time.Now().Add(30 * time.Second))
_, msgBytes, err := conn.ReadMessage() _, msgBytes, err := conn.ReadMessage()
@ -139,8 +158,27 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Payload: RegisteredPayload{Success: true, Message: "Registered"}, 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.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 // Run read loop (blocking) - don't use goroutine, or HTTP handler will close connection
s.readLoop(ac) 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 { func (s *Server) sendMessage(conn *websocket.Conn, msg Message) error {
msgBytes, err := json.Marshal(msg) msgBytes, err := json.Marshal(msg)
if err != nil { if err != nil {

View file

@ -106,11 +106,11 @@ func (u *Updater) RunLoop(ctx context.Context) {
return return
} }
// Initial check after a short delay // Initial check after a short delay (5s to quickly update outdated agents)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-time.After(30 * time.Second): case <-time.After(5 * time.Second):
u.CheckAndUpdate(ctx) u.CheckAndUpdate(ctx)
} }
@ -156,13 +156,23 @@ func (u *Updater) CheckAndUpdate(ctx context.Context) {
return return
} }
// Normalize both versions by stripping "v" prefix for comparison. // Compare versions using semver comparison to only update to newer versions.
// Server returns version without prefix (e.g., "4.33.1"), but agent's // This prevents accidental downgrades if the server temporarily has an older version.
// CurrentVersion may include it (e.g., "v4.33.1") depending on build. currentNorm := utils.NormalizeVersion(u.cfg.CurrentVersion)
if utils.NormalizeVersion(serverVersion) == 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") u.logger.Debug().Str("version", u.cfg.CurrentVersion).Msg("Agent is up to date")
return return
} }
if cmp < 0 {
u.logger.Debug().
Str("currentVersion", currentNorm).
Str("serverVersion", serverNorm).
Msg("Server has older version, skipping downgrade")
return
}
u.logger.Info(). u.logger.Info().
Str("currentVersion", u.cfg.CurrentVersion). Str("currentVersion", u.cfg.CurrentVersion).
@ -402,6 +412,10 @@ func (u *Updater) performUpdate(ctx context.Context) error {
// Remove backup on success // Remove backup on success
os.Remove(backupPath) 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 // On Unraid, also update the persistent copy on the flash drive
// This ensures the update survives reboots // This ensures the update survives reboots
if isUnraid() { if isUnraid() {
@ -433,6 +447,32 @@ func (u *Updater) performUpdate(ctx context.Context) error {
return restartProcess(execPath) 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"). // determineArch returns the architecture string for download URLs (e.g., "linux-amd64", "darwin-arm64").
func determineArch() string { func determineArch() string {
os := runtime.GOOS os := runtime.GOOS

View file

@ -8,11 +8,15 @@ import (
"io" "io"
"net/http" "net/http"
"time" "time"
"github.com/rs/zerolog/log"
) )
const ( const (
anthropicAPIURL = "https://api.anthropic.com/v1/messages" anthropicAPIURL = "https://api.anthropic.com/v1/messages"
anthropicAPIVersion = "2023-06-01" anthropicAPIVersion = "2023-06-01"
maxRetries = 3
initialBackoff = 2 * time.Second
) )
// AnthropicClient implements the Provider interface for Anthropic's Claude API // 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) return nil, fmt.Errorf("failed to marshal request: %w", err)
} }
httpReq, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIURL, bytes.NewReader(body)) // Retry loop for transient errors (429, 529, 5xx)
if err != nil { var respBody []byte
return nil, fmt.Errorf("failed to create request: %w", err) var lastErr error
}
httpReq.Header.Set("Content-Type", "application/json") for attempt := 0; attempt <= maxRetries; attempt++ {
httpReq.Header.Set("x-api-key", c.apiKey) if attempt > 0 {
httpReq.Header.Set("anthropic-version", anthropicAPIVersion) // 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) select {
if err != nil { case <-ctx.Done():
return nil, fmt.Errorf("request failed: %w", err) return nil, ctx.Err()
} case <-time.After(backoff):
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)
} }
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 var anthropicResp anthropicResponse

View file

@ -369,6 +369,7 @@ type ToolStartData struct {
// ToolEndData is sent when a tool execution completes // ToolEndData is sent when a tool execution completes
type ToolEndData struct { type ToolEndData struct {
Name string `json:"name"` Name string `json:"name"`
Input string `json:"input"`
Output string `json:"output"` Output string `json:"output"`
Success bool `json:"success"` 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 // Stream tool end event
callback(StreamEvent{ callback(StreamEvent{
Type: "tool_end", 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 // Fall back to context-based routing if VMID lookup didn't find anything
if targetNode == "" { 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) // or from context (guest_node field)
if node, ok := req.Context["guest_node"].(string); ok && node != "" { if targetNode == "" {
targetNode = strings.ToLower(node) if node, ok := req.Context["guest_node"].(string); ok && node != "" {
} else if req.TargetID != "" { targetNode = strings.ToLower(node)
parts := strings.Split(req.TargetID, "-") } else if req.TargetID != "" {
if len(parts) >= 2 { parts := strings.Split(req.TargetID, "-")
targetNode = strings.ToLower(parts[0]) 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) { t.Run("loads alerts from valid file", func(t *testing.T) {
m := newTestManager(t) m := newTestManager(t)
m.ClearActiveAlerts()
// Create an alert and save it // Create an alert and save it
startTime := time.Now().Add(-30 * time.Minute) startTime := time.Now().Add(-30 * time.Minute)
@ -14714,8 +14713,10 @@ func TestLoadActiveAlerts(t *testing.T) {
// Save to disk // Save to disk
_ = m.SaveActiveAlerts() _ = m.SaveActiveAlerts()
// Clear and reload // Clear in-memory map only (don't use ClearActiveAlerts which triggers async save)
m.ClearActiveAlerts() m.mu.Lock()
m.activeAlerts = make(map[string]*Alert)
m.mu.Unlock()
err := m.LoadActiveAlerts() err := m.LoadActiveAlerts()
if err != nil { if err != nil {

View file

@ -430,6 +430,7 @@ type NodeResponse struct {
IsCluster bool `json:"isCluster,omitempty"` IsCluster bool `json:"isCluster,omitempty"`
ClusterName string `json:"clusterName,omitempty"` ClusterName string `json:"clusterName,omitempty"`
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,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 { func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string {
@ -834,6 +835,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
IsCluster: pve.IsCluster, IsCluster: pve.IsCluster,
ClusterName: pve.ClusterName, ClusterName: pve.ClusterName,
ClusterEndpoints: pve.ClusterEndpoints, ClusterEndpoints: pve.ClusterEndpoints,
Source: pve.Source,
} }
nodes = append(nodes, node) nodes = append(nodes, node)
} }
@ -859,6 +861,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorPruneJobs: pbs.MonitorPruneJobs, MonitorPruneJobs: pbs.MonitorPruneJobs,
MonitorGarbageJobs: pbs.MonitorGarbageJobs, MonitorGarbageJobs: pbs.MonitorGarbageJobs,
Status: h.getNodeStatus("pbs", pbs.Name), Status: h.getNodeStatus("pbs", pbs.Name),
Source: pbs.Source,
} }
nodes = append(nodes, node) 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 AutoRegisterRequest struct {
Type string `json:"type"` // "pve" or "pbs" Type string `json:"type"` // "pve" or "pbs"
Host string `json:"host"` // The host URL Host string `json:"host"` // The host URL
@ -5548,6 +5551,7 @@ type AutoRegisterRequest struct {
ServerName string `json:"serverName"` // Hostname or IP ServerName string `json:"serverName"` // Hostname or IP
SetupCode string `json:"setupCode,omitempty"` // One-time setup code for authentication (deprecated) SetupCode string `json:"setupCode,omitempty"` // One-time setup code for authentication (deprecated)
AuthToken string `json:"authToken,omitempty"` // Direct auth token from URL (new approach) 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 // New secure fields
RequestToken bool `json:"requestToken,omitempty"` // If true, Pulse will generate and return a token 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") 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, 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 existingIndex := -1
if req.Type == "pve" { if req.Type == "pve" {
for i, node := range h.config.PVEInstances { 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 existingIndex = i
break break
} }
} }
} else { } else {
for i, node := range h.config.PBSInstances { 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 existingIndex = i
break break
} }
@ -5780,6 +5795,10 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
instance.Password = "" instance.Password = ""
instance.TokenName = nodeConfig.TokenName instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue 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 // Check for cluster if not already detected
if !instance.IsCluster { if !instance.IsCluster {
@ -5809,6 +5828,10 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
instance.Password = "" instance.Password = ""
instance.TokenName = nodeConfig.TokenName instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue 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 // Keep other settings as they were
} }
log.Info(). log.Info().
@ -5864,6 +5887,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
IsCluster: isCluster, IsCluster: isCluster,
ClusterName: clusterName, ClusterName: clusterName,
ClusterEndpoints: clusterEndpoints, ClusterEndpoints: clusterEndpoints,
Source: req.Source, // Track how this node was registered
} }
h.config.PVEInstances = append(h.config.PVEInstances, newInstance) h.config.PVEInstances = append(h.config.PVEInstances, newInstance)
@ -5911,6 +5935,7 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
MonitorVerifyJobs: monitorVerifyJobs, MonitorVerifyJobs: monitorVerifyJobs,
MonitorPruneJobs: monitorPruneJobs, MonitorPruneJobs: monitorPruneJobs,
MonitorGarbageJobs: monitorGarbageJobs, MonitorGarbageJobs: monitorGarbageJobs,
Source: req.Source, // Track how this node was registered
} }
h.config.PBSInstances = append(h.config.PBSInstances, newInstance) h.config.PBSInstances = append(h.config.PBSInstances, newInstance)
} }
@ -6272,3 +6297,111 @@ func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyP
return publicKeyString 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) // Generate setup script URL with temporary token (for authenticated users)
r.mux.HandleFunc("/api/setup-script-url", r.configHandlers.HandleSetupScriptURL) 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 // Auto-register route for setup scripts
r.mux.HandleFunc("/api/auto-register", r.configHandlers.HandleAutoRegister) r.mux.HandleFunc("/api/auto-register", r.configHandlers.HandleAutoRegister)
// Discovery endpoint // Discovery endpoint

View file

@ -424,6 +424,9 @@ type PVEInstance struct {
IsCluster bool // True if this is a cluster IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable ClusterName string // Cluster name if applicable
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes 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 // ClusterEndpoint represents a single node in a cluster
@ -461,6 +464,9 @@ type PBSInstance struct {
MonitorGarbageJobs bool MonitorGarbageJobs bool
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override) TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
SSHPort int // SSH port for temperature monitoring (0 = use global default) 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 // PMGInstance represents a Proxmox Mail Gateway connection

View file

@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/buffer" "github.com/rcourtman/pulse-go-rewrite/internal/buffer"
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics" "github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
"github.com/rcourtman/pulse-go-rewrite/internal/mdadm" "github.com/rcourtman/pulse-go-rewrite/internal/mdadm"
@ -35,6 +36,10 @@ type Config struct {
RunOnce bool RunOnce bool
LogLevel zerolog.Level LogLevel zerolog.Level
Logger *zerolog.Logger 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. // Agent is responsible for collecting host metrics and shipping them to Pulse.
@ -54,6 +59,7 @@ type Agent struct {
machineID string machineID string
agentID string agentID string
agentVersion string agentVersion string
updatedFrom string // Previous version if recently auto-updated (reported once)
interval time.Duration interval time.Duration
trimmedPulseURL string trimmedPulseURL string
reportBuffer *buffer.Queue[agentshost.Report] reportBuffer *buffer.Queue[agentshost.Report]
@ -169,6 +175,15 @@ func New(cfg Config) (*Agent, error) {
const bufferCapacity = 60 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{ agent := &Agent{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
@ -184,6 +199,7 @@ func New(cfg Config) (*Agent, error) {
machineID: machineID, machineID: machineID,
agentID: agentID, agentID: agentID,
agentVersion: agentVersion, agentVersion: agentVersion,
updatedFrom: updatedFrom,
interval: cfg.Interval, interval: cfg.Interval,
trimmedPulseURL: pulseURL, trimmedPulseURL: pulseURL,
reportBuffer: buffer.New[agentshost.Report](bufferCapacity), reportBuffer: buffer.New[agentshost.Report](bufferCapacity),
@ -201,6 +217,11 @@ func (a *Agent) Run(ctx context.Context) error {
return a.runOnce(ctx) return a.runOnce(ctx)
} }
// Proxmox setup (if enabled)
if a.cfg.EnableProxmox {
a.runProxmoxSetup(ctx)
}
// Start command client in background for AI command execution // Start command client in background for AI command execution
if a.commandClient != nil { if a.commandClient != nil {
go func() { go func() {
@ -304,6 +325,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Type: a.cfg.AgentType, Type: a.cfg.AgentType,
IntervalSeconds: int(a.interval / time.Second), IntervalSeconds: int(a.interval / time.Second),
Hostname: a.hostname, Hostname: a.hostname,
UpdatedFrom: a.updatedFrom,
}, },
Host: agentshost.HostInfo{ Host: agentshost.HostInfo{
ID: a.machineID, ID: a.machineID,
@ -325,6 +347,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Memory: snapshot.Memory, Memory: snapshot.Memory,
}, },
Disks: append([]agentshost.Disk(nil), snapshot.Disks...), Disks: append([]agentshost.Disk(nil), snapshot.Disks...),
DiskIO: append([]agentshost.DiskIO(nil), snapshot.DiskIO...),
Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...), Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...),
Sensors: sensorData, Sensors: sensorData,
RAID: raidData, RAID: raidData,
@ -458,3 +481,42 @@ func (a *Agent) collectRAIDArrays(ctx context.Context) []agentshost.RAIDArray {
return arrays 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, MinVersion: tls.VersionTLS12,
InsecureSkipVerify: c.insecureSkipVerify, InsecureSkipVerify: c.insecureSkipVerify,
}, },
HandshakeTimeout: 10 * time.Second, HandshakeTimeout: 45 * time.Second,
} }
// Connect // Connect
@ -172,6 +172,15 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
c.logger.Info().Msg("Connected and registered with Pulse command server") 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 // Start ping loop
pingDone := make(chan struct{}) pingDone := make(chan struct{})
go c.pingLoop(ctx, conn, pingDone) 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 // System call wrappers for testing
var ( var (
cpuCounts = gocpu.CountsWithContext cpuCounts = gocpu.CountsWithContext
cpuPercent = gocpu.PercentWithContext cpuPercent = gocpu.PercentWithContext
loadAvg = goload.AvgWithContext loadAvg = goload.AvgWithContext
virtualMemory = gomem.VirtualMemoryWithContext virtualMemory = gomem.VirtualMemoryWithContext
diskPartitions = godisk.PartitionsWithContext diskPartitions = godisk.PartitionsWithContext
diskUsage = godisk.UsageWithContext diskUsage = godisk.UsageWithContext
netInterfaces = gonet.InterfacesWithContext diskIOCounters = godisk.IOCountersWithContext
netIOCounters = gonet.IOCountersWithContext netInterfaces = gonet.InterfacesWithContext
netIOCounters = gonet.IOCountersWithContext
) )
// Snapshot represents a host resource utilisation sample. // Snapshot represents a host resource utilisation sample.
@ -35,6 +36,7 @@ type Snapshot struct {
LoadAverage []float64 LoadAverage []float64
Memory agentshost.MemoryMetric Memory agentshost.MemoryMetric
Disks []agentshost.Disk Disks []agentshost.Disk
DiskIO []agentshost.DiskIO
Network []agentshost.NetworkInterface Network []agentshost.NetworkInterface
} }
@ -77,6 +79,7 @@ func Collect(ctx context.Context) (Snapshot, error) {
} }
snapshot.Disks = collectDisks(collectCtx) snapshot.Disks = collectDisks(collectCtx)
snapshot.DiskIO = collectDiskIO(collectCtx)
snapshot.Network = collectNetwork(collectCtx) snapshot.Network = collectNetwork(collectCtx)
return snapshot, nil return snapshot, nil
@ -234,3 +237,78 @@ func isLoopback(flags []string) bool {
} }
return false 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 package hostmetrics
import ( import (
"context" "context"
"testing" "encoding/json"
"testing"
godisk "github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
) )
func TestCollectDisks_ZFS_Deduplication(t *testing.T) { func TestCollectDiskIO(t *testing.T) {
// Save original functions and restore after test ctx := context.Background()
origDiskPartitions := diskPartitions
origDiskUsage := diskUsage snapshot, err := Collect(ctx)
defer func() { if err != nil {
diskPartitions = origDiskPartitions t.Fatalf("Collect failed: %v", err)
diskUsage = origDiskUsage }
}()
t.Logf("DiskIO count: %d", len(snapshot.DiskIO))
// Mock partitions
diskPartitions = func(ctx context.Context, all bool) ([]godisk.PartitionStat, error) { if len(snapshot.DiskIO) == 0 {
return []godisk.PartitionStat{ t.Error("Expected disk IO data but got none")
{Device: "tank/dataset1", Mountpoint: "/mnt/dataset1", Fstype: "zfs"}, }
{Device: "tank/dataset2", Mountpoint: "/mnt/dataset2", Fstype: "zfs"},
{Device: "tank/dataset3", Mountpoint: "/mnt/dataset3", Fstype: "zfs"}, data, _ := json.MarshalIndent(snapshot.DiskIO, "", " ")
}, nil t.Logf("DiskIO data:\n%s", string(data))
}
// 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)
} }

View file

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

View file

@ -162,6 +162,7 @@ type Host struct {
Memory Memory `json:"memory"` Memory Memory `json:"memory"`
LoadAverage []float64 `json:"loadAverage,omitempty"` LoadAverage []float64 `json:"loadAverage,omitempty"`
Disks []Disk `json:"disks,omitempty"` Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
Sensors HostSensorSummary `json:"sensors,omitempty"` Sensors HostSensorSummary `json:"sensors,omitempty"`
RAID []HostRAIDArray `json:"raid,omitempty"` RAID []HostRAIDArray `json:"raid,omitempty"`
@ -219,6 +220,19 @@ type HostRAIDDevice struct {
Slot int `json:"slot"` 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. // DockerHost represents a Docker host reporting metrics via the external agent.
type DockerHost struct { type DockerHost struct {
ID string `json:"id"` ID string `json:"id"`

View file

@ -315,6 +315,7 @@ type HostFrontend struct {
LoadAverage []float64 `json:"loadAverage,omitempty"` LoadAverage []float64 `json:"loadAverage,omitempty"`
Memory *Memory `json:"memory,omitempty"` Memory *Memory `json:"memory,omitempty"`
Disks []Disk `json:"disks,omitempty"` Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
Sensors *HostSensorSummaryFrontend `json:"sensors,omitempty"` Sensors *HostSensorSummaryFrontend `json:"sensors,omitempty"`
Status string `json:"status"` 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)) network := make([]models.HostNetworkInterface, 0, len(report.Network))
for _, nic := range report.Network { for _, nic := range report.Network {
network = append(network, models.HostNetworkInterface{ 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...), LoadAverage: append([]float64(nil), report.Host.LoadAverage...),
Memory: memory, Memory: memory,
Disks: disks, Disks: disks,
DiskIO: diskIO,
NetworkInterfaces: network, NetworkInterfaces: network,
Sensors: models.HostSensorSummary{ Sensors: models.HostSensorSummary{
TemperatureCelsius: cloneStringFloatMap(report.Sensors.TemperatureCelsius), TemperatureCelsius: cloneStringFloatMap(report.Sensors.TemperatureCelsius),
@ -1950,6 +1965,9 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
if len(host.Disks) == 0 { if len(host.Disks) == 0 {
host.Disks = nil host.Disks = nil
} }
if len(host.DiskIO) == 0 {
host.DiskIO = nil
}
if len(host.NetworkInterfaces) == 0 { if len(host.NetworkInterfaces) == 0 {
host.NetworkInterfaces = nil host.NetworkInterfaces = nil
} }

View file

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

View file

@ -109,6 +109,7 @@ func isCPUChip(chipLower string) bool {
func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) { func parseCPUTemps(chipMap map[string]interface{}, data *TemperatureData) {
foundPackageTemp := false foundPackageTemp := false
var chipletTemps []float64 var chipletTemps []float64
var genericTemp float64 // For chips that only report temp1
for sensorName, sensorData := range chipMap { for sensorName, sensorData := range chipMap {
sensorMap, ok := sensorData.(map[string]interface{}) 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 // Look for AMD chiplet temperatures
if strings.HasPrefix(sensorName, "Tccd") { if strings.HasPrefix(sensorName, "Tccd") {
if tempVal := extractTempInput(sensorMap); !math.IsNaN(tempVal) && tempVal > 0 { 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) { 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 { func NormalizeVersion(version string) string {
return strings.TrimPrefix(strings.TrimSpace(version), "v") 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"` Host HostInfo `json:"host"`
Metrics Metrics `json:"metrics"` Metrics Metrics `json:"metrics"`
Disks []Disk `json:"disks,omitempty"` Disks []Disk `json:"disks,omitempty"`
DiskIO []DiskIO `json:"diskIO,omitempty"`
Network []NetworkInterface `json:"network,omitempty"` Network []NetworkInterface `json:"network,omitempty"`
Sensors Sensors `json:"sensors,omitempty"` Sensors Sensors `json:"sensors,omitempty"`
RAID []RAIDArray `json:"raid,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 Type string `json:"type,omitempty"` // "unified", "host", or "docker" - empty means legacy
IntervalSeconds int `json:"intervalSeconds,omitempty"` IntervalSeconds int `json:"intervalSeconds,omitempty"`
Hostname string `json:"hostname,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. // HostInfo contains platform and identification details about the monitored host.
@ -70,6 +72,19 @@ type Disk struct {
Usage float64 `json:"usage,omitempty"` 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. // NetworkInterface summarises network adapter statistics.
type NetworkInterface struct { type NetworkInterface struct {
Name string `json:"name"` 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) return nil, fmt.Errorf("failed to create client for %s: %w", selectedEndpoint, err)
} }
// Quick connectivity test // Connectivity test - 5 seconds to allow for TLS handshake (~3s typical)
testCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
testNodes, testErr := testClient.GetNodes(testCtx) testNodes, testErr := testClient.GetNodes(testCtx)
cancel() cancel()
@ -489,10 +489,12 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
cc.lastHealthCheck[ep] = now cc.lastHealthCheck[ep] = now
cc.mu.Unlock() 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 := cc.config
cfg.Host = ep cfg.Host = ep
cfg.Timeout = 2 * time.Second // Use shorter timeout for recovery attempts cfg.Timeout = 5 * time.Second
testClient, err := NewClient(cfg) testClient, err := NewClient(cfg)
if err != nil { if err != nil {
@ -504,8 +506,8 @@ func (cc *ClusterClient) recoverUnhealthyNodes(ctx context.Context) {
return return
} }
// Try a simple API call with short timeout // Try a simple API call
testCtx, cancel := context.WithTimeout(ctx, 2*time.Second) testCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, err = testClient.GetNodes(testCtx) _, err = testClient.GetNodes(testCtx)
cancel() cancel()

View file

@ -54,6 +54,8 @@ PULSE_TOKEN=""
INTERVAL="30s" INTERVAL="30s"
ENABLE_HOST="true" ENABLE_HOST="true"
ENABLE_DOCKER="false" ENABLE_DOCKER="false"
ENABLE_PROXMOX="false"
PROXMOX_TYPE=""
UNINSTALL="false" UNINSTALL="false"
INSECURE="false" INSECURE="false"
AGENT_ID="" AGENT_ID=""
@ -83,6 +85,8 @@ build_exec_args() {
EXEC_ARGS="$EXEC_ARGS --enable-host=false" EXEC_ARGS="$EXEC_ARGS --enable-host=false"
fi fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; 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 [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS="$EXEC_ARGS --agent-id ${AGENT_ID}"; 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) EXEC_ARGS_ARRAY+=(--enable-host=false)
fi fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); 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 [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS_ARRAY+=(--agent-id "$AGENT_ID"); 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 ;; --disable-host) ENABLE_HOST="false"; shift ;;
--enable-docker) ENABLE_DOCKER="true"; shift ;; --enable-docker) ENABLE_DOCKER="true"; shift ;;
--disable-docker) ENABLE_DOCKER="false"; shift ;; --disable-docker) ENABLE_DOCKER="false"; shift ;;
--enable-proxmox) ENABLE_PROXMOX="true"; shift ;;
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
--insecure) INSECURE="true"; shift ;; --insecure) INSECURE="true"; shift ;;
--uninstall) UNINSTALL="true"; shift ;; --uninstall) UNINSTALL="true"; shift ;;
--agent-id) AGENT_ID="$2"; shift 2 ;; --agent-id) AGENT_ID="$2"; shift 2 ;;