feat: Add in-app help system with HelpIcon component

Add contextual help icons throughout the UI to improve feature
discoverability. Users can click (?) icons to see explanations
with examples for settings they might not understand.

- HelpIcon component with click-to-open popover
- Centralized help content registry in /content/help/
- FeatureTip component for dismissible contextual tips
- Help added to: alert delay, AI endpoints, update channel
This commit is contained in:
rcourtman
2026-01-07 09:21:03 +00:00
parent b75b33b9fe
commit dcdbee3c5c
31 changed files with 1093 additions and 107 deletions

View File

@@ -205,7 +205,12 @@ If you're comfortable with your security setup, you can dismiss warnings:
By default, configuration export/import is blocked. You have two options:
### Option 1: Set API Tokens (Recommended)
### Option 1: Create an API Token (Recommended)
Create a token in **Settings → Security → API Tokens**, then use it for exports.
For automation-only environments, you can seed tokens via environment variables (legacy) and
they will be persisted to `api_tokens.json` on startup.
Legacy environment seeding:
```bash
# Using systemd (secure)
sudo systemctl edit pulse
@@ -255,7 +260,7 @@ for sensitive data.
- SHA3-256 hashed before storage (64character hash)
- Raw token shown only once
- Tokens never stored in plain text
- Live reloading when `.env` changes
- Stored in `api_tokens.json` and managed via the UI
- API-only mode supported (no password auth required)
- **CSRF protection**: all state-changing operations require CSRF tokens
- **Rate limiting** (enhanced in v4.24.0)
@@ -359,7 +364,7 @@ The Quick Security Setup automatically:
- Stores only the 64-character hash
- Adds the token to the managed token list
#### Manual Token Setup
#### Manual Token Setup (Legacy Seeding)
```bash
# Using systemd (plain text values are auto-hashed on startup)
sudo systemctl edit pulse
@@ -374,7 +379,7 @@ docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest
# Environment="API_TOKENS=83c8...,b1de..."
```
**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored on disk. Plain values never persist beyond startup.
**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored in `api_tokens.json`. Plain values never persist beyond startup.
#### Token Management (Settings → API Tokens)
- Issue dedicated tokens for automation/agents without sharing a global credential
@@ -402,7 +407,7 @@ curl -H "Authorization: Bearer your-original-token" http://localhost:7655/api/ex
#### Secure Mode
- Require API token for all operations
- Protects auto-registration endpoint
- Enable by setting at least one API token via `API_TOKENS` (or legacy `API_TOKEN`) environment variable
- Enable by creating at least one API token (UI or legacy env seeding)
### Runtime Logging Configuration
@@ -445,7 +450,8 @@ docker run \
## CORS (Cross-Origin Resource Sharing)
By default, Pulse only allows same-origin requests (no CORS headers). This is the most secure configuration.
By default, Pulse allows all origins (`ALLOWED_ORIGINS=*`). This is convenient for local setups,
but should be restricted in production.
### Configuring CORS for External Access
@@ -466,8 +472,8 @@ PULSE_DEV=true
Notes:
- `ALLOWED_ORIGINS` currently supports a single origin or `*` (it is written directly to `Access-Control-Allow-Origin`).
- Never use `ALLOWED_ORIGINS=*` in production as it allows any website to access your API.
- `ALLOWED_ORIGINS` supports a single origin or `*` (it is written directly to `Access-Control-Allow-Origin`).
- In production, set a specific origin to avoid exposing the API to arbitrary sites.
## Monitoring and Observability
@@ -563,7 +569,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
## Troubleshooting
**Account locked?** Wait 15 minutes or contact admin for manual reset
**Export blocked?** You're on a public network login with password, set an API token (`API_TOKENS`), or set `ALLOW_UNPROTECTED_EXPORT=true`
**Export blocked?** You're on a public network login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true`
**Rate limited?** Wait 1 minute and try again
**Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
**API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash)
@@ -571,13 +577,3 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
**Forgot password?** Start fresh delete your Pulse data and restart
---
_Last updated: 2025-10-20_
**Version 4.24.0 Security Enhancements:**
- ✅ X-RateLimit-* headers for all API responses
- ✅ Runtime logging configuration for incident response
- ✅ Scheduler health API for anomaly detection
- ✅ Enhanced audit logging (rollback actions, scheduler events)
- ✅ Adaptive polling with circuit breakers and backoff
- ✅ Shared script library system (secure installer patterns)

View File

@@ -452,7 +452,11 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
envKubeContext := strings.TrimSpace(getenv("PULSE_KUBE_CONTEXT"))
envKubeIncludeNamespaces := strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_NAMESPACES"))
envKubeExcludeNamespaces := strings.TrimSpace(getenv("PULSE_KUBE_EXCLUDE_NAMESPACES"))
envKubeIncludeAllPods := strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_ALL_POD_FILES")) // wait, it was PULSE_KUBE_INCLUDE_ALL_PODS in original
envKubeIncludeAllPods := strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_ALL_PODS"))
if envKubeIncludeAllPods == "" {
// Backwards compatibility for older env var name.
envKubeIncludeAllPods = strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_ALL_POD_FILES"))
}
envKubeIncludeAllDeployments := strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_ALL_DEPLOYMENTS"))
envKubeMaxPods := strings.TrimSpace(getenv("PULSE_KUBE_MAX_PODS"))
envDiskExclude := strings.TrimSpace(getenv("PULSE_DISK_EXCLUDE"))

View File

@@ -1,5 +1,7 @@
# Pulse AI
Pulse Pro unlocks **AI Patrol** for continuous, automated health checks. Learn more at https://pulserelay.pro.
Pulse AI adds an optional assistant for troubleshooting and proactive monitoring. It is **off by default** and can be enabled per instance.
## What Makes AI Patrol Different
@@ -150,4 +152,3 @@ If you enable execution features, ensure agent tokens and scopes are appropriate
| No execution capability | Confirm at least one agent is connected |
| Findings not persisting | Check Pulse has write access to `ai_findings.enc` in the config directory |
| Too many findings | This shouldn't happen - please report if it does |

View File

@@ -6,7 +6,7 @@ Pulse provides a comprehensive REST API for automation and integration.
## 🔐 Authentication
All API requests require authentication via one of the following methods:
Most API requests require authentication via one of the following methods:
**1. API Token (Recommended)**
Pass the token in the `X-API-Token` header.
@@ -22,6 +22,19 @@ curl -H "Authorization: Bearer your-token" http://localhost:7655/api/health
**3. Session Cookie**
Standard browser session cookie (used by the UI).
Public endpoints include:
- `GET /api/health`
- `GET /api/version`
## 🔏 Scopes and Admin Access
Some endpoints require admin privileges and/or scopes. Common scopes include:
- `monitoring:read`
- `settings:read`
- `settings:write`
Endpoints that require admin access are noted below.
---
## 📡 Core Endpoints
@@ -73,6 +86,10 @@ Validate credentials before saving.
Returns time-series data for CPU, Memory, and Storage.
**Ranges**: `1h`, `24h`, `7d`, `30d`
### Storage Charts
`GET /api/storage-charts`
Returns storage chart data.
### Storage Stats
`GET /api/storage/`
Detailed storage usage per node and pool.
@@ -86,13 +103,29 @@ Combined view of PVE and PBS backups.
## 🔔 Notifications
### Send Test Notification
`POST /api/notifications/test`
`POST /api/notifications/test` (admin)
Triggers a test alert to all configured channels.
### Manage Webhooks
- `GET /api/notifications/webhooks`
- `POST /api/notifications/webhooks`
- `DELETE /api/notifications/webhooks/<id>`
### Email, Apprise, and Webhooks
- `GET /api/notifications/email` (admin)
- `PUT /api/notifications/email` (admin)
- `GET /api/notifications/apprise` (admin)
- `PUT /api/notifications/apprise` (admin)
- `GET /api/notifications/webhooks` (admin)
- `POST /api/notifications/webhooks` (admin)
- `PUT /api/notifications/webhooks/<id>` (admin)
- `DELETE /api/notifications/webhooks/<id>` (admin)
- `POST /api/notifications/webhooks/test` (admin)
- `GET /api/notifications/webhook-templates` (admin)
- `GET /api/notifications/webhook-history` (admin)
- `GET /api/notifications/email-providers` (admin)
- `GET /api/notifications/health` (admin)
### Queue and Dead-Letter Tools
- `GET /api/notifications/queue/stats` (admin)
- `GET /api/notifications/dlq` (admin)
- `POST /api/notifications/dlq/retry` (admin)
- `POST /api/notifications/dlq/delete` (admin)
---
@@ -170,11 +203,13 @@ Streaming variant of execute (used by the UI for incremental responses).
- `GET /api/ai/patrol/status`
- `GET /api/ai/patrol/findings`
- `GET /api/ai/patrol/history`
- `GET /api/ai/patrol/stream`
- `POST /api/ai/patrol/run` (admin)
### Cost Tracking
`GET /api/ai/cost/summary`
Get AI usage statistics (includes retention window details).
- `GET /api/ai/cost/summary`
- `POST /api/ai/cost/reset` (admin)
- `GET /api/ai/cost/export` (admin)
## 📈 Metrics Store (v5)

View File

@@ -54,11 +54,17 @@ In **Settings → System → Updates**:
### Environment Variables
```bash
# Disable auto-update check
PULSE_AUTO_UPDATE_CHECK=false
# Enable one-click updates
AUTO_UPDATE_ENABLED=true
# Use release candidate channel
PULSE_UPDATE_CHANNEL=rc
UPDATE_CHANNEL=rc
# Adjust automatic check cadence (duration string)
AUTO_UPDATE_CHECK_INTERVAL=24h
# Schedule daily checks (HH:MM, 24h)
AUTO_UPDATE_TIME=03:00
```
## Manual Update Methods

View File

@@ -16,7 +16,11 @@ Pulse uses a split-configuration model to ensure security and flexibility.
| `ai.enc` | AI settings and credentials | 🔒 **Encrypted** |
| `metrics.db` | Persistent metrics history (SQLite) | 📝 Standard |
All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes).
All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default.
Path overrides:
- `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token.
- `PULSE_AUTH_CONFIG_DIR` sets the directory for `.env` (auth-only) if you need auth on a separate volume.
---
@@ -31,8 +35,9 @@ This file controls access to Pulse. It is **never** exposed to the UI.
PULSE_AUTH_USER='admin'
PULSE_AUTH_PASS='$2a$12$...'
# API Tokens (comma-separated)
API_TOKENS='token1,token2'
# Legacy API tokens (deprecated, auto-migrated to api_tokens.json)
API_TOKEN='token1'
API_TOKENS='token2,token3'
```
<details>
@@ -58,6 +63,9 @@ Configure Single Sign-On in **Settings → Security → Single Sign-On**, or use
See [OIDC Documentation](OIDC.md) and [Proxy Auth](PROXY_AUTH.md) for details.
</details>
> **Note**: `API_TOKEN` / `API_TOKENS` are legacy and will be migrated into `api_tokens.json` on startup.
> Manage API tokens in the UI for long-term support.
---
## 🖥️ System Settings (`system.json`)
@@ -103,12 +111,17 @@ Environment variables take precedence over `system.json`.
| Variable | Description | Default |
|----------|-------------|---------|
| `PULSE_PUBLIC_URL` | URL for agent install commands, notifications, and OIDC. **Important for reverse proxy setups**: Set this to your internal Pulse URL (e.g., `http://192.168.1.10:7655`) so agents connect directly instead of through the proxy. | Auto-detected |
| `ALLOWED_ORIGINS` | CORS allowed domains | `""` (Same origin) |
| `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. **Reverse proxy setups**: set this to the direct/internal Pulse URL (e.g., `http://192.168.1.10:7655`) so agents connect directly instead of via the proxy. | Auto-detected |
| `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* |
| `ALLOWED_ORIGINS` | CORS allowed domains | `*` |
| `IFRAME_EMBEDDING_ALLOW` | Iframe embedding policy (`SAMEORIGIN`, `ALLOWALL`, etc.) | `SAMEORIGIN` |
| `DISCOVERY_ENABLED` | Auto-discover nodes | `false` |
| `DISCOVERY_SUBNET` | CIDR or `auto` | `auto` |
| `PULSE_ENABLE_SENSOR_PROXY` | Enable legacy `pulse-sensor-proxy` endpoints (deprecated, unsupported) | `false` |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
| `DEMO_MODE` | Enable read-only demo mode | `false` |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks | *(unset)* |
### Monitoring Overrides
@@ -117,14 +130,43 @@ Environment variables take precedence over `system.json`.
| `PVE_POLLING_INTERVAL` | PVE metrics polling frequency | `10s` |
| `PBS_POLLING_INTERVAL` | PBS metrics polling frequency | `60s` |
| `PMG_POLLING_INTERVAL` | PMG metrics polling frequency | `60s` |
| `CONCURRENT_POLLING` | Enable concurrent polling for multi-node clusters | `true` |
| `CONNECTION_TIMEOUT` | API connection timeout | `45s` |
| `BACKUP_POLLING_CYCLES` | Poll cycles between backup checks | `10` |
| `ENABLE_BACKUP_POLLING` | Enable backup job monitoring | `true` |
| `BACKUP_POLLING_INTERVAL` | Backup polling frequency | `0` (Auto) |
| `ENABLE_TEMPERATURE_MONITORING` | Enable temperature monitoring (where supported) | `true` |
| `SSH_PORT` | SSH port for legacy SSH-based temperature collection | `22` |
| `ADAPTIVE_POLLING_ENABLED` | Enable smart polling for large clusters | `false` |
| `ADAPTIVE_POLLING_BASE_INTERVAL` | Base interval for adaptive polling | `10s` |
| `ADAPTIVE_POLLING_MIN_INTERVAL` | Minimum adaptive polling interval | `5s` |
| `ADAPTIVE_POLLING_MAX_INTERVAL` | Maximum adaptive polling interval | `5m` |
| `GUEST_METADATA_MIN_REFRESH_INTERVAL` | Minimum refresh for guest metadata | `2m` |
| `GUEST_METADATA_REFRESH_JITTER` | Jitter for guest metadata refresh | `45s` |
| `GUEST_METADATA_RETRY_BACKOFF` | Retry backoff for guest metadata | `30s` |
| `GUEST_METADATA_MAX_CONCURRENT` | Max concurrent guest metadata fetches | `4` |
| `DNS_CACHE_TIMEOUT` | Cache TTL for DNS lookups | `5m` |
| `MAX_POLL_TIMEOUT` | Maximum time per polling cycle | `3m` |
| `WEBHOOK_BATCH_DELAY` | Delay before sending batched webhooks | `10s` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker update detection entirely | `false` |
### Logging Overrides
| Variable | Description | Default |
|----------|-------------|---------|
| `LOG_FILE` | Log file path (empty = stdout) | *(unset)* |
| `LOG_MAX_SIZE` | Log file max size (MB) | `100` |
| `LOG_MAX_AGE` | Log file retention (days) | `30` |
| `LOG_COMPRESS` | Compress rotated logs | `true` |
### Update Settings
| Variable | Description | Default |
|----------|-------------|---------|
| `UPDATE_CHANNEL` | Update channel (`stable` or `rc`) | `stable` |
| `AUTO_UPDATE_ENABLED` | Allow one-click updates | `false` |
| `AUTO_UPDATE_CHECK_INTERVAL` | Auto-check interval | `24h` |
| `AUTO_UPDATE_TIME` | Scheduled check time (HH:MM) | `03:00` |
### Metrics Retention (Tiered)

View File

@@ -54,14 +54,14 @@ Pulse is configured via environment variables.
| `TZ` | Timezone | `UTC` |
| `PULSE_AUTH_USER` | Admin Username | *(unset)* |
| `PULSE_AUTH_PASS` | Admin Password | *(unset)* |
| `API_TOKENS` | Comma-separated API tokens | *(unset)* |
| `API_TOKENS` | Comma-separated API tokens (**legacy**) | *(unset)* |
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed domains | *(none)* |
| `ALLOWED_ORIGINS` | CORS allowed domains | `*` |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker update detection entirely | `false` |
> **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events.
> **Note**: `API_TOKEN` / `API_TOKENS` are legacy. Prefer managing API tokens in the UI after initial setup.
<details>
<summary><strong>Advanced: Resource Limits & Healthcheck</strong></summary>
@@ -143,7 +143,7 @@ When multiple containers have updates available, an **"Update All"** button appe
### Requirements
- **Unified Agent v5.0.6+** running on the Docker host
- **Unified agent** running on the Docker host with Docker monitoring enabled
- Agent must have Docker socket access (`/var/run/docker.sock`)
- Registry must be accessible for update detection (public registries work automatically)
@@ -164,7 +164,6 @@ Pulse provides granular control over update features via environment variables o
| Variable | Description |
|----------|-------------|
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. |
| `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disables update detection entirely. No registry checks are performed. |
**Example - Read-Only Mode** (detect updates but prevent actions):
```yaml
@@ -175,14 +174,7 @@ services:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```
**Example - Fully Disable Update Detection**:
```yaml
services:
pulse:
image: rcourtman/pulse:latest
environment:
- PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true
```
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI: **Settings → Agents → Docker Settings**.

View File

@@ -120,7 +120,7 @@ Pulse is secure by default. On first launch, you must retrieve a **Bootstrap Tok
2. Paste the **Bootstrap Token**.
3. Create your **Admin Username** and **Password**.
> **Note**: If you configure authentication via environment variables (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS` and/or `API_TOKENS`), the bootstrap token is automatically removed and this step is skipped.
> **Note**: If you configure authentication via environment variables (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS` and/or legacy `API_TOKENS`), the bootstrap token is automatically removed and this step is skipped.
---
@@ -135,7 +135,7 @@ Pulse can self-update to the latest stable version.
| Platform | Command |
|----------|---------|
| **Docker** | `docker pull rcourtman/pulse:latest && docker restart pulse` |
| **Kubernetes** | `helm repo update && helm upgrade pulse pulse/pulse -n pulse` |
| **Kubernetes** | `helm upgrade pulse oci://ghcr.io/rcourtman/pulse-chart -n pulse` |
| **Systemd** | Re-download binary and restart service |
### Rollback

View File

@@ -25,6 +25,8 @@ Deploy Pulse to Kubernetes using the official Helm chart.
Configure via `values.yaml` or `--set` flags.
> **Note**: `API_TOKEN` / `API_TOKENS` environment variables are legacy. Prefer managing API tokens in the UI after initial setup.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `service.type` | Service type (ClusterIP/LoadBalancer) | `ClusterIP` |
@@ -54,19 +56,13 @@ server:
secretEnv:
create: true
data:
API_TOKENS: "my-token"
agent:
enabled: false
secretEnv:
create: true
data:
PULSE_TOKEN: "my-token"
PULSE_AUTH_USER: "admin"
PULSE_AUTH_PASS: "replace-me"
```
Apply with:
```bash
helm upgrade --install pulse pulse/pulse -n pulse -f values.yaml
helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart -n pulse -f values.yaml
```
---

View File

@@ -52,7 +52,7 @@ Because local login credentials are stored in `.env` (not part of exports), you
1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
2. **Confirm API access**:
* If you created API tokens in the UI, those token records are included in the export and should continue working.
* If you used `.env`-based `API_TOKENS`/`API_TOKEN`, reconfigure them on the new host.
* If you used `.env`-based `API_TOKENS`/`API_TOKEN` (legacy), reconfigure them on the new host or re-create tokens in the UI.
3. **Update Agents**:
* **Unified Agent**: Update the `--token` flag in your service definition.
* **Docker**: Update `PULSE_TOKEN` in your container config.

View File

@@ -35,13 +35,14 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
## Setting Up Direct PBS Connection
### Method 1: Agent Install (Recommended for Bare Metal)
### Method 1: Unified Agent Install (Recommended for Bare Metal)
Install the Pulse agent directly on your PBS server for automatic setup:
Install the unified agent directly on your PBS server for automatic setup:
```bash
# Run on your PBS server
curl -sSL https://your-pulse-server/api/pulse-agent-install?type=pbs | sudo bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
sudo bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox --proxmox-type pbs
```
The agent will:

View File

@@ -19,6 +19,7 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl
| `PROXY_AUTH_SECRET` | **Required**. Shared secret to verify requests. | - |
| `PROXY_AUTH_USER_HEADER` | **Required**. Header containing the username. | - |
| `PROXY_AUTH_ROLE_HEADER` | Header containing user groups/roles. | - |
| `PROXY_AUTH_ROLE_SEPARATOR` | Separator for multiple roles in the header. | `|` |
| `PROXY_AUTH_ADMIN_ROLE` | Role name that grants admin access. | `admin` |
| `PROXY_AUTH_LOGOUT_URL` | URL to redirect to after logout. | - |

View File

@@ -38,6 +38,13 @@ Welcome to the Pulse documentation portal. Here you'll find everything you need
- **[Auto Updates](AUTO_UPDATE.md)** One-click updates for supported deployments.
- **[Kubernetes](KUBERNETES.md)** Helm deployment (ingress, persistence, HA patterns).
## 🚀 Pulse Pro
Pulse Pro unlocks **AI Patrol** — automated background monitoring that spots issues before they become incidents.
- **[Learn more at pulserelay.pro](https://pulserelay.pro)**
- **[AI Patrol deep dive](AI.md)**
## 📡 Monitoring & Agents
- **[Unified Agent](UNIFIED_AGENT.md)** Single binary for Host and Docker monitoring.

View File

@@ -41,17 +41,16 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|------|---------|-------------|---------|
| `--url` | `PULSE_URL` | Pulse server URL | `http://localhost:7655` |
| `--token` | `PULSE_TOKEN` | API token | *(required)* |
| `--token-file` | - | Read API token from file | *(unset)* |
| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` |
| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Force enable Docker metrics | **auto-detect** |
| `--disable-docker` | - | Disable Docker even if detected | - |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` (auto-detect if not configured) |
| `--docker-runtime` | `PULSE_DOCKER_RUNTIME` | Force container runtime: `auto`, `docker`, or `podman` | `auto` |
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Force enable Kubernetes metrics | **auto-detect** |
| `--disable-kubernetes` | - | Disable Kubernetes even if detected | - |
| `--enable-proxmox` | `PULSE_ENABLE_PROXMOX` | Force enable Proxmox integration | **auto-detect** |
| `--disable-proxmox` | - | Disable Proxmox even if detected | - |
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | `false` |
| `--enable-proxmox` | `PULSE_ENABLE_PROXMOX` | Enable Proxmox integration | `false` |
| `--proxmox-type` | `PULSE_PROXMOX_TYPE` | Proxmox type: `pve` or `pbs` | *(auto-detect)* |
| `--enable-commands` | `PULSE_ENABLE_COMMANDS` | Enable AI command execution (disabled by default) | `false` |
| `--disable-commands` | `PULSE_DISABLE_COMMANDS` | **Deprecated** (commands are disabled by default) | - |
| `--disk-exclude` | `PULSE_DISK_EXCLUDE` | Mount point patterns to exclude from disk monitoring (repeatable or CSV) | *(none)* |
| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (optional) | *(auto)* |
| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context (optional) | *(auto)* |
@@ -65,22 +64,30 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
| `--agent-id` | `PULSE_AGENT_ID` | Unique agent identifier | *(machine-id)* |
| `--report-ip` | `PULSE_REPORT_IP` | Override reported IP (multi-NIC) | *(auto)* |
| `--tag` | `PULSE_TAGS` | Apply tags (repeatable or CSV) | *(none)* |
| `--log-level` | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
| `--health-addr` | `PULSE_HEALTH_ADDR` | Health/metrics server address | `:9191` |
**Token resolution order**: `--token``--token-file``PULSE_TOKEN``/var/lib/pulse-agent/token`.
Legacy env var: `PULSE_KUBE_INCLUDE_ALL_POD_FILES` is still accepted for backward compatibility.
## Auto-Detection
The installer automatically detects available platforms on the target machine:
Auto-detection behavior:
- **Docker/Podman**: Enabled if `docker info` or `podman info` succeeds
- **Kubernetes**: Enabled if `kubectl cluster-info` succeeds or kubeconfig exists
- **Proxmox**: Enabled if `/etc/pve` or `/etc/proxmox-backup` exists
- **Host metrics**: Enabled by default.
- **Docker/Podman**: Enabled automatically if Docker/Podman is detected and `PULSE_ENABLE_DOCKER` was not explicitly set.
- **Kubernetes**: Only enabled when `--enable-kubernetes`/`PULSE_ENABLE_KUBERNETES=true` is set.
- **Proxmox**: Only enabled when `--enable-proxmox`/`PULSE_ENABLE_PROXMOX=true` is set. Type auto-detects `pve` vs `pbs` if not specified.
Use `--disable-*` flags to skip auto-detected platforms, or `--enable-*` to force enable.
To disable Docker auto-detection, set `--enable-docker=false` or `PULSE_ENABLE_DOCKER=false`.
## Installation Options
### Simple Install (auto-detects everything)
### Simple Install (host + Docker auto-detect)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token>
@@ -95,7 +102,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
### Disable Docker (even if detected)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-docker
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
```
### Host + Kubernetes Monitoring
@@ -107,7 +114,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-host --enable-docker
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
```
### Exclude Specific Disks from Monitoring

View File

@@ -20,7 +20,7 @@ If you prefer CLI, use the official installer for the target version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
sudo bash -s -- --version v5.0.0
sudo bash -s -- --stable
```
### Docker

View File

@@ -54,6 +54,7 @@ Exposed at `:9091/metrics`.
| `pulse_monitor_poll_staleness_seconds` | Gauge | Age since last success. |
| `pulse_monitor_poll_queue_depth` | Gauge | Queue size. |
| `pulse_monitor_poll_errors_total` | Counter | Error counts by category. |
| `pulse_scheduler_queue_due_soon` | Gauge | Tasks due in the next 12 seconds. |
## ⚡ Circuit Breaker
| State | Trigger | Recovery |

View File

@@ -12,28 +12,41 @@ This listener is separate from the main UI/API port (`7655`). In Docker and Kube
| Metric | Type | Description |
| :--- | :--- | :--- |
| `pulse_http_request_duration_seconds` | Histogram | Latency buckets by `method`, `route`, `status`. |
| `pulse_http_requests_total` | Counter | Total requests. |
| `pulse_http_request_errors_total` | Counter | 4xx/5xx errors. |
| `pulse_http_requests_total` | Counter | Total requests by `method`, `route`, `status`. |
| `pulse_http_request_errors_total` | Counter | Error totals by `method`, `route`, `status_class` (`client_error`, `server_error`, `none`). |
## 🔄 Polling & Nodes
| Metric | Type | Description |
| :--- | :--- | :--- |
| `pulse_monitor_node_poll_duration_seconds` | Histogram | Per-node poll latency. |
| `pulse_monitor_node_poll_total` | Counter | Success/error counts per node. |
| `pulse_monitor_node_poll_staleness_seconds` | Gauge | Seconds since last success. |
| `pulse_monitor_poll_duration_seconds` | Histogram | Per-instance poll latency. |
| `pulse_monitor_poll_total` | Counter | Success/error counts per instance (`result` label). |
| `pulse_monitor_poll_errors_total` | Counter | Poll failures by `error_type`. |
| `pulse_monitor_poll_last_success_timestamp` | Gauge | Unix timestamp of last success. |
| `pulse_monitor_poll_staleness_seconds` | Gauge | Seconds since last success (`-1` if never succeeded). |
| `pulse_monitor_poll_queue_depth` | Gauge | Global queue depth. |
| `pulse_monitor_poll_inflight` | Gauge | In-flight polls by `instance_type`. |
| `pulse_monitor_node_poll_duration_seconds` | Histogram | Per-node poll latency. |
| `pulse_monitor_node_poll_total` | Counter | Success/error counts per node (`result` label). |
| `pulse_monitor_node_poll_errors_total` | Counter | Node poll failures by `error_type`. |
| `pulse_monitor_node_poll_last_success_timestamp` | Gauge | Unix timestamp of last node success. |
| `pulse_monitor_node_poll_staleness_seconds` | Gauge | Seconds since last node success (`-1` if never succeeded). |
## 🧠 Scheduler Health
| Metric | Type | Description |
| :--- | :--- | :--- |
| `pulse_scheduler_queue_depth` | Gauge | Queue depth per instance type. |
| `pulse_scheduler_dead_letter_depth` | Gauge | DLQ depth per instance. |
| `pulse_scheduler_breaker_state` | Gauge | `0`=Closed, `1`=Half-Open, `2`=Open. |
| `pulse_scheduler_queue_due_soon` | Gauge | Tasks due within the next 12 seconds. |
| `pulse_scheduler_queue_depth` | Gauge | Queue depth per `instance_type`. |
| `pulse_scheduler_queue_wait_seconds` | Histogram | Wait time between task readiness and execution. |
| `pulse_scheduler_dead_letter_depth` | Gauge | DLQ depth by `instance_type` and `instance`. |
| `pulse_scheduler_breaker_state` | Gauge | `0`=Closed, `1`=Half-Open, `2`=Open, `-1`=Unknown. |
| `pulse_scheduler_breaker_failure_count` | Gauge | Consecutive failure count. |
| `pulse_scheduler_breaker_retry_seconds` | Gauge | Seconds until next retry allowed. |
## ⚡ Diagnostics Cache
| Metric | Type | Description |
| :--- | :--- | :--- |
| `pulse_diagnostics_cache_hits_total` | Counter | Cache hits. |
| `pulse_diagnostics_cache_misses_total` | Counter | Cache misses. |
| `pulse_diagnostics_refresh_duration_seconds` | Histogram | Refresh latency. |
## 🚨 Alerting Examples

View File

@@ -6,6 +6,7 @@ import type { Alert } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { HelpIcon } from '@/components/shared/HelpIcon';
import { logger } from '@/utils/logger';
const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
@@ -962,8 +963,9 @@ export function ResourceTable(props: ResourceTableProps) {
<span class="text-sm text-gray-400">-</span>
</td>
<td class="p-1 px-2 align-middle">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 inline-flex items-center gap-1">
Alert Delay (s)
<HelpIcon contentId="alerts.thresholds.delay" size="xs" />
</span>
</td>
<For each={props.columns}>

View File

@@ -3,6 +3,7 @@ import { createStore } from 'solid-js/store';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import { HelpIcon } from '@/components/shared/HelpIcon';
import { formField, labelClass, controlClass } from '@/components/shared/Form';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
@@ -916,14 +917,20 @@ export const AISettings: Component = () => {
class={controlClass()}
disabled={saving()}
/>
<input
type="url"
value={form.openaiBaseUrl}
onInput={(e) => setForm('openaiBaseUrl', e.currentTarget.value)}
placeholder="Custom base URL (optional, for Azure OpenAI)"
class={controlClass()}
disabled={saving()}
/>
<div class="space-y-1">
<label class="text-xs text-gray-600 dark:text-gray-400 inline-flex items-center gap-1">
Custom Base URL
<HelpIcon contentId="ai.openai.baseUrl" size="xs" />
</label>
<input
type="url"
value={form.openaiBaseUrl}
onInput={(e) => setForm('openaiBaseUrl', e.currentTarget.value)}
placeholder="https://openrouter.ai/api/v1 (optional)"
class={controlClass()}
disabled={saving()}
/>
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Get API key </a>
@@ -1120,14 +1127,20 @@ export const AISettings: Component = () => {
</button>
<Show when={expandedProviders().has('ollama')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="url"
value={form.ollamaBaseUrl}
onInput={(e) => setForm('ollamaBaseUrl', e.currentTarget.value)}
placeholder="http://localhost:11434"
class={controlClass()}
disabled={saving()}
/>
<div class="space-y-1">
<label class="text-xs text-gray-600 dark:text-gray-400 inline-flex items-center gap-1">
Server URL
<HelpIcon contentId="ai.ollama.baseUrl" size="xs" />
</label>
<input
type="url"
value={form.ollamaBaseUrl}
onInput={(e) => setForm('ollamaBaseUrl', e.currentTarget.value)}
placeholder="http://localhost:11434"
class={controlClass()}
disabled={saving()}
/>
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://ollama.ai" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline">Learn about Ollama </a>

View File

@@ -1,6 +1,7 @@
import { Component, Show, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { HelpIcon } from '@/components/shared/HelpIcon';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import CheckCircle from 'lucide-solid/icons/check-circle';
import ArrowRight from 'lucide-solid/icons/arrow-right';
@@ -519,6 +520,7 @@ sudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Update Preferences
<HelpIcon contentId="updates.pulse.channel" size="xs" />
</h4>
{/* Update Channel */}

View File

@@ -0,0 +1,129 @@
import { Component, Show } from 'solid-js';
import { A } from '@solidjs/router';
import X from 'lucide-solid/icons/x';
import Lightbulb from 'lucide-solid/icons/lightbulb';
import ArrowRight from 'lucide-solid/icons/arrow-right';
import type { FeatureTip as FeatureTipType } from '@/content/features';
import { dismissTip, isTipDismissed } from '@/stores/featureTips';
export interface FeatureTipProps {
/** The feature tip to display */
tip: FeatureTipType;
/** Display variant */
variant?: 'inline' | 'banner' | 'compact';
/** Additional CSS classes */
class?: string;
/** Called when the tip is dismissed */
onDismiss?: () => void;
}
export const FeatureTip: Component<FeatureTipProps> = (props) => {
// Don't render if already dismissed
if (isTipDismissed(props.tip.id)) {
return null;
}
const variant = () => props.variant ?? 'inline';
const handleDismiss = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dismissTip(props.tip.id);
props.onDismiss?.();
};
// Inline variant - small, subtle
if (variant() === 'inline') {
return (
<div
class={`flex items-start gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-xs ${props.class ?? ''}`}
>
<Lightbulb class="w-3.5 h-3.5 text-blue-500 flex-shrink-0 mt-0.5" strokeWidth={2} />
<div class="flex-1 min-w-0">
<span class="text-blue-800 dark:text-blue-200">{props.tip.description}</span>
<Show when={props.tip.action}>
<A
href={props.tip.action!.path}
class="ml-1 text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
{props.tip.action!.label}
</A>
</Show>
</div>
<button
type="button"
onClick={handleDismiss}
class="p-0.5 text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 rounded transition-colors flex-shrink-0"
aria-label="Dismiss tip"
>
<X class="w-3 h-3" strokeWidth={2} />
</button>
</div>
);
}
// Compact variant - single line
if (variant() === 'compact') {
return (
<div
class={`flex items-center gap-2 px-2 py-1 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs ${props.class ?? ''}`}
>
<Lightbulb class="w-3 h-3 text-amber-500 flex-shrink-0" strokeWidth={2} />
<span class="text-amber-800 dark:text-amber-200 truncate">{props.tip.title}</span>
<Show when={props.tip.action}>
<A
href={props.tip.action!.path}
class="text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-0.5"
>
<ArrowRight class="w-3 h-3" strokeWidth={2} />
</A>
</Show>
<button
type="button"
onClick={handleDismiss}
class="p-0.5 text-amber-400 hover:text-amber-600 dark:hover:text-amber-300 rounded transition-colors flex-shrink-0 ml-auto"
aria-label="Dismiss tip"
>
<X class="w-3 h-3" strokeWidth={2} />
</button>
</div>
);
}
// Banner variant - full width, more prominent
return (
<div
class={`flex items-start gap-3 p-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800 rounded-lg ${props.class ?? ''}`}
>
<div class="p-1.5 bg-blue-100 dark:bg-blue-800 rounded-lg flex-shrink-0">
<Lightbulb class="w-4 h-4 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-blue-900 dark:text-blue-100">{props.tip.title}</div>
<p class="text-xs text-blue-700 dark:text-blue-300 mt-0.5">{props.tip.description}</p>
<Show when={props.tip.action}>
<A
href={props.tip.action!.path}
class="inline-flex items-center gap-1 mt-2 text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
{props.tip.action!.label}
<ArrowRight class="w-3 h-3" strokeWidth={2} />
</A>
</Show>
</div>
<button
type="button"
onClick={handleDismiss}
class="p-1 text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-800 rounded transition-colors flex-shrink-0"
aria-label="Dismiss tip"
>
<X class="w-4 h-4" strokeWidth={2} />
</button>
</div>
);
};
export default FeatureTip;

View File

@@ -0,0 +1,233 @@
import { Component, Show, createSignal, createEffect, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import CircleHelp from 'lucide-solid/icons/circle-help';
import ExternalLink from 'lucide-solid/icons/external-link';
import X from 'lucide-solid/icons/x';
import { getHelpContent, type HelpContentId, type HelpContent } from '@/content/help';
export interface HelpIconProps {
/** Help content ID from registry */
contentId?: HelpContentId;
/** Inline content (alternative to contentId for one-off help) */
inline?: {
title: string;
description: string;
examples?: string[];
docUrl?: string;
};
/** Icon size variant */
size?: 'xs' | 'sm' | 'md';
/** Additional CSS classes for the button */
class?: string;
/** Popover position relative to icon */
position?: 'top' | 'bottom';
/** Max width of popover in pixels */
maxWidth?: number;
}
const sizeClasses = {
xs: 'w-3 h-3',
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
};
export const HelpIcon: Component<HelpIconProps> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
const [popoverPosition, setPopoverPosition] = createSignal({ top: 0, left: 0 });
let buttonRef: HTMLButtonElement | undefined;
let popoverRef: HTMLDivElement | undefined;
// Get content from registry or inline prop
const content = (): HelpContent | undefined => {
if (props.inline) {
return {
id: 'inline',
title: props.inline.title,
description: props.inline.description,
examples: props.inline.examples,
docUrl: props.inline.docUrl,
};
}
if (props.contentId) {
return getHelpContent(props.contentId);
}
return undefined;
};
const size = () => props.size ?? 'sm';
const maxWidth = () => props.maxWidth ?? 320;
const preferredPosition = () => props.position ?? 'top';
// Calculate popover position when opened
createEffect(() => {
if (!isOpen() || !buttonRef) return;
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
if (!buttonRef || !popoverRef) return;
const buttonRect = buttonRef.getBoundingClientRect();
const popoverRect = popoverRef.getBoundingClientRect();
const viewportPadding = 8;
let top: number;
let left = buttonRect.left + buttonRect.width / 2 - popoverRect.width / 2;
// Position above or below based on preference and available space
if (preferredPosition() === 'top') {
top = buttonRect.top - popoverRect.height - 8;
// If not enough space above, flip to below
if (top < viewportPadding) {
top = buttonRect.bottom + 8;
}
} else {
top = buttonRect.bottom + 8;
// If not enough space below, flip to above
if (top + popoverRect.height > window.innerHeight - viewportPadding) {
top = buttonRect.top - popoverRect.height - 8;
}
}
// Clamp horizontal position to viewport
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - popoverRect.width - viewportPadding));
// Clamp vertical position to viewport
top = Math.max(viewportPadding, Math.min(top, window.innerHeight - popoverRect.height - viewportPadding));
setPopoverPosition({ top, left });
});
});
// Close on outside click
createEffect(() => {
if (!isOpen()) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (buttonRef?.contains(target) || popoverRef?.contains(target)) return;
setIsOpen(false);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
buttonRef?.focus();
}
};
// Delay adding listeners to avoid immediate close from the opening click
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}, 0);
onCleanup(() => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
});
});
const helpContent = content();
// Don't render if no content available
if (!helpContent) {
if (props.contentId) {
console.warn(`[HelpIcon] No content found for ID: ${props.contentId}`);
}
return null;
}
return (
<>
<button
ref={buttonRef}
type="button"
class={`inline-flex items-center justify-center text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 rounded-full ${props.class ?? ''}`}
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen());
}}
aria-label={`Help: ${helpContent.title}`}
aria-expanded={isOpen()}
aria-haspopup="dialog"
>
<CircleHelp class={sizeClasses[size()]} strokeWidth={2} />
</button>
<Show when={isOpen()}>
<Portal mount={document.body}>
<div
ref={popoverRef}
role="dialog"
aria-labelledby="help-popover-title"
class="fixed z-[9999] bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in-0 zoom-in-95 duration-150"
style={{
top: `${popoverPosition().top}px`,
left: `${popoverPosition().left}px`,
'max-width': `${maxWidth()}px`,
'min-width': '200px',
}}
>
{/* Header */}
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<span id="help-popover-title" class="text-sm font-medium text-gray-900 dark:text-gray-100">
{helpContent.title}
</span>
<button
type="button"
class="p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors"
onClick={() => setIsOpen(false)}
aria-label="Close help"
>
<X class="w-3.5 h-3.5" strokeWidth={2} />
</button>
</div>
{/* Content */}
<div class="px-3 py-2.5 text-xs text-gray-600 dark:text-gray-300 space-y-2">
<p class="whitespace-pre-line leading-relaxed">{helpContent.description}</p>
<Show when={helpContent.examples && helpContent.examples.length > 0}>
<div class="pt-2 border-t border-gray-100 dark:border-gray-700">
<p class="text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-500 font-medium mb-1.5">
Examples
</p>
<ul class="space-y-1 text-[11px]">
{helpContent.examples!.map((example) => (
<li class="flex items-start gap-1.5">
<span class="text-gray-400 dark:text-gray-500 mt-0.5 select-none">-</span>
<span class="text-gray-600 dark:text-gray-400">{example}</span>
</li>
))}
</ul>
</div>
</Show>
<Show when={helpContent.docUrl}>
<div class="pt-2 border-t border-gray-100 dark:border-gray-700">
<a
href={helpContent.docUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-[11px] font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline"
>
Learn more
<ExternalLink class="w-3 h-3" strokeWidth={2} />
</a>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</>
);
};
export default HelpIcon;

View File

@@ -0,0 +1,94 @@
/**
* Feature Discovery Registry
*
* Central export point for feature tips and What's New content.
*/
import type { FeatureTip } from './types';
// Feature tips for discoverable features
const featureTips: FeatureTip[] = [
{
id: 'alert-delay-thresholds',
title: 'Alert Delay Thresholds',
description:
'Click the clock icon to set how long a threshold must be exceeded before an alert fires. Prevents alerts from brief spikes.',
location: 'alerts',
addedInVersion: 'v5.0.0',
action: {
label: 'Go to Alerts',
path: '/alerts',
},
priority: 10,
},
{
id: 'custom-ai-endpoints',
title: 'Custom AI Endpoints',
description:
'Use OpenRouter, vLLM, or any OpenAI-compatible API. Expand the OpenAI section in AI Settings and enter your provider\'s base URL.',
location: 'settings',
addedInVersion: 'v4.5.0',
action: {
label: 'Configure AI',
path: '/settings',
},
priority: 8,
},
{
id: 'container-update-detection',
title: 'Container Update Detection',
description:
'Pulse checks for container image updates automatically. Look for the blue badge on containers with available updates.',
location: 'docker',
addedInVersion: 'v5.0.11',
action: {
label: 'View Containers',
path: '/docker',
},
priority: 7,
},
{
id: 'sparkline-metrics',
title: 'Sparkline Metrics',
description:
'Toggle between bar charts and sparklines for CPU/Memory metrics. Click the chart icon in the column header.',
location: 'dashboard',
addedInVersion: 'v4.0.0',
priority: 5,
},
{
id: 'column-customization',
title: 'Customize Columns',
description:
'Click "Columns" to show/hide table columns. Your preferences are saved automatically.',
location: 'dashboard',
addedInVersion: 'v4.0.0',
priority: 4,
},
];
/**
* Get all feature tips
*/
export function getAllFeatureTips(): FeatureTip[] {
return [...featureTips].sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Get feature tips for a specific location
*/
export function getFeatureTipsForLocation(location: FeatureTip['location']): FeatureTip[] {
return featureTips
.filter((tip) => tip.location === location || tip.location === 'global')
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/**
* Get a specific feature tip by ID
*/
export function getFeatureTip(id: string): FeatureTip | undefined {
return featureTips.find((tip) => tip.id === id);
}
// Re-export types
export type { FeatureTip } from './types';

View File

@@ -0,0 +1,29 @@
/**
* Feature Tip - A discoverable feature that users might not know about
*/
export interface FeatureTip {
/** Unique identifier for this tip */
id: string;
/** Short title for the tip */
title: string;
/** Description explaining the feature */
description: string;
/** Where this tip should appear in the UI */
location: 'alerts' | 'settings' | 'docker' | 'dashboard' | 'hosts' | 'global';
/** Version when this feature was added */
addedInVersion: string;
/** Optional call to action */
action?: {
label: string;
path: string;
};
/** Priority for display order (higher = more important) */
priority?: number;
}

View File

@@ -0,0 +1,55 @@
import type { HelpContent } from './types';
/**
* Help content for AI-related features
*/
export const aiHelpContent: HelpContent[] = [
{
id: 'ai.openai.baseUrl',
title: 'Custom OpenAI-Compatible Endpoint',
description:
'Use alternative providers with OpenAI-compatible APIs instead of the official OpenAI API.\n\n' +
'Supported services:\n' +
'- OpenRouter: Access Claude, Llama, Mistral, and 100+ models through one API key\n' +
'- vLLM / llama.cpp: Self-hosted local inference servers\n' +
'- Azure OpenAI: Enterprise Azure deployments\n' +
'- Together AI, Anyscale, Fireworks: Alternative cloud providers\n\n' +
'Enter the provider\'s base URL and use their API key in the API Key field.',
examples: [
'https://openrouter.ai/api/v1 (OpenRouter)',
'http://localhost:8000/v1 (vLLM local)',
'https://your-resource.openai.azure.com (Azure)',
'https://api.together.xyz/v1 (Together AI)',
],
addedInVersion: 'v4.5.0',
},
{
id: 'ai.ollama.baseUrl',
title: 'Ollama Server URL',
description:
'Connect to a local or remote Ollama instance for AI features.\n\n' +
'Ollama provides easy access to open-source models like Llama, Mistral, and CodeLlama ' +
'without requiring cloud API keys.\n\n' +
'Default: http://localhost:11434 (local Ollama installation)',
examples: [
'http://localhost:11434 (local)',
'http://192.168.1.100:11434 (LAN server)',
'http://ollama.internal:11434 (Docker network)',
],
addedInVersion: 'v4.5.0',
},
{
id: 'ai.providers.overview',
title: 'AI Provider Configuration',
description:
'Configure one or more AI providers to enable intelligent features:\n\n' +
'- Anomaly detection and pattern recognition\n' +
'- Natural language infrastructure queries\n' +
'- Automated troubleshooting suggestions\n' +
'- Alert investigation assistance\n\n' +
'You can configure multiple providers and Pulse will use the primary provider ' +
'with fallback to others if unavailable.',
related: ['ai.openai.baseUrl', 'ai.ollama.baseUrl'],
addedInVersion: 'v4.0.0',
},
];

View File

@@ -0,0 +1,49 @@
import type { HelpContent } from './types';
/**
* Help content for alert-related features
*/
export const alertsHelpContent: HelpContent[] = [
{
id: 'alerts.thresholds.delay',
title: 'Alert Delay (Sustained Duration)',
description:
'Click the clock icon to set per-metric delay settings.\n\n' +
'Alert delay defines how long a threshold must be continuously exceeded ' +
'before an alert fires. This prevents false positives from transient spikes ' +
'(e.g., brief CPU bursts during cron jobs, backups, or container startups).\n\n' +
'A metric must stay above the threshold for the entire delay period before alerting.',
examples: [
'5 seconds - Quick response, may catch brief spikes',
'30 seconds - Balanced (recommended default)',
'60 seconds - Conservative, filters most transient issues',
'300 seconds - Very conservative, only sustained problems',
],
addedInVersion: 'v4.0.0',
},
{
id: 'alerts.thresholds.hysteresis',
title: 'Trigger & Clear Thresholds',
description:
'Hysteresis prevents alert flapping when metrics hover near a threshold.\n\n' +
'The "trigger" threshold fires the alert (e.g., CPU > 90%).\n' +
'The "clear" threshold resolves it (e.g., CPU < 85%).\n\n' +
'This gap prevents rapid on/off cycling when values oscillate around the threshold.',
examples: [
'Trigger: 90%, Clear: 85% - 5% gap prevents flapping',
'Trigger: 95%, Clear: 90% - Tighter gap, more responsive',
],
addedInVersion: 'v4.0.0',
},
{
id: 'alerts.thresholds.perGuest',
title: 'Per-Guest Threshold Overrides',
description:
'Each VM or container can have custom threshold settings that override the defaults.\n\n' +
'Use this to set different thresholds for:\n' +
'- Database servers (higher memory thresholds)\n' +
'- Build servers (higher CPU thresholds)\n' +
'- Development VMs (more relaxed thresholds)',
addedInVersion: 'v4.2.0',
},
];

View File

@@ -0,0 +1,66 @@
/**
* Help Content Registry
*
* Central export point for all help content.
* Use getHelpContent(id) to retrieve content by ID.
*/
import type { HelpContent, HelpContentId, HelpContentRegistry } from './types';
import { alertsHelpContent } from './alerts';
import { aiHelpContent } from './ai';
import { updatesHelpContent } from './updates';
// Combine all help content sources
const allContent: HelpContent[] = [
...alertsHelpContent,
...aiHelpContent,
...updatesHelpContent,
];
// Build registry for O(1) lookups
const registry: HelpContentRegistry = {};
for (const item of allContent) {
if (registry[item.id]) {
console.warn(`[HelpContent] Duplicate ID detected: ${item.id}`);
}
registry[item.id] = item;
}
/**
* Get help content by ID
* @param id - The help content ID (e.g., "alerts.thresholds.delay")
* @returns The help content or undefined if not found
*/
export function getHelpContent(id: HelpContentId): HelpContent | undefined {
return registry[id];
}
/**
* Get all help content for a category
* @param category - The category prefix (e.g., "alerts", "ai")
* @returns Array of matching help content
*/
export function getHelpContentByCategory(category: string): HelpContent[] {
const prefix = category.endsWith('.') ? category : `${category}.`;
return allContent.filter((item) => item.id.startsWith(prefix));
}
/**
* Get all help content
* @returns Array of all registered help content
*/
export function getAllHelpContent(): HelpContent[] {
return [...allContent];
}
/**
* Check if help content exists for an ID
* @param id - The help content ID
* @returns true if content exists
*/
export function hasHelpContent(id: HelpContentId): boolean {
return id in registry;
}
// Re-export types
export type { HelpContent, HelpContentId, HelpContentRegistry } from './types';

View File

@@ -0,0 +1,43 @@
/**
* Help Content System Types
*
* Centralized help content for feature discoverability.
* All help text lives in this directory for easy maintenance.
*/
/**
* Unique identifier for help content items
* Format: category.subcategory.item (e.g., "alerts.thresholds.delay")
*/
export type HelpContentId = string;
/**
* Help content item - the core unit of help text
*/
export interface HelpContent {
/** Unique identifier for this help item */
id: HelpContentId;
/** Short title displayed in popover header */
title: string;
/** Main help text - supports newlines for formatting */
description: string;
/** Optional: Example values or use cases */
examples?: string[];
/** Optional: Link to documentation */
docUrl?: string;
/** Optional: Related help content IDs for cross-referencing */
related?: HelpContentId[];
/** Version when this feature was added (for What's New tracking) */
addedInVersion?: string;
}
/**
* Help content registry - centralized lookup by ID
*/
export type HelpContentRegistry = Record<HelpContentId, HelpContent>;

View File

@@ -0,0 +1,54 @@
import type { HelpContent } from './types';
/**
* Help content for update-related features
*/
export const updatesHelpContent: HelpContent[] = [
{
id: 'updates.docker.notifications',
title: 'Container Update Detection',
description:
'Pulse automatically detects when newer versions of your container images are available.\n\n' +
'How it works:\n' +
'- Compares local image digests against registry manifests\n' +
'- Checks periodically without pulling full images\n' +
'- Shows update badges on containers with available updates\n\n' +
'Supported registries: Docker Hub, GitHub Container Registry (ghcr.io), ' +
'and most private registries with v2 API support.',
examples: [
'Blue badge = Update available',
'Hover the badge for version details',
'Click container row for update instructions',
],
addedInVersion: 'v3.0.0',
},
{
id: 'updates.pulse.channel',
title: 'Update Channel',
description:
'Choose which Pulse releases to be notified about:\n\n' +
'- Stable: Production-ready releases only\n' +
'- Release Candidate (RC): Preview upcoming features before stable release\n\n' +
'RC builds are tested but may have rough edges. Use stable for production.',
examples: [
'stable - v5.0.11, v5.0.12, etc.',
'rc - v5.1.0-rc.1, v5.1.0-rc.2, etc.',
],
addedInVersion: 'v4.0.0',
},
{
id: 'updates.docker.checkInterval',
title: 'Docker Update Check Interval',
description:
'How often Pulse checks for container image updates.\n\n' +
'More frequent checks catch updates sooner but increase registry API calls. ' +
'Most registries have generous rate limits, but very frequent checks on many ' +
'containers could hit limits.',
examples: [
'1 hour - Frequent updates, higher API usage',
'6 hours - Balanced (recommended)',
'24 hours - Conservative, minimal API usage',
],
addedInVersion: 'v4.0.0',
},
];

View File

@@ -0,0 +1,112 @@
/**
* Feature Tips Store
*
* Manages feature discovery state:
* - Which feature tips have been dismissed
* - Provides functions to check and update discovery state
*/
import { createSignal } from 'solid-js';
import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
getAllFeatureTips,
getFeatureTipsForLocation,
type FeatureTip,
} from '@/content/features';
// Read dismissed tips from localStorage
const getInitialDismissedTips = (): Set<string> => {
if (typeof window === 'undefined') return new Set();
try {
const stored = localStorage.getItem(STORAGE_KEYS.DISMISSED_FEATURE_TIPS);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed);
}
}
} catch (_err) {
// Ignore localStorage errors
}
return new Set();
};
// Create signals
const [dismissedTips, setDismissedTips] = createSignal<Set<string>>(getInitialDismissedTips());
/**
* Check if a feature tip has been dismissed
*/
export function isTipDismissed(tipId: string): boolean {
return dismissedTips().has(tipId);
}
/**
* Dismiss a feature tip and persist to localStorage
*/
export function dismissTip(tipId: string): void {
const current = dismissedTips();
if (current.has(tipId)) return;
const updated = new Set(current);
updated.add(tipId);
setDismissedTips(updated);
if (typeof window !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEYS.DISMISSED_FEATURE_TIPS, JSON.stringify([...updated]));
} catch (err) {
logger.warn('Failed to save dismissed tips', err);
}
}
}
/**
* Reset all dismissed tips (useful for testing)
*/
export function resetDismissedTips(): void {
setDismissedTips(new Set<string>());
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(STORAGE_KEYS.DISMISSED_FEATURE_TIPS);
} catch (err) {
logger.warn('Failed to reset dismissed tips', err);
}
}
}
/**
* Get undismissed feature tips for a location
*/
export function getUndismissedTipsForLocation(location: FeatureTip['location']): FeatureTip[] {
const tips = getFeatureTipsForLocation(location);
const dismissed = dismissedTips();
return tips.filter((tip) => !dismissed.has(tip.id));
}
/**
* Get all undismissed feature tips
*/
export function getAllUndismissedTips(): FeatureTip[] {
const tips = getAllFeatureTips();
const dismissed = dismissedTips();
return tips.filter((tip) => !dismissed.has(tip.id));
}
/**
* Hook for components to use feature tips state
*/
export function useFeatureTips() {
return {
dismissedTips,
isTipDismissed,
dismissTip,
resetDismissedTips,
getUndismissedTipsForLocation,
getAllUndismissedTips,
};
}

View File

@@ -123,4 +123,7 @@ export const STORAGE_KEYS = {
// Resources search
RESOURCES_SEARCH_HISTORY: 'resourcesSearchHistory',
// Feature discovery
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
} as const;