diff --git a/SECURITY.md b/SECURITY.md index 20f352290..1d6d0d8da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 (64‑character 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) diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 54cea423d..1f943bbe2 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -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")) diff --git a/docs/AI.md b/docs/AI.md index 15e464c99..6644ff214 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -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 | - diff --git a/docs/API.md b/docs/API.md index 02aba4f49..c5caf365d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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/` +### 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/` (admin) +- `DELETE /api/notifications/webhooks/` (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) diff --git a/docs/AUTO_UPDATE.md b/docs/AUTO_UPDATE.md index 2ff1a594a..e5fdce7b8 100644 --- a/docs/AUTO_UPDATE.md +++ b/docs/AUTO_UPDATE.md @@ -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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1622a8506..150926f0b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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' ```
@@ -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.
+> **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) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 6c34aa372..b464a1bff 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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.
Advanced: Resource Limits & Healthcheck @@ -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**. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1a360ae3f..d459c2d03 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md index a53ef6fcb..5d591d739 100644 --- a/docs/KUBERNETES.md +++ b/docs/KUBERNETES.md @@ -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 ``` --- diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 84c6b5386..0b76c0c52 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -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. diff --git a/docs/PBS.md b/docs/PBS.md index 654d55c45..480410e8b 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -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://:7655/install.sh | \ + sudo bash -s -- --url http://:7655 --token --enable-proxmox --proxmox-type pbs ``` The agent will: diff --git a/docs/PROXY_AUTH.md b/docs/PROXY_AUTH.md index aaaa74848..6000be6e0 100644 --- a/docs/PROXY_AUTH.md +++ b/docs/PROXY_AUTH.md @@ -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. | - | diff --git a/docs/README.md b/docs/README.md index 4de1c3a2a..c0d336bf4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index d701a1376..5fe552190 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -41,17 +41,16 @@ curl -fsSL http://: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://: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://:7655/install.sh | \ bash -s -- --url http://:7655 --token @@ -95,7 +102,7 @@ curl -fsSL http://:7655/install.sh | \ ### Disable Docker (even if detected) ```bash curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --disable-docker + bash -s -- --url http://:7655 --token --enable-docker=false ``` ### Host + Kubernetes Monitoring @@ -107,7 +114,7 @@ curl -fsSL http://:7655/install.sh | \ ### Docker Monitoring Only ```bash curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --token --disable-host --enable-docker + bash -s -- --url http://:7655 --token --enable-host=false --enable-docker ``` ### Exclude Specific Disks from Monitoring diff --git a/docs/UPGRADE_v5.md b/docs/UPGRADE_v5.md index dcb8c455a..afa3fc850 100644 --- a/docs/UPGRADE_v5.md +++ b/docs/UPGRADE_v5.md @@ -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 diff --git a/docs/monitoring/ADAPTIVE_POLLING.md b/docs/monitoring/ADAPTIVE_POLLING.md index 7923218d0..a5144c3a1 100644 --- a/docs/monitoring/ADAPTIVE_POLLING.md +++ b/docs/monitoring/ADAPTIVE_POLLING.md @@ -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 | diff --git a/docs/monitoring/PROMETHEUS_METRICS.md b/docs/monitoring/PROMETHEUS_METRICS.md index 2733abbb8..36a467c59 100644 --- a/docs/monitoring/PROMETHEUS_METRICS.md +++ b/docs/monitoring/PROMETHEUS_METRICS.md @@ -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 diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index c2ece0387..c03bfcafb 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -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 = { @@ -962,8 +963,9 @@ export function ResourceTable(props: ResourceTableProps) { - - + Alert Delay (s) + diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 7ba3150c8..d6ccc7b3f 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -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()} /> - setForm('openaiBaseUrl', e.currentTarget.value)} - placeholder="Custom base URL (optional, for Azure OpenAI)" - class={controlClass()} - disabled={saving()} - /> +
+ + setForm('openaiBaseUrl', e.currentTarget.value)} + placeholder="https://openrouter.ai/api/v1 (optional)" + class={controlClass()} + disabled={saving()} + /> +

Get API key → @@ -1120,14 +1127,20 @@ export const AISettings: Component = () => {

- setForm('ollamaBaseUrl', e.currentTarget.value)} - placeholder="http://localhost:11434" - class={controlClass()} - disabled={saving()} - /> +
+ + setForm('ollamaBaseUrl', e.currentTarget.value)} + placeholder="http://localhost:11434" + class={controlClass()} + disabled={saving()} + /> +

Learn about Ollama → diff --git a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx index 5bbc50cb2..6221a53ad 100644 --- a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx @@ -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 / Update Preferences + {/* Update Channel */} diff --git a/frontend-modern/src/components/shared/FeatureTip.tsx b/frontend-modern/src/components/shared/FeatureTip.tsx new file mode 100644 index 000000000..2312dabc0 --- /dev/null +++ b/frontend-modern/src/components/shared/FeatureTip.tsx @@ -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 = (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 ( +

+ +
+ {props.tip.description} + + + {props.tip.action!.label} + + +
+ +
+ ); + } + + // Compact variant - single line + if (variant() === 'compact') { + return ( +
+ + {props.tip.title} + + + + + + +
+ ); + } + + // Banner variant - full width, more prominent + return ( +
+
+ +
+
+
{props.tip.title}
+

{props.tip.description}

+ + + {props.tip.action!.label} + + + +
+ +
+ ); +}; + +export default FeatureTip; diff --git a/frontend-modern/src/components/shared/HelpIcon.tsx b/frontend-modern/src/components/shared/HelpIcon.tsx new file mode 100644 index 000000000..4865c1196 --- /dev/null +++ b/frontend-modern/src/components/shared/HelpIcon.tsx @@ -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 = (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 ( + <> + + + + + + + + + ); +}; + +export default HelpIcon; diff --git a/frontend-modern/src/content/features/index.ts b/frontend-modern/src/content/features/index.ts new file mode 100644 index 000000000..0fad7b73b --- /dev/null +++ b/frontend-modern/src/content/features/index.ts @@ -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'; diff --git a/frontend-modern/src/content/features/types.ts b/frontend-modern/src/content/features/types.ts new file mode 100644 index 000000000..ee1139f04 --- /dev/null +++ b/frontend-modern/src/content/features/types.ts @@ -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; +} + diff --git a/frontend-modern/src/content/help/ai.ts b/frontend-modern/src/content/help/ai.ts new file mode 100644 index 000000000..2f23856d8 --- /dev/null +++ b/frontend-modern/src/content/help/ai.ts @@ -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', + }, +]; diff --git a/frontend-modern/src/content/help/alerts.ts b/frontend-modern/src/content/help/alerts.ts new file mode 100644 index 000000000..bdff17ea9 --- /dev/null +++ b/frontend-modern/src/content/help/alerts.ts @@ -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', + }, +]; diff --git a/frontend-modern/src/content/help/index.ts b/frontend-modern/src/content/help/index.ts new file mode 100644 index 000000000..faae30899 --- /dev/null +++ b/frontend-modern/src/content/help/index.ts @@ -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'; diff --git a/frontend-modern/src/content/help/types.ts b/frontend-modern/src/content/help/types.ts new file mode 100644 index 000000000..6ccef350f --- /dev/null +++ b/frontend-modern/src/content/help/types.ts @@ -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; diff --git a/frontend-modern/src/content/help/updates.ts b/frontend-modern/src/content/help/updates.ts new file mode 100644 index 000000000..a399ae6c7 --- /dev/null +++ b/frontend-modern/src/content/help/updates.ts @@ -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', + }, +]; diff --git a/frontend-modern/src/stores/featureTips.ts b/frontend-modern/src/stores/featureTips.ts new file mode 100644 index 000000000..a6d2ef70a --- /dev/null +++ b/frontend-modern/src/stores/featureTips.ts @@ -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 => { + 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>(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()); + + 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, + }; +} diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index fa1be703e..54e3fb9b5 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -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;