diff --git a/Dockerfile b/Dockerfile index 9fa4fd5..8ec5133 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,6 +113,8 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \ COPY --from=backend-builder /app/VERSION /VERSION +ENV PULSE_NO_AUTO_UPDATE=true + ENTRYPOINT ["/usr/local/bin/pulse-docker-agent"] # Final stage (Pulse server runtime) diff --git a/docs/DOCKER_MONITORING.md b/docs/DOCKER_MONITORING.md index 87698af..9b8f0ad 100644 --- a/docs/DOCKER_MONITORING.md +++ b/docs/DOCKER_MONITORING.md @@ -1,6 +1,6 @@ # Docker Monitoring Agent -Pulse is focused on Proxmox VE and PBS, but many homelabs also run application stacks in Docker. The optional Pulse Docker agent turns container health and resource usage into first-class metrics that show up alongside your hypervisor data. +Pulse is focused on Proxmox VE and PBS, but many homelabs also run application stacks in Docker. The optional Pulse Docker agent turns container health and resource usage into first-class metrics that show up alongside your hypervisor data. The recommended deployment is the bundled, least-privilege systemd service that runs the static `pulse-docker-agent` binary directly on the host. That path lets the installer lock down permissions, manage upgrades automatically, and integrate with the native init system. Containerising the agent is still available for orchestrated environments, but it trades away some of those controls (and still needs the Docker socket) so treat that option as advanced. ## What the agent reports @@ -12,6 +12,7 @@ Every check interval (30s by default) the agent collects: - CPU usage, memory consumption and limits - Images, port mappings, network addresses, and start times - Writable layer size, root filesystem size, block I/O totals, and mount metadata (shown in the Docker table drawer) +- Read/write throughput derived from Docker block I/O counters so you can spot noisy workloads at a glance - Health-check failures, restart-loop windows, and recent exit codes (displayed in the UI under each container drawer) Data is pushed to Pulse over HTTPS using your existing API token – no inbound firewall rules required. @@ -38,7 +39,7 @@ Copy the binary to your Docker host (e.g. `/usr/local/bin/pulse-docker-agent`) a > **Why `CGO_ENABLED=0`?** Building a fully static binary ensures the agent runs on hosts still using older glibc releases (for example Debian 11 with glibc 2.31). -### Quick install from your Pulse server +### Quick install from your Pulse server (recommended) Use the bundled installation script (ships with Pulse v4.22.0+) to deploy and manage the agent. Replace the token placeholder with an API token generated in **Settings → Security**. Create a dedicated token for each Docker host so you can revoke individual credentials without touching others—sharing one token across many hosts makes incident response much harder. Tokens used here should include the `docker:report` scope so the agent can submit telemetry (add `docker:manage` only if you plan to issue lifecycle commands remotely). @@ -49,6 +50,10 @@ curl -fsSL http://pulse.example.com/install-docker-agent.sh \ > **Why sudo?** The installer needs to drop binaries under `/usr/local/bin`, create a systemd service, and start it—actions that require root privileges. Piping to `sudo bash …` saves you from retrying if you run the command as an unprivileged user. +The script stores credentials in `/etc/pulse/pulse-docker-agent.env` (mode `600`) and creates a locked-down `pulse-docker` service account that only needs access to the Docker socket. Rotate tokens by editing that env file and running `sudo systemctl restart pulse-docker-agent`. + +To keep remote stop/remove commands working from Pulse, the installer also drops a small polkit rule that lets the `pulse-docker` service account run `systemctl stop/disable pulse-docker-agent` without password prompts. If you remove that rule, expect to acknowledge stop requests manually with `sudo systemctl disable --now pulse-docker-agent`. + Running the one-liner again from another Pulse server (with its own URL/token) will merge that server into the same agent automatically—no extra flags required. To report to more than one Pulse instance from the same Docker host, repeat the `--target` flag (format: `https://pulse.example.com|`) or export `PULSE_TARGETS` before running the script: @@ -112,32 +117,58 @@ A single `pulse-docker-agent` process can now serve any number of Pulse backends ```ini [Unit] Description=Pulse Docker Agent -After=network.target docker.service -Requires=docker.service +After=network-online.target docker.socket docker.service +Wants=network-online.target docker.socket [Service] Type=simple -Environment=PULSE_URL=https://pulse.example.com -Environment=PULSE_TOKEN=replace-me -Environment=PULSE_TARGETS=https://pulse.example.com|replace-me;https://pulse-dr.example.com|replace-me-dr +EnvironmentFile=-/etc/pulse/pulse-docker-agent.env ExecStart=/usr/local/bin/pulse-docker-agent --interval 30s -Restart=always -RestartSec=5 +Restart=on-failure +RestartSec=5s +StartLimitIntervalSec=120 +StartLimitBurst=5 +User=pulse-docker +Group=pulse-docker +SupplementaryGroups=docker +UMask=0077 +NoNewPrivileges=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=read-only +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectKernelLogs=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/var/run/docker.sock +ProtectHostname=yes +ProtectClock=yes [Install] WantedBy=multi-user.target ``` -### Containerised agent (advanced) +Rotate credentials or add additional Pulse targets by editing `/etc/pulse/pulse-docker-agent.env` and reloading the service with `sudo systemctl restart pulse-docker-agent`. + +### Containerised agent (advanced / optional) If you prefer to run the agent inside a container, mount the Docker socket and supply the same environment variables: ```bash docker run -d \ --name pulse-docker-agent \ + --pid=host \ + --uts=host \ -e PULSE_URL="https://pulse.example.com" \ -e PULSE_TOKEN="" \ -e PULSE_TARGETS="https://pulse.example.com|;https://pulse-dr.example.com|" \ + -e PULSE_NO_AUTO_UPDATE=true \ + -v /etc/machine-id:/etc/machine-id:ro \ -v /var/run/docker.sock:/var/run/docker.sock \ --restart unless-stopped \ ghcr.io/rcourtman/pulse-docker-agent:latest @@ -145,6 +176,8 @@ docker run -d \ > **Note**: Official images for `linux/amd64` and `linux/arm64` are published to `ghcr.io/rcourtman/pulse-docker-agent`. To test local changes, run `docker build --target agent_runtime -t pulse-docker-agent:test .` from the repository root. +`--pid=host`, `--uts=host`, and the `/etc/machine-id` bind keep host metadata stable so Pulse doesn’t think the container itself is the Docker host. Auto-update is disabled in the image by default; rebuild or override `PULSE_NO_AUTO_UPDATE=false` only if you manage upgrades outside of your orchestrator. Expect to grant the container the same level of Docker socket access as the systemd service—running inside Docker doesn’t sandbox the agent from the host. + ## Configuration reference | Flag / Env var | Description | Default | diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index a9c1d69..5031d1f 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -173,6 +173,13 @@ The auto-setup script (Settings → Nodes → Setup Script) will prompt you to c If the node is part of a Proxmox cluster, the script will now detect the other members and offer to configure the same SSH/lm-sensors setup on each of them automatically—confirm when prompted to roll it out cluster-wide. +### Host-side responsibilities + +- Run the host installer (`install-sensor-proxy.sh`) on the Proxmox machine that hosts Pulse to install and maintain the `pulse-sensor-proxy` service. The node setup script does not create this service. +- Re-run the host installer if the service or socket disappears after a host upgrade or configuration cleanup; the installer is idempotent. +- The installer now ships a self-heal timer (`pulse-sensor-proxy-selfheal.timer`) that restarts or reinstalls the proxy if it ever goes missing; leave it enabled for automatic recovery. +- Hot dev builds now warn when only a container-local proxy socket is present, signalling that the host proxy needs to be reinstalled before temperatures will flow back into Pulse. + ### Turnkey Setup for Standalone Nodes (v4.25.0+) **For standalone nodes** (not in a Proxmox cluster) running **containerized Pulse**, the setup script now automatically configures temperature monitoring with zero manual steps: diff --git a/frontend-modern/pnpm-lock.yaml b/frontend-modern/pnpm-lock.yaml new file mode 100644 index 0000000..847fae7 Binary files /dev/null and b/frontend-modern/pnpm-lock.yaml differ diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 9536312..4bf5ed3 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -148,6 +148,76 @@ export class MonitoringAPI { } } + static async allowDockerHostReenroll(hostId: string): Promise { + const url = `${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}/allow-reenroll`; + + const response = await apiFetch(url, { + method: 'POST', + }); + + if (!response.ok) { + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } + } catch (_err) { + // ignore parse error, use raw text + } + } + } catch (_err) { + // ignore read error + } + + throw new Error(message); + } + } + + static async deleteHostAgent(hostId: string): Promise { + if (!hostId) { + throw new Error('Host ID is required to remove a host agent.'); + } + + const url = `${this.baseUrl}/agents/host/${encodeURIComponent(hostId)}`; + const response = await apiFetch(url, { method: 'DELETE' }); + + if (!response.ok) { + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } else if (typeof parsed?.message === 'string' && parsed.message.trim()) { + message = parsed.message.trim(); + } + } catch (_err) { + // Ignore JSON parse errors, fallback to raw text. + } + } + } catch (_err) { + // Ignore body read errors, keep default message. + } + + throw new Error(message); + } + + // Consume and ignore the body so the fetch can be reused by the connection pool. + try { + await response.text(); + } catch (_err) { + // Swallow body read errors; the deletion already succeeded. + } + } + static async lookupHost(params: { id?: string; hostname?: string }): Promise { const search = new URLSearchParams(); if (params.id) search.set('id', params.id); diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index b1a2c0c..ea5064f 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -4,6 +4,7 @@ import { StatusBadge } from '@/components/shared/StatusBadge'; import type { Alert } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; +import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider'; const COLUMN_TOOLTIP_LOOKUP: Record = { 'cpu %': 'Percent CPU utilization allowed before an alert fires.', @@ -48,6 +49,8 @@ const COLUMN_TOOLTIP_LOOKUP: Record = { const OFFLINE_ALERTS_TOOLTIP = 'Toggle default behavior for powered-off or connectivity alerts for this resource type.'; +const SLIDER_METRICS = new Set(['cpu', 'memory', 'disk', 'temperature']); + export interface Resource { id: string; name: string; @@ -79,6 +82,7 @@ export interface Resource { toggleTitleEnabled?: string; toggleTitleDisabled?: string; editable?: boolean; + note?: string; [key: string]: unknown; } @@ -103,6 +107,7 @@ interface ResourceTableProps { resourceId: string, thresholds: Record, defaults: Record, + note: string | undefined, ) => void; onSaveEdit: (resourceId: string) => void; onCancelEdit: () => void; @@ -136,6 +141,8 @@ interface ResourceTableProps { groupHeaderMeta?: Record; factoryDefaults?: Record; onResetDefaults?: () => void; + editingNote: () => string; + setEditingNote: (value: string) => void; } type OfflineState = 'off' | 'warning' | 'critical'; @@ -513,7 +520,7 @@ export function ResourceTable(props: ResourceTableProps) { {/* Global Defaults Row */} - + +
+ +