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:
parent
3f08bfe8d0
commit
cc3c0187a0
45 changed files with 2038 additions and 353 deletions
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
344
internal/hostagent/proxmox_setup.go
Normal file
344
internal/hostagent/proxmox_setup.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
defer func() {
|
|
||||||
diskPartitions = origDiskPartitions
|
|
||||||
diskUsage = origDiskUsage
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Mock partitions
|
snapshot, err := Collect(ctx)
|
||||||
diskPartitions = func(ctx context.Context, all bool) ([]godisk.PartitionStat, error) {
|
if err != nil {
|
||||||
return []godisk.PartitionStat{
|
t.Fatalf("Collect failed: %v", err)
|
||||||
{Device: "tank/dataset1", Mountpoint: "/mnt/dataset1", Fstype: "zfs"},
|
}
|
||||||
{Device: "tank/dataset2", Mountpoint: "/mnt/dataset2", Fstype: "zfs"},
|
|
||||||
{Device: "tank/dataset3", Mountpoint: "/mnt/dataset3", Fstype: "zfs"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock usage
|
t.Logf("DiskIO count: %d", len(snapshot.DiskIO))
|
||||||
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)
|
if len(snapshot.DiskIO) == 0 {
|
||||||
// We can't easily mock queryZpoolStats because it's in zfs.go and not exported/variable-ized in the same way,
|
t.Error("Expected disk IO data but got none")
|
||||||
// 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.
|
data, _ := json.MarshalIndent(snapshot.DiskIO, "", " ")
|
||||||
origQueryZpoolStats := queryZpoolStats
|
t.Logf("DiskIO data:\n%s", string(data))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
266
internal/monitoring/host_agent_temps.go
Normal file
266
internal/monitoring/host_agent_temps.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ;;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue