Fix settings security tab navigation

This commit is contained in:
rcourtman
2025-10-11 23:29:47 +00:00
commit f46ff1792b
333 changed files with 114894 additions and 0 deletions

48
.dockerignore Normal file
View File

@@ -0,0 +1,48 @@
# Git
.git
.gitignore
# Documentation
*.md
docs/
dev-docs/
# Binaries and build artifacts
pulse
backend
bin/
dist/
*.exe
*.dll
*.so
*.dylib
# Dependencies
node_modules/
vendor/
# Logs
*.log
# Test files
testing-tools/
*_test.go
*.test
# Development files
.env
.env.local
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# CI/CD
.github/
.gitlab-ci.yml
# Temporary files
*.tmp
*.temp
*~

42
.env.example Normal file
View File

@@ -0,0 +1,42 @@
# Pulse Deployment Environment Variables
# Copy this file next to your docker-compose.yml or into /etc/pulse/.env.
# Values defined here override the UI configuration. Keep the file out of
# version control and restrict permissions (chmod 600) because it can contain
# credentials.
# -----------------------------------------------------------------------------
# Authentication (recommended for Docker or headless installs)
# -----------------------------------------------------------------------------
# Uncomment and set these to skip the first-run setup wizard.
# Plain-text values are automatically hashed on startup, or supply a bcrypt hash.
# PULSE_AUTH_USER=admin
# PULSE_AUTH_PASS=super-secret-password
# API_TOKEN=your-48-char-hex-token
# -----------------------------------------------------------------------------
# Optional security toggles
# -----------------------------------------------------------------------------
# DISABLE_AUTH=false
# PULSE_AUDIT_LOG=true
# -----------------------------------------------------------------------------
# Networking
# -----------------------------------------------------------------------------
# PULSE_PUBLIC_URL=https://pulse.example.com
# ALLOWED_ORIGINS=https://pulse.example.com
# FRONTEND_PORT=7655
# BACKEND_HOST=0.0.0.0
# BACKEND_PORT=7655
# -----------------------------------------------------------------------------
# Monitoring and logging
# -----------------------------------------------------------------------------
# DISCOVERY_SUBNET=192.168.50.0/24
# CONNECTION_TIMEOUT=10
# LOG_LEVEL=info
# METRICS_RETENTION_DAYS=7
# -----------------------------------------------------------------------------
# Miscellaneous
# -----------------------------------------------------------------------------
# TZ=UTC

30
.gitguardian.yaml Normal file
View File

@@ -0,0 +1,30 @@
version: 2
# GitGuardian Configuration
# Prevents false positives while maintaining security scanning
# Ignore documentation and example files where placeholder tokens are expected
paths-ignore:
- "**/*.md" # Documentation files with examples
- "**/docs/**" # Documentation directory
- "**/examples/**" # Example code
- "**/*.example" # Example configuration files
- "**/*.sample" # Sample files
# Ignore specific patterns that are known false positives
matches-ignore:
- name: Disabled token placeholder
match: "--token disabled"
- name: Token environment variable placeholder
match: "PULSE_TOKEN_PLACEHOLDER"
- name: URL placeholder
match: "PULSE_URL_PLACEHOLDER"
- name: Generic documentation placeholders
match: "your-api-token|replace-me|<token>|<your-api-token>"
# Keep scanning enabled for actual code and config
# GitGuardian will still catch real secrets in:
# - Source code (.go, .ts, .tsx, .js, etc.)
# - Configuration files (.env, config.json, etc.)
# - Scripts (.sh that don't match ignored patterns)
# - Any file not explicitly ignored above

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,29 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. See error
**Expected behavior**
What you expected to happen.
**Environment:**
- Pulse Version: [e.g. v4.15.0]
- Installation Type: [e.g. ProxmoxVE LXC, Docker, Manual]
**Additional context**
Add any other context, screenshots, or logs here.
💡 **Tip:** If you're experiencing connection issues, API errors, or missing data, you can attach diagnostics from Settings → Diagnostics tab → Export for GitHub (sanitized version)

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Community Support
url: https://github.com/rcourtman/Pulse/discussions
about: Please ask and answer questions here
- name: Documentation
url: https://github.com/rcourtman/Pulse/wiki
about: Check the wiki for guides and documentation

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

51
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,51 @@
# GitHub Actions Workflows
## Update Demo Server
**File**: `update-demo-server.yml`
Automatically updates the public demo server (`pulse-relay`) when a new stable release is published.
### Configuration Required
Add these secrets to your GitHub repository settings (`Settings``Secrets and variables``Actions`):
1. **DEMO_SERVER_SSH_KEY**
- The private SSH key for accessing the demo server
- Generate with: `cat ~/.ssh/id_ed25519` (or your key file)
- Should be the full private key including `-----BEGIN` and `-----END` lines
2. **DEMO_SERVER_HOST**
- The hostname or IP of the demo server
- Value: `174.138.72.137` (or hostname if using DNS)
3. **DEMO_SERVER_USER**
- The SSH username for the demo server
- Value: `root` (or the appropriate user with sudo access)
### How It Works
1. **Trigger**: Runs automatically when a GitHub release is published
2. **Filter**: Only runs for stable releases (skips RC/pre-releases)
3. **Update**: SSHs to demo server and runs the install script
4. **Verify**: Checks that the new version is running and mock mode is active
5. **Cleanup**: Removes SSH key from runner
### Testing
To test without publishing a release:
1. Go to `Actions` tab in GitHub
2. Select `Update Demo Server` workflow
3. Click `Run workflow` (if manual trigger is enabled)
Or test manually:
```bash
ssh pulse-relay "curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash"
```
### Benefits
- ✅ Demo server always showcases latest stable release
- ✅ Validates install script works on real server
- ✅ Removes manual step from release process
- ✅ Free to run (public repos get unlimited GitHub Actions minutes)

View File

@@ -0,0 +1,59 @@
name: Build and Publish Docker Agent Image
on:
push:
branches:
- main
paths:
- 'Dockerfile'
- 'cmd/pulse-docker-agent/**'
- 'internal/dockeragent/**'
- 'go.mod'
- 'go.sum'
- '.github/workflows/docker-agent-image.yml'
release:
types: [published]
workflow_dispatch:
jobs:
build-agent-image:
name: Build & Push agent image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read version
id: version
run: |
VERSION=$(tr -d '\n' < VERSION)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build and push agent image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: agent_runtime
platforms: linux/amd64,linux/arm64
push: true
provenance: false
tags: |
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:latest
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:${{ steps.version.outputs.version }}

View File

@@ -0,0 +1,58 @@
name: Update Demo Server
on:
release:
types: [published]
jobs:
update-demo:
# Only run for stable releases (not pre-releases)
if: github.event.release.prerelease == false
runs-on: ubuntu-latest
steps:
- name: Check release type
run: |
echo "Release: ${{ github.event.release.tag_name }}"
echo "Prerelease: ${{ github.event.release.prerelease }}"
echo "Updating demo server to latest stable release..."
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEMO_SERVER_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.DEMO_SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Update demo server
run: |
ssh -i ~/.ssh/id_ed25519 ${{ secrets.DEMO_SERVER_USER }}@${{ secrets.DEMO_SERVER_HOST }} \
'curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash'
- name: Verify update
run: |
# Wait a moment for service to restart
sleep 5
# Check version endpoint
VERSION=$(ssh -i ~/.ssh/id_ed25519 ${{ secrets.DEMO_SERVER_USER }}@${{ secrets.DEMO_SERVER_HOST }} \
"curl -s http://localhost:7655/api/version | grep -o '\"version\":\"[^\"]*' | cut -d'\"' -f4")
echo "Demo server is now running version: $VERSION"
# Verify mock mode is active
NODES=$(ssh -i ~/.ssh/id_ed25519 ${{ secrets.DEMO_SERVER_USER }}@${{ secrets.DEMO_SERVER_HOST }} \
"curl -s http://localhost:7655/api/state | grep -o '\"nodes\":\[[^]]*\]' | grep -o 'pve[0-9]' | wc -l")
echo "Mock nodes detected: $NODES"
if [ "$NODES" -ge 1 ]; then
echo "✅ Demo server successfully updated and verified!"
else
echo "⚠️ Demo server updated but mock mode may not be active"
exit 1
fi
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/id_ed25519

146
.gitignore vendored Normal file
View File

@@ -0,0 +1,146 @@
# Binaries
/bin/
pulse
# Logs
*.log
# OS files
.DS_Store
Thumbs.db
# Development environment files
.dev-mode
.tmux.conf
.dev-aliases
# IDE
.idea/
.vscode/
*.swp
*.swo
# Go
*.exe
*.dll
*.so
*.dylib
*.test
*.out
vendor/
pulse-test
test-pulse
# Node.js (for frontend-modern)
node_modules/
.npm/
.yarn/
frontend-modern/.vite/
# Environment
.env
.env.local
.env.*.local
# Build outputs
dist/
build/
*.tar.gz
pulse-fixes*.tar.gz
# Frontend copy for embedding (generated during build)
# Frontend build artifact for Go embedding
# This is auto-generated, DO NOT EDIT
internal/api/frontend-modern/
# AI assistant files (local development environment docs)
CLAUDE.md
.claude*
claude-*
fix-claude-*
backend
AGENTS.md
AI_DEVELOPMENT.md
.ai-coordination/
scripts/pulse-watchdog.sh
pulse-watchdog.log
# Release process files
CHANGELOG.md
pulse-release/
pulse-release-staging/
release/
# Development scripts
scripts/backend-watch.sh
temp/
RELEASE_CHECKLIST.md
DOCKER_PUSH_INSTRUCTIONS.md
# Testing and temporary files
testing-tools/
manual-test*.md
verify-*.md
test-*.md
package.json
package-lock.json
*.test.js
*.test.md
screenshots/
.devdata/
test-*.js
test-*.sh
test-*.html
*.backup.*
.env.dev
.env.backup*
PMG_BACKUP_DETECTION.md
SAFE_TESTING.md
tmp/
# Master plan documents (local only)
PULSE_V4_ISSUES_MASTER_PLAN.md
FIX_SUMMARY_*.md
# Development documentation
TYPING_*.md
test-config.json
# Local test scripts
scripts/test-*.sh
!scripts/test-vm-disk.sh
scripts/run-tests.sh
scripts/TEST_*.md
# Mock mode - exclude local overrides but keep the base file
mock.env.local
mock.env.backup
# Legacy mock mode files (no longer used)
internal/monitoring/mock_integration.go
internal/monitoring/mock_stub.go
scripts/mock-dev.sh
scripts/toggle-mock-pure.sh
MOCK_MODE_GUIDE.md
# Claude Code Safety Hooks (local only)
.claudecode-settings.json
.claudecode-hooks/
# Sensitive files - DO NOT COMMIT
secrets.env
*secret*.env
# Development documentation (local only)
CLAUDE_DEV_SETUP.md
AGENT_METRICS_*.md
# Temporary scripts
tmp_*.py
tmp_*.sh
# Experimental/abandoned features (not part of main project)
cloud-relay/
scripts/agent/
docs/internal/
claude.md

20
.golangci.yml Normal file
View File

@@ -0,0 +1,20 @@
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- govet
- gofmt
- goimports
- errcheck
issues:
max-same-issues: 0
max-issues-per-linter: 0
linters-settings:
gofmt:
simplify: true
errcheck:
exclude-functions:
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write

91
DEV-QUICK-START.md Normal file
View File

@@ -0,0 +1,91 @@
# Development Quick Start
## Hot-Reload Development Mode
Start the development environment with hot-reload:
```bash
./scripts/hot-dev.sh
```
This starts:
- Backend API on port 7656
- Frontend on port 7655 with hot-reload
- Both backend and frontend automatically reload on code changes
Access the app at: http://localhost:7655 or http://192.168.0.123:7655
## Toggle Between Mock and Production Data
Switch modes seamlessly without manually restarting services:
```bash
# Enable mock mode (test with fake data)
npm run mock:on
# Disable mock mode (use real Proxmox nodes)
npm run mock:off
# Check current mode
npm run mock:status
# Edit mock configuration
npm run mock:edit
```
Or use the script directly:
```bash
./scripts/toggle-mock.sh on # Enable mock mode
./scripts/toggle-mock.sh off # Disable mock mode (use production data)
./scripts/toggle-mock.sh status # Show current status
```
The toggle script automatically:
- Updates `mock.env` configuration
- Restarts the backend with new settings
- Keeps the frontend running (no restart needed)
- Syncs production config when switching to production mode
- Switches `PULSE_DATA_DIR` between `/opt/pulse/tmp/mock-data` (mock) and `/etc/pulse` (production) so test data never touches real credentials
## Mock Mode Configuration
Edit `mock.env` to customize mock data:
```bash
PULSE_MOCK_MODE=false # Enable/disable mock mode
PULSE_MOCK_NODES=7 # Number of mock nodes
PULSE_MOCK_VMS_PER_NODE=5 # Average VMs per node
PULSE_MOCK_LXCS_PER_NODE=8 # Average containers per node
PULSE_MOCK_RANDOM_METRICS=true # Enable metric fluctuations
PULSE_MOCK_STOPPED_PERCENT=20 # Percentage of stopped guests
```
Prefer `mock.env.local` for personal tweaks (`cp mock.env mock.env.local`). The toggle script honours `.local` first, keeping the shared defaults untouched.
## Development Workflow
1. Start hot-dev: `./scripts/hot-dev.sh`
2. Switch to mock mode for testing: `npm run mock:on`
3. Develop and test your changes
4. Switch to production mode to verify: `npm run mock:off`
5. Code changes auto-reload, no manual restarts needed!
## Troubleshooting
If the backend doesn't pick up changes:
```bash
npm run mock:off # Force restart with production data
npm run mock:on # Force restart with mock data
```
Check backend logs:
```bash
tail -f /tmp/pulse-backend.log
```
Check if services are running:
```bash
lsof -i :7656 # Backend
lsof -i :7655 # Frontend
```

106
Dockerfile Normal file
View File

@@ -0,0 +1,106 @@
# Build stage for frontend (must be built first for embedding)
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend-modern
# Copy package files
COPY frontend-modern/package*.json ./
RUN npm ci
# Copy frontend source
COPY frontend-modern/ ./
# Build frontend
RUN npm run build
# Build stage for Go backend
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy go mod files for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy only necessary source code
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY pkg/ ./pkg/
COPY VERSION ./
# Copy built frontend from frontend-builder stage for embedding
# Must be at internal/api/frontend-modern for Go embed
COPY --from=frontend-builder /app/frontend-modern/dist ./internal/api/frontend-modern/dist
# Build the binaries with embedded frontend
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-trimpath \
-o pulse ./cmd/pulse && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-trimpath \
-o pulse-docker-agent ./cmd/pulse-docker-agent
# Runtime image for the Docker agent (offered via --target agent_runtime)
FROM alpine:latest AS agent_runtime
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=backend-builder /app/pulse-docker-agent /usr/local/bin/pulse-docker-agent
COPY --from=backend-builder /app/VERSION /VERSION
ENTRYPOINT ["/usr/local/bin/pulse-docker-agent"]
# Final stage (Pulse server runtime)
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata su-exec
WORKDIR /app
# Copy binaries from builder (frontend is embedded)
COPY --from=backend-builder /app/pulse .
COPY --from=backend-builder /app/pulse-docker-agent .
# Copy VERSION file
COPY --from=backend-builder /app/VERSION .
# Copy entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Provide docker-agent installer script for HTTP download endpoint
RUN mkdir -p /opt/pulse/scripts
COPY scripts/install-docker-agent.sh /opt/pulse/scripts/install-docker-agent.sh
RUN chmod 755 /opt/pulse/scripts/install-docker-agent.sh
# Create config directory
RUN mkdir -p /etc/pulse /data
# Expose port
EXPOSE 7655
# Set environment variables
# Only PULSE_DATA_DIR is used - all node config is done via web UI
ENV PULSE_DATA_DIR=/data
ENV PULSE_DOCKER=true
# Create default user (will be adjusted by entrypoint if PUID/PGID are set)
RUN adduser -D -u 1000 -g 1000 pulse && \
chown -R pulse:pulse /app /etc/pulse /data /opt/pulse
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:7655 || exit 1
# Use entrypoint script to handle UID/GID
ENTRYPOINT ["/docker-entrypoint.sh"]
# Run the binary
CMD ["./pulse"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 rcourtman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

63
Makefile Normal file
View File

@@ -0,0 +1,63 @@
# Pulse Makefile for development
.PHONY: build run dev frontend backend all clean dev-hot lint lint-backend lint-frontend format format-backend format-frontend
# Build everything
all: frontend backend
# Build frontend only
frontend:
cd frontend-modern && npm run build
@echo "================================================"
@echo "Copying frontend to internal/api/ for Go embed"
@echo "This is REQUIRED - Go cannot embed external paths"
@echo "================================================"
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
@echo "✓ Frontend copied for embedding"
# Build backend only (includes embedded frontend)
backend:
go build -o pulse ./cmd/pulse
# Build both and run
build: frontend backend
# Run the built binary
run: build
./pulse
# Development - rebuild everything and restart service
dev: frontend backend
sudo systemctl restart pulse-backend
dev-hot:
./scripts/dev-hot.sh
# Clean build artifacts
clean:
rm -f pulse
rm -rf frontend-modern/dist
# Quick rebuild and restart for development
restart: frontend backend
sudo systemctl restart pulse-backend
# Run linters for both backend and frontend
lint: lint-backend lint-frontend
lint-backend:
golangci-lint run ./...
lint-frontend:
cd frontend-modern && npm run lint
# Apply formatters
format: format-backend format-frontend
format-backend:
gofmt -w cmd internal pkg
format-frontend:
cd frontend-modern && npm run format

614
README.md Normal file
View File

@@ -0,0 +1,614 @@
# Pulse
[![GitHub release](https://img.shields.io/github/v/release/rcourtman/Pulse)](https://github.com/rcourtman/Pulse/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/rcourtman/pulse)](https://hub.docker.com/r/rcourtman/pulse)
[![License](https://img.shields.io/github/license/rcourtman/Pulse)](LICENSE)
**Real-time monitoring for Proxmox VE, Proxmox Mail Gateway, PBS, and Docker infrastructure with alerts and webhooks.**
Monitor your hybrid Proxmox and Docker estate from a single dashboard. Get instant alerts when nodes go down, containers misbehave, backups fail, or storage fills up. Supports email, Discord, Slack, Telegram, and more.
**[Try the live demo →](https://demo.pulserelay.pro)** (read-only with mock data)
<img width="2872" height="1502" alt="image" src="https://github.com/user-attachments/assets/41ac125c-59e3-4bdc-bfd2-e300109aa1f7" />
## Support Pulse Development
Pulse is built by a solo developer in evenings and weekends. Your support helps:
- Keep me motivated to add new features
- Prioritize bug fixes and user requests
- Ensure Pulse stays 100% free and open-source forever
[![GitHub Sponsors](https://img.shields.io/github/sponsors/rcourtman?style=social&label=Sponsor)](https://github.com/sponsors/rcourtman)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/rcourtman)
**Not ready to sponsor?** Star the project or share it with your homelab community!
## Features
- **Auto-Discovery**: Finds Proxmox nodes on your network, one-liner setup via generated scripts
- **Cluster Support**: Configure one node, monitor entire cluster
- **Enterprise Security**:
- Credentials encrypted at rest, masked in logs, never sent to frontend
- CSRF protection for all state-changing operations
- Rate limiting (500 req/min general, 10 attempts/min for auth)
- Account lockout after failed login attempts
- Secure session management with HttpOnly cookies
- bcrypt password hashing (cost 12) - passwords NEVER stored in plain text
- API tokens stored securely with restricted file permissions
- Security headers (CSP, X-Frame-Options, etc.)
- Comprehensive audit logging
- Live monitoring of VMs, containers, nodes, storage
- **Smart Alerts**: Email and webhooks (Discord, Slack, Telegram, Teams, ntfy.sh, Gotify)
- Example: "VM 'webserver' is down on node 'pve1'"
- Example: "Storage 'local-lvm' at 85% capacity"
- Example: "VM 'database' is back online"
- **Adaptive Thresholds**: Hysteresis-based trigger/clear levels, fractional network thresholds, per-metric search, reset-to-defaults, and Custom overrides with inline audit trail
- **Alert Timeline Analytics**: Rich history explorer with acknowledgement/clear markers, escalation breadcrumbs, and quick filters for noisy resources
- **Ceph Awareness**: Surface Ceph health, pool utilisation, and daemon status automatically when Proxmox exposes Ceph-backed storage
- Unified view of PBS backups, PVE backups, and snapshots
- **Interactive Backup Explorer**: Cross-highlighted bar chart + grid with quick time-range pivots (24h/7d/30d/custom) and contextual tooltips for the busiest jobs
- Proxmox Mail Gateway analytics: mail volume, spam/virus trends, quarantine health, and cluster node status
- Optional Docker container monitoring via lightweight agent
- Config export/import with encryption and authentication
- Automatic stable updates with safe rollback (opt-in)
- Dark/light themes, responsive design
- Built with Go for minimal resource usage
[Screenshots →](docs/SCREENSHOTS.md)
## Privacy
**Pulse respects your privacy:**
- No telemetry or analytics collection
- No phone-home functionality
- No external API calls (except for configured webhooks)
- All data stays on your server
- Open source - verify it yourself
Your infrastructure data is yours alone.
## Quick Start
### Install
```bash
# Recommended: Official installer (auto-detects Proxmox and creates container)
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
# Need to roll back to a previous release? Pass the tag you want
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version v4.20.0
# Alternative: Docker
docker run -d -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
# Testing: Install from main branch source (for testing latest fixes)
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --main
```
**Proxmox users**: The installer detects PVE hosts and automatically creates an optimized LXC container. Choose Quick mode for one-minute setup.
[Advanced installation options →](docs/INSTALL.md)
### Updating
**Automatic Updates (New!):** Enable during installation or via Settings UI to stay current automatically
**Standard Install:** Re-run the installer
**Docker:** `docker pull rcourtman/pulse:latest` then recreate container
### Initial Setup
**Option A: Interactive Setup (UI)**
1. Open `http://<your-server>:7655`
2. **Complete the mandatory security setup** (first-time only)
3. Create your admin username and password
4. Save the generated API token for automation
**Option B: Automated Setup (No UI)**
For automated deployments, configure authentication via environment variables:
```bash
# Start Pulse with auth pre-configured - skips setup screen
API_TOKEN=your-api-token ./pulse
# Or use basic auth
PULSE_AUTH_USER=admin PULSE_AUTH_PASS=password ./pulse
# Plain text credentials are automatically hashed for security
# You can also provide pre-hashed values if preferred
```
See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for details.
### Configure Nodes
**Two authentication methods available:**
#### Method 1: Manual Setup (Recommended for interactive use)
1. After login, go to Settings → Nodes
2. Discovered nodes appear automatically
3. Click "Setup Script" next to any node
4. Click "Generate Setup Code" button (creates a 6-character code valid for 5 minutes)
5. Copy and run the provided one-liner on your Proxmox/PBS host
6. Node is configured and monitoring starts automatically
**Example:**
```bash
curl -sSL "http://pulse:7655/api/setup-script?type=pve&host=https://pve:8006&auth_token=ABC123" | bash
```
#### Method 2: Automated Setup (For scripts/automation)
Use your permanent API token directly in the URL for automation:
```bash
# For Proxmox VE
curl -sSL "http://pulse:7655/api/setup-script?type=pve&host=https://pve:8006&auth_token=YOUR_API_TOKEN" | bash
# For Proxmox Backup Server
curl -sSL "http://pulse:7655/api/setup-script?type=pbs&host=https://pbs:8007&auth_token=YOUR_API_TOKEN" | bash
```
**Parameters:**
- `type`: `pve` for Proxmox VE, `pbs` for Proxmox Backup Server
- `host`: Full URL of your Proxmox/PBS server (e.g., https://192.168.1.100:8006)
- `auth_token`: Either a 6-character setup code (expires in 5 min) or your permanent API token
- `backup_perms=true` (optional): Add backup management permissions
- `pulse_url` (optional): Pulse server URL if different from where script is downloaded
The script handles user creation, permissions, token generation, and registration automatically.
### Monitor Docker Containers (optional)
Deploy the lightweight [Pulse Docker agent](docs/DOCKER_MONITORING.md) on any host running Docker to stream container status and resource data back to Pulse. Install the agent alongside your stack, point it at your Pulse URL and API token, and the **Docker** workspace lights up with host summaries, restart loop detection, per-container CPU/memory charts, and quick filters for stacks and unhealthy workloads.
## Docker
### Basic
```bash
docker run -d \
--name pulse \
-p 7655:7655 \
-v pulse_data:/data \
--restart unless-stopped \
rcourtman/pulse:latest
```
### Network Discovery
Pulse automatically discovers Proxmox nodes on your network! By default, it scans:
- 192.168.0.0/16 (home networks)
- 10.0.0.0/8 (private networks)
- 172.16.0.0/12 (Docker/internal networks)
To scan a custom subnet instead:
```bash
docker run -d \
--name pulse \
-p 7655:7655 \
-v pulse_data:/data \
-e DISCOVERY_SUBNET="192.168.50.0/24" \
--restart unless-stopped \
rcourtman/pulse:latest
```
### Automated Deployment
```bash
# Deploy with authentication pre-configured
docker run -d \
--name pulse \
-p 7655:7655 \
-v pulse_data:/data \
-e API_TOKEN="your-secure-token" \
-e PULSE_AUTH_USER="admin" \
-e PULSE_AUTH_PASS="your-password" \
--restart unless-stopped \
rcourtman/pulse:latest
# Plain text credentials are automatically hashed for security
# No setup required - API works immediately
```
### Docker Compose
```yaml
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse
ports:
- "7655:7655"
volumes:
- pulse_data:/data
environment:
# NOTE: Env vars override UI settings. Remove env var to allow UI configuration.
# Network discovery (usually not needed - auto-scans common networks)
# - DISCOVERY_SUBNET=192.168.50.0/24 # Only for non-standard networks
# Ports
# - PORT=7655 # Backend port (default: 7655)
# - FRONTEND_PORT=7655 # Frontend port (default: 7655)
# Security (all optional - runs open by default)
# - PULSE_AUTH_USER=admin # Username for web UI login
# - PULSE_AUTH_PASS=your-password # Plain text or bcrypt hash (auto-hashed if plain)
# - API_TOKEN=your-token # Plain text or SHA3-256 hash (auto-hashed if plain)
# - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false)
# Security: Plain text credentials are automatically hashed
# You can provide either:
# 1. Plain text (auto-hashed): PULSE_AUTH_PASS=mypassword
# 2. Pre-hashed (advanced): PULSE_AUTH_PASS='$$2a$$12$$...'
# Note: Escape $ as $$ in docker-compose.yml for pre-hashed values
# Performance
# - CONNECTION_TIMEOUT=10 # Connection timeout in seconds (default: 10)
# CORS & logging
# - ALLOWED_ORIGINS=https://app.example.com # CORS origins (default: none, same-origin only)
# - LOG_LEVEL=info # Log level: debug/info/warn/error (default: info)
restart: unless-stopped
volumes:
pulse_data:
```
## Security
- **Authentication required** - Protects your Proxmox infrastructure credentials
- **Quick setup wizard** - Secure your installation in under a minute
- **Multiple auth methods**: Password authentication, API tokens, proxy auth (SSO), or combinations
- **Proxy/SSO support** - Integrate with Authentik, Authelia, and other authentication proxies ([docs](docs/PROXY_AUTH.md))
- **Enterprise-grade protection**:
- Credentials encrypted at rest (AES-256-GCM)
- CSRF tokens for state-changing operations
- Rate limiting and account lockout protection
- Secure session management with HttpOnly cookies
- bcrypt password hashing (cost 12) - passwords NEVER stored in plain text
- API tokens stored securely with restricted file permissions
- Security headers (CSP, X-Frame-Options, etc.)
- Comprehensive audit logging
- **Security by design**:
- Frontend never receives node credentials
- API tokens visible only to authenticated users
- Export/import requires authentication when configured
See [Security Documentation](docs/SECURITY.md) for details.
## Updating
### Update Notifications
Pulse checks for updates and displays notifications in the UI when new versions are available. For security reasons, updates must be installed manually using the appropriate method for your deployment.
### Manual Installation (systemd)
```bash
# Update to latest stable
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
# Update to latest RC/pre-release
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --rc
# Install specific version
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version v4.8.0-rc.1
```
### Docker Updates
```bash
# Latest stable
docker pull rcourtman/pulse:latest
# Latest RC
docker pull rcourtman/pulse:rc
# Specific version
docker pull rcourtman/pulse:v4.8.0-rc.1
```
## Configuration
Quick start - most settings are in the web UI:
- **Settings → Nodes**: Add/remove Proxmox instances
- **Settings → System**: Polling intervals, timeouts, update settings
- **Settings → Security**: Authentication and API tokens
- **Alerts**: Thresholds and notifications
### Configuration Files
Pulse uses three separate configuration files with clear separation of concerns:
- `.env` - Authentication credentials only
- `system.json` - Application settings
- `nodes.enc` - Encrypted node credentials
See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for detailed documentation on configuration structure and management.
### Email Alerts Configuration
Configure email notifications in **Settings → Alerts → Email Destinations**
#### Supported Providers
- **Gmail/Google Workspace**: Requires app-specific password
- **Outlook/Office 365**: Requires app-specific password
- **Custom SMTP**: Any SMTP server
#### Recommended Settings
- **Port 587 with STARTTLS** (recommended for most providers)
- **Port 465** for SSL/TLS
- **Port 25** for unencrypted (not recommended)
#### Gmail Setup
1. Enable 2-factor authentication
2. Generate app-specific password at https://myaccount.google.com/apppasswords
3. Use your email as username and app password as password
4. Server: smtp.gmail.com, Port: 587, Enable STARTTLS
#### Outlook Setup
1. Generate app password at https://account.microsoft.com/security
2. Use your email as username and app password as password
3. Server: smtp-mail.outlook.com, Port: 587, Enable STARTTLS
### Alert Configuration
Pulse provides two complementary approaches for managing alerts:
#### Custom Alert Rules (Permanent Policy)
Configure persistent alert policies in **Settings → Alerts → Custom Rules**:
- Define thresholds for specific VMs/containers based on name patterns
- Set different thresholds for production vs development environments
- Create complex rules with AND/OR logic
- Manage all rules through the UI with priority ordering
**Use for:** Long-term alert policies like "all database VMs should alert at 90%"
### HTTPS/TLS Configuration
Enable HTTPS by setting these environment variables:
```bash
# Systemd: sudo systemctl edit pulse-backend
Environment="HTTPS_ENABLED=true"
Environment="TLS_CERT_FILE=/etc/pulse/cert.pem"
Environment="TLS_KEY_FILE=/etc/pulse/key.pem"
# Docker
docker run -d -p 7655:7655 \
-e HTTPS_ENABLED=true \
-e TLS_CERT_FILE=/data/cert.pem \
-e TLS_KEY_FILE=/data/key.pem \
-v pulse_data:/data \
-v /path/to/certs:/data/certs:ro \
rcourtman/pulse:latest
```
For deployment overrides (ports, etc), use environment variables:
```bash
# Systemd: sudo systemctl edit pulse-backend
Environment="FRONTEND_PORT=8080"
# Docker: -e FRONTEND_PORT=8080
```
**[Full Configuration Guide →](docs/CONFIGURATION.md)**
### Backup/Restore
**Via UI (recommended):**
- Settings → Security → Backup & Restore
- Export: Choose login password or custom passphrase for encryption
- Import: Upload backup file with passphrase
- Includes all settings, nodes, and custom console URLs
**Via CLI:**
```bash
# Export (v4.0.3+)
pulse config export -o backup.enc
# Import
pulse config import -i backup.enc
```
## Updates
Pulse shows when updates are available and provides deployment-specific instructions:
### Docker
```bash
docker pull rcourtman/pulse:latest
docker stop pulse
docker rm pulse
# Run docker run command again with your settings
```
### Manual Install
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
The UI will detect your deployment type and show the appropriate update method when a new version is available.
## API
```bash
# Status
curl http://localhost:7655/api/health
# Metrics (default time range: 1h)
curl http://localhost:7655/api/charts
# With authentication (if configured)
curl -H "X-API-Token: your-token" http://localhost:7655/api/health
```
**[Full API Documentation →](docs/API.md)** - Complete endpoint reference with examples
## Reverse Proxy & SSO
Using Pulse behind a reverse proxy? **WebSocket support is required for real-time updates.**
**NEW: Proxy Authentication Support** - Integrate with Authentik, Authelia, and other SSO providers. See [Proxy Authentication Guide](docs/PROXY_AUTH.md).
See [Reverse Proxy Configuration Guide](docs/REVERSE_PROXY.md) for nginx, Caddy, Apache, Traefik, HAProxy, and Cloudflare Tunnel configurations.
## Troubleshooting
### Authentication Issues
#### Cannot login after setting up security
- **Docker**: Ensure bcrypt hash is exactly 60 characters and wrapped in single quotes
- **Docker Compose**: MUST escape $ characters as $$ (e.g., `$$2a$$12$$...`)
- **Example (docker run)**: `PULSE_AUTH_PASS='$2a$12$YTZXOCEylj4TaevZ0DCeI.notayQZ..b0OZ97lUZ.Q24fljLiMQHK'`
- **Example (docker-compose.yml)**: `PULSE_AUTH_PASS='$$2a$$12$$YTZXOCEylj4TaevZ0DCeI.notayQZ..b0OZ97lUZ.Q24fljLiMQHK'`
- If hash is truncated or mangled, authentication will fail
- Use Quick Security Setup in the UI to avoid manual configuration errors
#### .env file not created (Docker)
- **Expected behavior**: When using environment variables, no .env file is created in /data
- The .env file is only created when using Quick Security Setup or password changes
- If you provide credentials via environment variables, they take precedence
- To use Quick Security Setup: Start container WITHOUT auth environment variables
### VM Disk Stats Show "-"
- VMs require QEMU Guest Agent to report disk usage (Proxmox API returns 0 for VMs)
- Install guest agent in VM: `apt install qemu-guest-agent` (Linux) or virtio-win tools (Windows)
- Enable in VM Options → QEMU Guest Agent, then restart VM
- See [VM Disk Monitoring Guide](docs/VM_DISK_MONITORING.md) for setup
- Container (LXC) disk stats always work (no guest agent needed)
### Connection Issues
- Check Proxmox API is accessible (port 8006/8007)
- Verify credentials have PVEAuditor role minimum
- For PBS: ensure API token has Datastore.Audit permission
### High CPU/Memory
- Reduce polling interval in Settings
- Check number of monitored nodes
- Disable unused features (backups, snapshots)
### Logs
```bash
# Docker
docker logs pulse
# Manual
journalctl -u pulse -f
```
## Documentation
- [Docker Guide](docs/DOCKER.md) - Complete Docker deployment guide
- [Configuration Guide](docs/CONFIGURATION.md) - Complete setup and configuration
- [VM Disk Monitoring](docs/VM_DISK_MONITORING.md) - Set up QEMU Guest Agent for accurate VM disk usage
- [Port Configuration](docs/PORT_CONFIGURATION.md) - How to change the default port
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- [API Reference](docs/API.md) - REST API endpoints and examples
- [Webhook Guide](docs/WEBHOOKS.md) - Setting up webhooks and custom payloads
- [Proxy Authentication](docs/PROXY_AUTH.md) - SSO integration with Authentik, Authelia, etc.
- [Reverse Proxy Setup](docs/REVERSE_PROXY.md) - nginx, Caddy, Apache, Traefik configs
- [Security](docs/SECURITY.md) - Security features and best practices
- [FAQ](docs/FAQ.md) - Common questions and troubleshooting
- [Migration Guide](docs/MIGRATION.md) - Backup and migration procedures
## Security
- **Mandatory authentication** protects your infrastructure
- Credentials stored encrypted (AES-256-GCM)
- API token support for automation
- Export/import requires authentication
- **Setup script authentication**:
- **Setup codes**: Temporary 6-character codes for manual setup (expire in 5 minutes)
- **API tokens**: Permanent tokens for automation and scripting
- Use setup codes when giving access to others without sharing your API token
- Use API tokens for your own automation or trusted environments
- [Security Details →](docs/SECURITY.md)
## Development
### Quick Start - Hot Reload (Recommended)
```bash
# Launch Vite + Go with automatic frontend proxying
make dev-hot
# Frontend HMR: http://127.0.0.1:5173
# Backend API: http://127.0.0.1:7655 (served via the Go app)
# Ports come from FRONTEND_PORT/PULSE_DEV_API_PORT (loaded from .env*. Override there if you need a different port.)
```
The backend now detects `FRONTEND_DEV_SERVER` and proxies requests straight to the Vite dev server. Edit files under `frontend-modern/src/` and the browser refreshes instantly—no manual rebuilds or service restarts required. Use `CTRL+C` to stop both processes.
### Mock Mode - Develop Without Real Infrastructure
Work on Pulse without needing Proxmox servers! Mock mode generates realistic test data and auto-reloads when toggled. The `mock.env` configuration file is **included in the repository**, so it works out of the box for all developers.
```bash
# Enable mock mode with 7 nodes, ~90 guests
npm run mock:on
# Disable mock mode (use real infrastructure)
npm run mock:off
# Edit mock configuration
npm run mock:edit
# Create local overrides (not committed to git)
cp mock.env mock.env.local
# Edit mock.env.local with your personal preferences
# Data directories are isolated automatically:
# - Mock mode: /opt/pulse/tmp/mock-data
# - Production: /etc/pulse
```
**Backend auto-reloads when mock.env changes - no manual restarts!** The toggle scripts keep mock data isolated from `/etc/pulse` so your real credentials stay untouched.
See [docs/development/MOCK_MODE.md](docs/development/MOCK_MODE.md) for full details.
### Production-like Development
```bash
# Watches files and rebuilds/embeds frontend into Go binary
./dev.sh
# Access at: http://localhost:7655
```
### Manual Development
```bash
# Frontend only
cd frontend-modern
npm install
npm run dev
# Backend only
go build -o pulse ./cmd/pulse
./pulse
# Or use make for full rebuild
make dev
```
## Visual Tour
See Pulse in action with our [complete screenshot gallery →](docs/SCREENSHOTS.md)
### Core Features
| Dashboard | Storage | Backups |
|-----------|---------|---------|
| ![Dashboard](docs/images/01-dashboard.png) | ![Storage](docs/images/02-storage.png) | ![Backups](docs/images/03-backups.png) |
| *Real-time monitoring of nodes, VMs & containers* | *Storage pool usage across all nodes* | *Unified backup management & PBS integration* |
### Alerts & Configuration
| Alert Configuration | Alert History | Settings |
|---------------------|---------------|----------|
| ![Alerts](docs/images/04-alerts.png) | ![Alert History](docs/images/05-alert-history.png) | ![Settings](docs/images/06-settings.png) |
| *Configure thresholds & notifications* | *Track patterns with visual timeline* | *Manage nodes & authentication* |
### Mobile Experience
| Mobile Dashboard |
|------------------|
| ![Mobile](docs/images/08-mobile.png) |
| *Fully responsive interface for monitoring on the go* |
## Links
- [Releases](https://github.com/rcourtman/Pulse/releases)
- [Docker Hub](https://hub.docker.com/r/rcourtman/pulse)
- [Issues](https://github.com/rcourtman/Pulse/issues)
## License
MIT - See [LICENSE](LICENSE)

1
VERSION Normal file
View File

@@ -0,0 +1 @@
4.22.0

View File

@@ -0,0 +1,203 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rs/zerolog"
)
type targetFlagList []string
func (l *targetFlagList) String() string {
return strings.Join(*l, ",")
}
func (l *targetFlagList) Set(value string) error {
*l = append(*l, value)
return nil
}
func main() {
cfg := loadConfig()
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
cfg.Logger = &logger
agent, err := dockeragent.New(cfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to create docker agent")
}
defer agent.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
logger.Info().Str("pulse_url", cfg.PulseURL).Dur("interval", cfg.Interval).Msg("Starting Pulse Docker agent")
if err := agent.Run(ctx); err != nil && err != context.Canceled {
logger.Fatal().Err(err).Msg("Agent terminated with error")
}
logger.Info().Msg("Agent stopped")
}
func loadConfig() dockeragent.Config {
envURL := strings.TrimSpace(os.Getenv("PULSE_URL"))
envToken := strings.TrimSpace(os.Getenv("PULSE_TOKEN"))
envInterval := strings.TrimSpace(os.Getenv("PULSE_INTERVAL"))
envHostname := strings.TrimSpace(os.Getenv("PULSE_HOSTNAME"))
envAgentID := strings.TrimSpace(os.Getenv("PULSE_AGENT_ID"))
envInsecure := strings.TrimSpace(os.Getenv("PULSE_INSECURE_SKIP_VERIFY"))
envTargets := strings.TrimSpace(os.Getenv("PULSE_TARGETS"))
defaultInterval := 30 * time.Second
if envInterval != "" {
if parsed, err := time.ParseDuration(envInterval); err == nil {
defaultInterval = parsed
}
}
urlFlag := flag.String("url", envURL, "Pulse server URL (e.g. http://pulse:7655)")
tokenFlag := flag.String("token", envToken, "Pulse API token (required)")
intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval (e.g. 30s)")
hostnameFlag := flag.String("hostname", envHostname, "Override hostname reported to Pulse")
agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := flag.Bool("insecure", parseBool(envInsecure), "Skip TLS certificate verification")
var targetFlags targetFlagList
flag.Var(&targetFlags, "target", "Pulse target in url|token[|insecure] format. Repeat to send to multiple Pulse instances")
flag.Parse()
pulseURL := *urlFlag
if pulseURL == "" {
pulseURL = "http://localhost:7655"
}
targets := make([]dockeragent.TargetConfig, 0)
if len(targetFlags) > 0 {
parsedTargets, err := parseTargetSpecs(targetFlags)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
targets = append(targets, parsedTargets...)
}
if envTargets != "" {
envTargetSpecs := splitTargetSpecs(envTargets)
if len(envTargetSpecs) > 0 {
parsedTargets, err := parseTargetSpecs(envTargetSpecs)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
targets = append(targets, parsedTargets...)
}
}
token := strings.TrimSpace(*tokenFlag)
if token == "" && len(targets) == 0 {
fmt.Fprintln(os.Stderr, "error: PULSE_TOKEN, --token, or at least one --target/PULSE_TARGETS entry must be provided")
flag.Usage()
os.Exit(1)
}
interval := *intervalFlag
if interval <= 0 {
interval = 30 * time.Second
}
return dockeragent.Config{
PulseURL: pulseURL,
APIToken: token,
Interval: interval,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
InsecureSkipVerify: *insecureFlag,
Targets: targets,
}
}
func parseBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func parseTargetSpecs(specs []string) ([]dockeragent.TargetConfig, error) {
targets := make([]dockeragent.TargetConfig, 0, len(specs))
for _, spec := range specs {
spec = strings.TrimSpace(spec)
if spec == "" {
continue
}
target, err := parseTargetSpec(spec)
if err != nil {
return nil, err
}
targets = append(targets, target)
}
return targets, nil
}
func parseTargetSpec(spec string) (dockeragent.TargetConfig, error) {
parts := strings.Split(spec, "|")
if len(parts) < 2 {
return dockeragent.TargetConfig{}, fmt.Errorf("invalid target %q: expected format url|token[|insecure]", spec)
}
url := strings.TrimSpace(parts[0])
token := strings.TrimSpace(parts[1])
if url == "" {
return dockeragent.TargetConfig{}, fmt.Errorf("invalid target %q: URL is required", spec)
}
if token == "" {
return dockeragent.TargetConfig{}, fmt.Errorf("invalid target %q: token is required", spec)
}
insecure := false
if len(parts) >= 3 {
switch strings.ToLower(strings.TrimSpace(parts[2])) {
case "1", "true", "yes", "y", "on":
insecure = true
case "", "0", "false", "no", "n", "off":
insecure = false
default:
return dockeragent.TargetConfig{}, fmt.Errorf("invalid target %q: insecure flag must be true/false", spec)
}
}
return dockeragent.TargetConfig{
URL: url,
Token: token,
InsecureSkipVerify: insecure,
}, nil
}
func splitTargetSpecs(value string) []string {
if value == "" {
return nil
}
normalized := strings.ReplaceAll(value, "\n", ";")
raw := strings.Split(normalized, ";")
result := make([]string, 0, len(raw))
for _, item := range raw {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

View File

@@ -0,0 +1,87 @@
package main
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
)
func TestParseTargetSpec(t *testing.T) {
target, err := parseTargetSpec("https://pulse.example.com|abc123|true")
if err != nil {
t.Fatalf("parseTargetSpec returned error: %v", err)
}
if target.URL != "https://pulse.example.com" {
t.Fatalf("expected URL https://pulse.example.com, got %q", target.URL)
}
if target.Token != "abc123" {
t.Fatalf("expected token abc123, got %q", target.Token)
}
if !target.InsecureSkipVerify {
t.Fatalf("expected insecure flag true")
}
}
func TestParseTargetSpecDefaults(t *testing.T) {
target, err := parseTargetSpec(" https://pulse.example.com | token456 ")
if err != nil {
t.Fatalf("parseTargetSpec returned error: %v", err)
}
if target.URL != "https://pulse.example.com" {
t.Fatalf("expected URL https://pulse.example.com, got %q", target.URL)
}
if target.Token != "token456" {
t.Fatalf("expected token token456, got %q", target.Token)
}
if target.InsecureSkipVerify {
t.Fatalf("expected insecure flag false")
}
}
func TestParseTargetSpecInvalid(t *testing.T) {
if _, err := parseTargetSpec("https://pulse.example.com"); err == nil {
t.Fatalf("expected error for missing token")
}
if _, err := parseTargetSpec("https://pulse.example.com|token|maybe"); err == nil {
t.Fatalf("expected error for invalid insecure flag")
}
}
func TestParseTargetSpecsSkipsBlanks(t *testing.T) {
specs, err := parseTargetSpecs([]string{"https://a|tokenA", " ", "\n", "https://b|tokenB|true"})
if err != nil {
t.Fatalf("parseTargetSpecs returned error: %v", err)
}
if len(specs) != 2 {
t.Fatalf("expected 2 targets, got %d", len(specs))
}
expected := []dockeragent.TargetConfig{
{URL: "https://a", Token: "tokenA", InsecureSkipVerify: false},
{URL: "https://b", Token: "tokenB", InsecureSkipVerify: true},
}
for i, target := range specs {
if target != expected[i] {
t.Fatalf("target %d mismatch: expected %+v, got %+v", i, expected[i], target)
}
}
}
func TestSplitTargetSpecs(t *testing.T) {
values := splitTargetSpecs("https://a|tokenA;https://b|tokenB\nhttps://c|tokenC")
expected := []string{"https://a|tokenA", "https://b|tokenB", "https://c|tokenC"}
if len(values) != len(expected) {
t.Fatalf("expected %d values, got %d", len(expected), len(values))
}
for i, v := range values {
if v != expected[i] {
t.Fatalf("value %d mismatch: expected %q, got %q", i, expected[i], v)
}
}
}

270
cmd/pulse/config.go Normal file
View File

@@ -0,0 +1,270 @@
package main
import (
"bufio"
"encoding/base64"
"fmt"
"io/ioutil"
"os"
"strings"
"syscall"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
exportFile string
importFile string
passphrase string
forceImport bool
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configuration management commands",
Long: `Manage Pulse configuration settings`,
}
var configInfoCmd = &cobra.Command{
Use: "info",
Short: "Show configuration information",
Long: `Display information about Pulse configuration`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Pulse Configuration Information")
fmt.Println("==============================")
fmt.Println()
fmt.Println("Configuration is managed through the web UI.")
fmt.Println("Settings are stored in encrypted files at /etc/pulse/")
fmt.Println()
fmt.Println("Configuration files:")
fmt.Println(" - nodes.enc : Encrypted Proxmox node configurations")
fmt.Println(" - email.enc : Encrypted email settings")
fmt.Println(" - system.json : System settings (polling interval, etc)")
fmt.Println(" - alerts.json : Alert rules and thresholds")
fmt.Println(" - webhooks.json : Webhook configurations")
fmt.Println()
fmt.Println("To configure Pulse, use the Settings tab in the web UI.")
return nil
},
}
var configExportCmd = &cobra.Command{
Use: "export",
Short: "Export configuration with encryption",
Long: `Export all Pulse configuration to an encrypted file`,
Example: ` # Export with interactive passphrase prompt
pulse config export -o pulse-config.enc
# Export with passphrase from environment variable
PULSE_PASSPHRASE=mysecret pulse config export -o pulse-config.enc`,
RunE: func(cmd *cobra.Command, args []string) error {
// Get passphrase
pass := getPassphrase("Enter passphrase for encryption: ", false)
if pass == "" {
return fmt.Errorf("passphrase is required")
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Export configuration
exportedData, err := persistence.ExportConfig(pass)
if err != nil {
return fmt.Errorf("failed to export configuration: %w", err)
}
// Write to file or stdout
if exportFile != "" {
if err := ioutil.WriteFile(exportFile, []byte(exportedData), 0600); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Configuration exported to %s\n", exportFile)
} else {
fmt.Println(exportedData)
}
return nil
},
}
var configImportCmd = &cobra.Command{
Use: "import",
Short: "Import configuration from encrypted export",
Long: `Import Pulse configuration from an encrypted export file`,
Example: ` # Import with interactive passphrase prompt
pulse config import -i pulse-config.enc
# Import with passphrase from environment variable
PULSE_PASSPHRASE=mysecret pulse config import -i pulse-config.enc
# Force import without confirmation
pulse config import -i pulse-config.enc --force`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check if import file is specified
if importFile == "" {
return fmt.Errorf("import file is required (use -i flag)")
}
// Read import file
data, err := ioutil.ReadFile(importFile)
if err != nil {
return fmt.Errorf("failed to read import file: %w", err)
}
// Get passphrase
pass := getPassphrase("Enter passphrase for decryption: ", false)
if pass == "" {
return fmt.Errorf("passphrase is required")
}
// Confirm import unless forced
if !forceImport {
fmt.Println("WARNING: This will overwrite all existing configuration!")
fmt.Print("Continue? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "yes" && response != "y" {
fmt.Println("Import cancelled")
return nil
}
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(string(data), pass); err != nil {
return fmt.Errorf("failed to import configuration: %w", err)
}
fmt.Println("Configuration imported successfully")
fmt.Println("Please restart Pulse for changes to take effect:")
fmt.Println(" sudo systemctl restart pulse")
return nil
},
}
// getPassphrase prompts for a passphrase or gets it from environment
func getPassphrase(prompt string, confirm bool) string {
// Check environment variable first
if pass := os.Getenv("PULSE_PASSPHRASE"); pass != "" {
return pass
}
// Check if passphrase flag was set
if passphrase != "" {
return passphrase
}
// Interactive prompt
fmt.Print(prompt)
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return ""
}
pass := string(bytePassword)
// Confirm if requested
if confirm {
fmt.Print("Confirm passphrase: ")
bytePassword2, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return ""
}
if string(bytePassword2) != pass {
fmt.Println("Passphrases do not match")
return ""
}
}
return pass
}
// Environment variable support for initial setup
var configAutoImportCmd = &cobra.Command{
Use: "auto-import",
Hidden: true, // Hidden command for automated setup
Short: "Auto-import configuration on startup",
Long: `Automatically import configuration from URL or file on first startup`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check for auto-import environment variables
configURL := os.Getenv("PULSE_INIT_CONFIG_URL")
configData := os.Getenv("PULSE_INIT_CONFIG_DATA")
configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE")
if configURL == "" && configData == "" {
return nil // Nothing to import
}
if configPass == "" {
return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import")
}
var encryptedData string
// Get data from URL or direct data
if configURL != "" {
// TODO: Implement HTTP fetch for config URL
return fmt.Errorf("URL import not yet implemented")
} else if configData != "" {
// Decode base64 if needed
if decoded, err := base64.StdEncoding.DecodeString(configData); err == nil {
encryptedData = string(decoded)
} else {
encryptedData = configData
}
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(encryptedData, configPass); err != nil {
return fmt.Errorf("failed to auto-import configuration: %w", err)
}
fmt.Println("Configuration auto-imported successfully")
return nil
},
}
func init() {
configCmd.AddCommand(configInfoCmd)
configCmd.AddCommand(configExportCmd)
configCmd.AddCommand(configImportCmd)
configCmd.AddCommand(configAutoImportCmd)
// Export flags
configExportCmd.Flags().StringVarP(&exportFile, "output", "o", "", "Output file for encrypted configuration")
configExportCmd.Flags().StringVarP(&passphrase, "passphrase", "p", "", "Passphrase for encryption (or use PULSE_PASSPHRASE env var)")
// Import flags
configImportCmd.Flags().StringVarP(&importFile, "input", "i", "", "Input file with encrypted configuration")
configImportCmd.Flags().StringVarP(&passphrase, "passphrase", "p", "", "Passphrase for decryption (or use PULSE_PASSPHRASE env var)")
configImportCmd.Flags().BoolVarP(&forceImport, "force", "f", false, "Force import without confirmation")
}

327
cmd/pulse/main.go Normal file
View File

@@ -0,0 +1,327 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
_ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// Version information (set at build time with -ldflags)
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
var rootCmd = &cobra.Command{
Use: "pulse",
Short: "Pulse - Proxmox VE and PBS monitoring system",
Long: `Pulse is a real-time monitoring system for Proxmox Virtual Environment (PVE) and Proxmox Backup Server (PBS)`,
Version: Version,
Run: func(cmd *cobra.Command, args []string) {
runServer()
},
}
func init() {
// Add config command
rootCmd.AddCommand(configCmd)
// Add version command
rootCmd.AddCommand(versionCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Pulse %s\n", Version)
if BuildTime != "unknown" {
fmt.Printf("Built: %s\n", BuildTime)
}
if GitCommit != "unknown" {
fmt.Printf("Commit: %s\n", GitCommit)
}
},
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runServer() {
// Initialize logger
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
// Check for auto-import on first startup
if shouldAutoImport() {
if err := performAutoImport(); err != nil {
log.Error().Err(err).Msg("Auto-import failed, continuing with normal startup")
}
}
// Load unified configuration
cfg, err := config.Load()
if err != nil {
log.Fatal().Err(err).Msg("Failed to load configuration")
}
log.Info().Msg("Starting Pulse monitoring server")
// Create context that cancels on interrupt
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize WebSocket hub first
wsHub := websocket.NewHub(nil)
// Set allowed origins from configuration
if cfg.AllowedOrigins != "" {
if cfg.AllowedOrigins == "*" {
// Explicit wildcard - allow all origins (less secure)
wsHub.SetAllowedOrigins([]string{"*"})
} else {
// Use configured origins
wsHub.SetAllowedOrigins(strings.Split(cfg.AllowedOrigins, ","))
}
} else {
// Default: don't set any specific origins
// This allows the WebSocket hub to use its lenient check for local/private networks
// The hub will automatically allow connections from common local/Docker scenarios
// while still being secure for public deployments
wsHub.SetAllowedOrigins([]string{})
}
go wsHub.Run()
// Initialize reloadable monitoring system
reloadableMonitor, err := monitoring.NewReloadableMonitor(cfg, wsHub)
if err != nil {
log.Fatal().Err(err).Msg("Failed to initialize monitoring system")
}
// Set state getter for WebSocket hub
wsHub.SetStateGetter(func() interface{} {
return reloadableMonitor.GetState()
})
// Start monitoring
reloadableMonitor.Start(ctx)
// Initialize API server with reload function
var router *api.Router
reloadFunc := func() error {
if err := reloadableMonitor.Reload(); err != nil {
return err
}
if router != nil {
router.SetMonitor(reloadableMonitor.GetMonitor())
}
return nil
}
router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc)
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data)
IdleTimeout: 60 * time.Second,
}
// Start config watcher for .env file changes
configWatcher, err := config.NewConfigWatcher(cfg)
if err != nil {
log.Warn().Err(err).Msg("Failed to create config watcher, .env changes will require restart")
} else {
// Set callback to reload monitor when mock.env changes
configWatcher.SetMockReloadCallback(func() {
log.Info().Msg("mock.env changed, reloading monitor")
if err := reloadableMonitor.Reload(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after mock.env change")
} else if router != nil {
router.SetMonitor(reloadableMonitor.GetMonitor())
}
})
if err := configWatcher.Start(); err != nil {
log.Warn().Err(err).Msg("Failed to start config watcher")
}
defer configWatcher.Stop()
}
// Start server
go func() {
if cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
log.Info().
Str("host", cfg.BackendHost).
Int("port", cfg.FrontendPort).
Str("protocol", "HTTPS").
Msg("Server listening")
if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Failed to start HTTPS server")
}
} else {
if cfg.HTTPSEnabled {
log.Warn().Msg("HTTPS_ENABLED is true but TLS_CERT_FILE or TLS_KEY_FILE not configured, falling back to HTTP")
}
log.Info().
Str("host", cfg.BackendHost).
Int("port", cfg.FrontendPort).
Str("protocol", "HTTP").
Msg("Server listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Failed to start HTTP server")
}
}
}()
// Setup signal handlers
sigChan := make(chan os.Signal, 1)
reloadChan := make(chan os.Signal, 1)
// SIGTERM and SIGINT for shutdown
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// SIGHUP for config reload
signal.Notify(reloadChan, syscall.SIGHUP)
// Handle signals
for {
select {
case <-reloadChan:
log.Info().Msg("Received SIGHUP, reloading configuration...")
// Reload .env manually (watcher will also pick it up)
if configWatcher != nil {
configWatcher.ReloadConfig()
}
// Reload system.json
persistence := config.NewConfigPersistence(cfg.DataPath)
if persistence != nil {
if sysConfig, err := persistence.LoadSystemSettings(); err == nil {
// Note: Polling interval is now hardcoded to 10s, no longer configurable
// Could reload other system.json settings here
_ = sysConfig // Avoid unused variable warning
log.Info().Msg("Reloaded system configuration")
} else {
log.Error().Err(err).Msg("Failed to reload system.json")
}
}
// Could reload other configs here (alerts.json, webhooks.json, etc.)
log.Info().Msg("Configuration reload complete")
case <-sigChan:
log.Info().Msg("Shutting down server...")
goto shutdown
}
}
shutdown:
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("Server shutdown error")
}
// Stop monitoring
cancel()
reloadableMonitor.Stop()
// Stop config watcher
if configWatcher != nil {
configWatcher.Stop()
}
log.Info().Msg("Server stopped")
}
// shouldAutoImport checks if auto-import environment variables are set
func shouldAutoImport() bool {
// Check if config already exists
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// If nodes.enc already exists, skip auto-import
if _, err := os.Stat(filepath.Join(configPath, "nodes.enc")); err == nil {
return false
}
// Check for auto-import environment variables
return os.Getenv("PULSE_INIT_CONFIG_DATA") != "" ||
os.Getenv("PULSE_INIT_CONFIG_FILE") != ""
}
// performAutoImport imports configuration from environment variables
func performAutoImport() error {
configData := os.Getenv("PULSE_INIT_CONFIG_DATA")
configFile := os.Getenv("PULSE_INIT_CONFIG_FILE")
configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE")
if configPass == "" {
return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import")
}
var encryptedData string
// Get data from file or direct data
if configFile != "" {
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
encryptedData = string(data)
} else if configData != "" {
// Try to decode base64 if it looks encoded
if decoded, err := base64.StdEncoding.DecodeString(configData); err == nil {
encryptedData = string(decoded)
} else {
encryptedData = configData
}
} else {
return fmt.Errorf("no config data provided")
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(encryptedData, configPass); err != nil {
return fmt.Errorf("failed to import configuration: %w", err)
}
log.Info().Msg("Configuration auto-imported successfully")
return nil
}

30
dev/oidc/dex-config.yaml Normal file
View File

@@ -0,0 +1,30 @@
issuer: http://127.0.0.1:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
frontend:
issuer: Pulse Mock IDP
dir: /srv/dex/web
logger:
level: info
format: text
oauth2:
skipApprovalScreen: true
responseTypes: ["code", "token", "id_token"]
alwaysShowLoginScreen: true
staticClients:
- id: pulse-dev
name: Pulse Dev
secret: pulse-secret
redirectURIs:
- http://127.0.0.1:5173/api/oidc/callback
- http://127.0.0.1:7655/api/oidc/callback
- http://127.0.0.1:8765/api/oidc/callback
staticPasswords:
- email: admin@example.com
hash: "$2a$10$uo8fC/3BtvIULFvS7/NuRe6Bn3NmidSXHHiAchpdZEiBBV3IcJKfy"
username: admin
userID: 19d82f09-9a6b-4f38-a6d8-2c4ed1faff42
displayName: Admin User
enablePasswordDB: true

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse
ports:
- "7655:7655" # Web UI and API
volumes:
- pulse_data:/data
environment:
- TZ=UTC # Set your timezone
# - PUID=1000 # Optional: Set user ID (uncomment and adjust as needed)
# - PGID=1000 # Optional: Set group ID (uncomment and adjust as needed)
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7655"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
pulse_data:
driver: local

43
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
set -e
# Default UID/GID if not provided
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Only adjust permissions if running as root
if [ "$(id -u)" = "0" ]; then
echo "Starting with UID: $PUID, GID: $PGID"
# If PUID is 0 (root), don't create a new user, just run as root
if [ "$PUID" = "0" ]; then
echo "Running as root user"
# Fix ownership to root
chown -R root:root /data /app /etc/pulse /opt/pulse
exec "$@"
fi
# Check if we need to modify the user/group
current_uid=$(id -u pulse 2>/dev/null || echo "")
current_gid=$(getent group pulse 2>/dev/null | cut -d: -f3 || echo "")
# If user/group don't match, recreate them
if [ "$current_uid" != "$PUID" ] || [ "$current_gid" != "$PGID" ]; then
# Remove existing user and group
deluser pulse 2>/dev/null || true
delgroup pulse 2>/dev/null || true
# Create new group and user with desired IDs
addgroup -g "$PGID" pulse
adduser -D -u "$PUID" -G pulse pulse
fi
# Fix ownership of data directory
chown -R pulse:pulse /data /app /etc/pulse /opt/pulse
# Switch to pulse user
exec su-exec pulse "$@"
else
# Not running as root, just exec the command
exec "$@"
fi

1058
docs/API.md Normal file

File diff suppressed because it is too large Load Diff

469
docs/CONFIGURATION.md Normal file
View File

@@ -0,0 +1,469 @@
# Pulse Configuration Guide
## Key Features
- **🔒 Auto-Hashing Security** (v4.5.0+): Plain text credentials provided via environment variables are hashed before being persisted
- **📁 Separated Configuration**: Authentication (.env), runtime settings (system.json), and node credentials (nodes.enc) stay isolated
- **⚙️ UI-First Provisioning**: Nodes and infrastructure settings are managed through the web UI to prevent accidental wipes
- **🔐 Enterprise Security**: Credentials encrypted at rest, hashed in memory
- **🎯 Hysteresis Thresholds**: `alerts.json` stores trigger/clear pairs, fractional network limits, per-metric delays, and overrides that match the Alert Thresholds UI
## Configuration File Structure
Pulse uses three separate configuration files, each with a specific purpose. This separation ensures security, clarity, and proper access control.
### File Locations
All configuration files are stored in `/etc/pulse/` (or `/data/` in Docker containers).
```
/etc/pulse/
├── .env # Authentication credentials ONLY
├── system.json # Application settings (ports, intervals, etc.)
├── nodes.enc # Encrypted node credentials
├── oidc.enc # Encrypted OIDC client configuration (issuer, client ID/secret)
├── alerts.json # Alert thresholds and rules
└── webhooks.enc # Encrypted webhook configurations (v4.1.9+)
```
---
## 📁 `.env` - Authentication & Security
**Purpose:** Contains authentication credentials and security settings ONLY.
**Format:** Environment variables (KEY=VALUE)
**Contents:**
```bash
# User authentication
PULSE_AUTH_USER='admin' # Admin username
PULSE_AUTH_PASS='$2a$12$...' # Bcrypt hashed password (keep quotes!)
API_TOKEN=abc123... # API token (Pulse hashes it automatically)
# Security settings
DISABLE_AUTH=true # Disable authentication entirely
PULSE_AUDIT_LOG=true # Enable security audit logging
# Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details)
PROXY_AUTH_SECRET=secret123 # Shared secret between proxy and Pulse
PROXY_AUTH_USER_HEADER=X-Username # Header containing authenticated username
PROXY_AUTH_ROLE_HEADER=X-Groups # Header containing user roles/groups
PROXY_AUTH_ADMIN_ROLE=admin # Role that grants admin access
PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
```
**Important Notes:**
- Password hash MUST be in single quotes to prevent shell expansion
- API tokens are stored as SHA3-256 hashes on disk; provide a plain token and Pulse hashes it automatically
- This file should have restricted permissions (600)
- Never commit this file to version control
- ProxmoxVE installations may pre-configure API_TOKEN
- Changes to this file are applied immediately without restart (v4.3.9+)
- **DO NOT** put port configuration here - use system.json or systemd overrides
- Copy `.env.example` from the repository for a ready-to-edit template
---
## 📁 `oidc.enc` - OIDC Single Sign-On
**Purpose:** Stores OpenID Connect (OIDC) client configuration for single sign-on.
**Format:** Encrypted JSON (AES-256-GCM via Pulse crypto manager)
**Contents:**
```json
{
"enabled": true,
"issuerUrl": "https://login.example.com/realms/pulse",
"clientId": "pulse",
"clientSecret": "s3cr3t",
"redirectUrl": "https://pulse.example.com/api/oidc/callback",
"scopes": ["openid", "profile", "email"],
"usernameClaim": "preferred_username",
"emailClaim": "email",
"groupsClaim": "groups",
"allowedGroups": ["pulse-admins"],
"allowedDomains": ["example.com"],
"allowedEmails": []
}
```
**Important Notes:**
- Managed through **Settings → Security → Single sign-on (OIDC)** in the UI.
- Secrets are encrypted at rest; client secrets are never exposed back to the browser.
- Optional environment variables (`OIDC_*`) can override individual fields and lock the UI.
- Redirect URL defaults to `<PUBLIC_URL>/api/oidc/callback` if not specified.
---
## 📁 `system.json` - Application Settings
**Purpose:** Contains all application behavior settings and configuration.
**Format:** JSON
**Contents:**
```json
{
"pbsPollingInterval": 60, // Seconds between PBS refreshes (PVE polling fixed at 10s)
"pmgPollingInterval": 60, // Seconds between PMG refreshes (mail analytics and health)
"connectionTimeout": 60, // Seconds before node connection timeout
"autoUpdateEnabled": false, // Systemd timer toggle for automatic updates
"autoUpdateCheckInterval": 24, // Hours between auto-update checks
"autoUpdateTime": "03:00", // Preferred update window (combined with randomized delay)
"updateChannel": "stable", // Update channel: stable or rc
"allowedOrigins": "", // CORS allowed origins (empty = same-origin only)
"allowEmbedding": false, // Allow iframe embedding
"allowedEmbedOrigins": "", // Comma-separated origins allowed to embed Pulse
"backendPort": 3000, // Internal API listen port (not normally changed)
"frontendPort": 7655, // Public port exposed by the service
"logLevel": "info", // Log level: debug, info, warn, error
"discoveryEnabled": true, // Enable/disable network discovery for Proxmox/PBS servers
"discoverySubnet": "auto", // CIDR to scan ("auto" discovers common ranges)
"theme": "" // UI theme preference: "", "light", or "dark"
}
```
**Important Notes:**
- User-editable via Settings UI
- Environment variable overrides (e.g., `DISCOVERY_ENABLED`, `ALLOWED_ORIGINS`) take precedence and lock the corresponding UI controls
- Can be safely backed up without exposing secrets
- Missing file results in defaults being used
- Changes take effect immediately (no restart required)
- API tokens are no longer managed in system.json (moved to .env in v4.3.9+)
---
## 📁 `nodes.enc` - Encrypted Node Credentials
**Purpose:** Stores encrypted credentials for Proxmox VE and PBS nodes.
**Format:** Encrypted JSON (AES-256-GCM)
**Structure (when decrypted):**
```json
{
"pveInstances": [
{
"name": "pve-node1",
"url": "https://192.168.1.10:8006",
"username": "root@pam",
"password": "encrypted_password_here",
"token": "optional_api_token"
}
],
"pbsInstances": [
{
"name": "backup-server",
"url": "https://192.168.1.20:8007",
"username": "admin@pbs",
"password": "encrypted_password_here"
}
]
}
```
**Important Notes:**
- Encrypted at rest using system-generated key
- Credentials never exposed in UI (only "•••••" shown)
- Export/import requires authentication
- Automatic re-encryption on each save
---
## 📁 `alerts.json` - Alert Thresholds & Scheduling
**Purpose:** Captures the full alerting policy default thresholds, per-resource overrides, suppression windows, and delivery preferences exactly as shown in **Alerts → Thresholds**.
**Format:** JSON with hysteresis-aware thresholds (`trigger` and `clear`) and nested configuration blocks.
**Example (trimmed):**
```json
{
"enabled": true,
"guestDefaults": {
"cpu": { "trigger": 90, "clear": 80 },
"memory": { "trigger": 85, "clear": 72.5 },
"networkOut": { "trigger": 120.5, "clear": 95 }
},
"nodeDefaults": {
"cpu": { "trigger": 85, "clear": 70 },
"temperature": { "trigger": 80, "clear": 70 },
"disableConnectivity": false
},
"storageDefault": { "trigger": 85, "clear": 75 },
"dockerDefaults": {
"cpu": { "trigger": 75, "clear": 60 },
"restartCount": 3,
"restartWindow": 300
},
"pmgThresholds": {
"queueTotalWarning": 500,
"oldestMessageWarnMins": 30
},
"timeThresholds": { "guest": 90, "node": 60, "storage": 180, "pbs": 120 },
"metricTimeThresholds": {
"guest": { "disk": 120, "networkOut": 240 }
},
"overrides": {
"delly.lan/qemu/101": {
"memory": { "trigger": 92, "clear": 80 },
"networkOut": -1,
"poweredOffSeverity": "warning"
}
},
"aggregation": {
"enabled": true,
"timeWindow": 120,
"countThreshold": 3,
"similarityWindow": 90
},
"flapping": {
"enabled": true,
"threshold": 5,
"window": 300,
"suppressionTime": 600,
"minStability": 180
},
"schedule": {
"quietHours": {
"enabled": true,
"start": "22:00",
"end": "06:00",
"timezone": "Europe/London",
"days": { "monday": true, "tuesday": true, "sunday": true },
"suppress": { "performance": true, "storage": false, "offline": true }
},
"cooldown": 15,
"grouping": { "enabled": true, "window": 120, "byNode": true }
}
}
```
**Key behaviours:**
- Thresholds use hysteresis pairs (`trigger` / `clear`) to avoid flapping. Use decimals for fine-grained network and IO limits.
- Set a metric to `-1` to disable it globally or per-resource (the UI shows “Off” and adds a **Custom** badge).
- `timeThresholds` apply a grace period before an alert fires; `metricTimeThresholds` allow per-metric overrides (e.g., delay network alerts longer than CPU).
- `overrides` are indexed by the stable resource ID returned from `/api/state` (VMs: `instance/qemu/vmid`, containers: `instance/lxc/ctid`, nodes: `instance/node`).
- Quiet hours, escalation, deduplication, and restart loop detection are all managed here, and the UI keeps the JSON in sync automatically.
> Tip: Back up `alerts.json` alongside `.env` during exports. Restoring it preserves all overrides, quiet-hour schedules, and webhook routing.
---
## 🔄 Automatic Updates
Pulse can automatically install stable updates to keep your installation secure and current.
### How It Works
- **Systemd Timer**: Runs daily at 2 AM with 4-hour random delay
- **Stable Only**: Never installs release candidates automatically
- **Safe Rollback**: Creates backup before updating, restores on failure
- **Respects Config**: Checks `autoUpdateEnabled` in system.json
### Enable/Disable
```bash
# Enable during installation
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --enable-auto-updates
# Enable after installation
systemctl enable --now pulse-update.timer
# Disable auto-updates
systemctl disable --now pulse-update.timer
# Check status
systemctl status pulse-update.timer
systemctl list-timers pulse-update
# View logs
journalctl -u pulse-update
```
### Configuration
Set `autoUpdateEnabled: true` in system.json or toggle in Settings UI.
**Note**: Docker installations do not support automatic updates (use Docker's update mechanisms instead).
---
## Configuration Priority
Settings are loaded in this order (later overrides earlier):
1. **Built-in defaults** - Hardcoded application defaults
2. **system.json file** - Settings configured via UI
3. **Environment variables** - Override both defaults and system.json
### Environment Variables
#### Configuration Variables (override system.json)
These env vars override system.json values. When set, the UI will show a warning and disable the affected fields:
- `DISCOVERY_ENABLED` - Enable/disable network discovery (default: true)
- `DISCOVERY_SUBNET` - Custom network to scan (default: auto-scans common networks)
- `CONNECTION_TIMEOUT` - API timeout in seconds (default: 10)
- `ALLOWED_ORIGINS` - CORS origins (default: same-origin only)
- `LOG_LEVEL` - Log verbosity: debug/info/warn/error (default: info)
- `PULSE_PUBLIC_URL` - Full URL to access Pulse (e.g., `http://192.168.1.100:7655`)
- **Auto-detected** if not set (except inside Docker where detection is disabled)
- Used in webhook notifications for "View in Pulse" links
- Set explicitly when running in containers or whenever auto-detection picks the wrong address
- Example: `PULSE_PUBLIC_URL="http://192.168.1.100:7655"`
#### Authentication Variables (from .env file)
These should be set in the .env file for security:
- `PULSE_AUTH_USER`, `PULSE_AUTH_PASS` - Basic authentication
- `API_TOKEN` - API token for authentication
- `DISABLE_AUTH` - Set to `true` to disable authentication entirely
#### OIDC Variables (optional overrides)
Set these environment variables to manage single sign-on without using the UI. When present, the OIDC form is locked read-only.
- `OIDC_ENABLED` - `true` / `false`
- `OIDC_ISSUER_URL` - Provider issuer URL
- `OIDC_CLIENT_ID` - Registered client ID
- `OIDC_CLIENT_SECRET` - Client secret (plain text)
- `OIDC_REDIRECT_URL` - Override default redirect callback (use `https://` when behind TLS proxy)
- `OIDC_LOGOUT_URL` - End-session URL for proper OIDC logout (e.g., `https://auth.example.com/application/o/pulse/end-session/`)
- `OIDC_SCOPES` - Space/comma separated scopes (e.g. `openid profile email`)
- `OIDC_USERNAME_CLAIM` - Claim used for the Pulse username
- `OIDC_EMAIL_CLAIM` - Claim that contains the email address
- `OIDC_GROUPS_CLAIM` - Claim that lists group memberships
- `OIDC_ALLOWED_GROUPS` - Allowed group names (comma/space separated)
- `OIDC_ALLOWED_DOMAINS` - Allowed email domains
- `OIDC_ALLOWED_EMAILS` - Explicit email allowlist
- `PULSE_PUBLIC_URL` **(strongly recommended)** - The externally reachable base URL Pulse should advertise. This is used to generate the default redirect URI. If you expose Pulse on multiple hostnames, list each one in your IdP configuration because OIDC callbacks must match exactly.
> **Authentik note:** Assign an RSA signing key to the application so ID tokens use `RS256`. Without it Authentik falls back to `HS256`, which Pulse rejects. See [Authentik setup details](OIDC.md#authentik) for the exact menu path.
#### Proxy/SSO Authentication Variables
For integration with authentication proxies (Authentik, Authelia, etc):
- `PROXY_AUTH_SECRET` - Shared secret between proxy and Pulse (required for proxy auth)
- `PROXY_AUTH_USER_HEADER` - Header containing authenticated username (default: none)
- `PROXY_AUTH_ROLE_HEADER` - Header containing user roles/groups (default: none)
- `PROXY_AUTH_ROLE_SEPARATOR` - Separator for multiple roles (default: |)
- `PROXY_AUTH_ADMIN_ROLE` - Role name that grants admin access (default: admin)
- `PROXY_AUTH_LOGOUT_URL` - URL to redirect for SSO logout (default: none)
See [Proxy Authentication Guide](PROXY_AUTH.md) for detailed configuration examples.
#### Port Configuration
Port configuration should be done via one of these methods:
1. **systemd override** (Recommended for production):
```bash
sudo systemctl edit pulse
# Add: Environment="FRONTEND_PORT=8080"
```
2. **system.json** (For persistent configuration):
```json
{"frontendPort": 8080}
```
3. **Environment variable** (For Docker/testing):
- `FRONTEND_PORT` - Port to listen on (default: 7655)
- `PORT` - Legacy port variable (use FRONTEND_PORT instead)
#### TLS/HTTPS Configuration
- `HTTPS_ENABLED` - Enable HTTPS (true/false)
- `TLS_CERT_FILE`, `TLS_KEY_FILE` - Paths to TLS certificate files
> **⚠️ UI Override Warning**: When configuration env vars are set (like `ALLOWED_ORIGINS`), the corresponding UI fields will be disabled with a warning message. Remove the env var and restart to enable UI configuration.
---
## Automated Setup (Skip UI)
For automated deployments (CI/CD, infrastructure as code, ProxmoxVE scripts), you can configure Pulse authentication via environment variables, completely bypassing the UI setup screen.
### Simple Automated Setup
**Option 1: API Token Authentication**
```bash
# Start Pulse with API token - setup screen is skipped
API_TOKEN=your-secure-api-token ./pulse
# The token is hashed and stored securely
# Use this same token for all API calls
curl -H "X-API-Token: your-secure-api-token" http://localhost:7655/api/nodes
```
**Option 2: Basic Authentication**
```bash
# Start Pulse with username/password - setup screen is skipped
PULSE_AUTH_USER=admin \
PULSE_AUTH_PASS=your-secure-password \
./pulse
# Password is bcrypt hashed and stored securely
# Use these credentials for UI login or API calls
```
**Option 3: Both (API + Basic Auth)**
```bash
# Configure both authentication methods
API_TOKEN=your-api-token \
PULSE_AUTH_USER=admin \
PULSE_AUTH_PASS=your-password \
./pulse
```
### Security Notes
- **Automatic hashing**: Plain text credentials are automatically hashed when provided via environment variables
- API tokens → SHA3-256 hash
- Passwords → bcrypt hash (cost 12)
- **Pre-hashed credentials supported**: Advanced users can provide pre-hashed values:
- API tokens: 64-character hex string (SHA3-256 hash)
- Passwords: bcrypt hash starting with `$2a$`, `$2b$`, or `$2y$` (60 characters)
- **No plain text in memory**: All credentials are hashed before use
- Once configured, the setup screen is automatically skipped
- Credentials work immediately - no additional setup required
### Example: Docker Automated Deployment
```bash
#!/bin/bash
# Generate secure token
API_TOKEN=$(openssl rand -hex 32)
# Deploy with authentication pre-configured
docker run -d \
--name pulse \
-p 7655:7655 \
-e API_TOKEN="$API_TOKEN" \
-v pulse-data:/data \
rcourtman/pulse:latest
echo "Pulse deployed! Use API token: $API_TOKEN"
# Immediately use the API - no setup needed
curl -H "X-API-Token: $API_TOKEN" http://localhost:7655/api/nodes
```
---
## Security Best Practices
1. **File Permissions**
```bash
chmod 600 /etc/pulse/.env # Only readable by owner
chmod 644 /etc/pulse/system.json # Readable by all, writable by owner
chmod 600 /etc/pulse/nodes.enc # Only readable by owner
```
2. **Backup Strategy**
- `.env` - Backup separately and securely (contains auth)
- `system.json` - Safe to include in regular backups
- `nodes.enc` - Backup with .env (contains encrypted credentials)
3. **Version Control**
- **NEVER** commit `.env` or `nodes.enc`
- `system.json` can be committed if it doesn't contain sensitive data
- Use `.gitignore` to exclude sensitive files

318
docs/DOCKER.md Normal file
View File

@@ -0,0 +1,318 @@
# Docker Deployment Guide
> **Proxmox VE Users:** Consider using the [official installer](https://github.com/rcourtman/Pulse#install) instead, which automatically creates an optimized LXC container.
## Quick Start
```bash
docker run -d \
--name pulse \
-p 7655:7655 \
-v pulse_data:/data \
--restart unless-stopped \
rcourtman/pulse:latest
```
1. Access at `http://your-server:7655`
2. **Complete the mandatory security setup** on first access
3. Save your credentials - they won't be shown again!
## First-Time Setup
When you first access Pulse, you'll see the security setup wizard:
1. **Create Admin Account**
- Choose a username (default: admin)
- Set a password or use the generated one
- An API token is automatically generated
2. **Save Your Credentials**
- Download or copy them immediately
- They won't be shown again after setup
3. **Access Dashboard**
- Click "Continue to Login"
- Use your new credentials to sign in
## Docker Compose
### Basic Setup (Recommended for First-Time Users)
```yaml
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse
ports:
- "7655:7655"
volumes:
- pulse_data:/data
restart: unless-stopped
volumes:
pulse_data:
```
Then:
1. Run: `docker compose up -d`
2. Access: `http://your-server:7655`
3. Complete the security setup wizard
4. (Optional) Copy `.env.example` to `.env` if you want to pre-configure credentials later
### Pre-Configured Authentication (Advanced)
If you want to skip the setup wizard, you can pre-configure authentication:
```yaml
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse
ports:
- "7655:7655"
volumes:
- pulse_data:/data
environment:
PULSE_AUTH_USER: 'admin'
# Plain text values are auto-hashed on startup. To use a bcrypt hash,
# escape $ as $$ (e.g. $$2a$$12$$...) so docker compose does not treat it
# as variable expansion.
PULSE_AUTH_PASS: 'super-secret-password'
API_TOKEN: 'your-48-char-hex-token' # Generate: openssl rand -hex 24
PULSE_PUBLIC_URL: 'https://pulse.example.com' # Used for webhooks/links
# TZ: 'UTC'
restart: unless-stopped
volumes:
pulse_data:
```
⚠️ **Important**: If you paste a bcrypt hash instead of a plain-text password, remember that Compose treats `$` as variable expansion. Escape each `$` as `$$`. Example: `$2a$12$...` becomes `$$2a$$12$$...`.
### Using External .env File (Cleaner Approach)
Create `.env` file (no escaping needed here). You can copy `.env.example` from the repository as a starting point:
```env
PULSE_AUTH_USER=admin
PULSE_AUTH_PASS=super-secret-password # Plain text (auto-hashed) or bcrypt hash
API_TOKEN=your-48-char-hex-token # Generate with: openssl rand -hex 24
PULSE_PUBLIC_URL=https://pulse.example.com # Recommended for webhooks
TZ=Asia/Kolkata # Optional: matches host timezone
```
**Note**: Plain text credentials are automatically hashed for security. You can provide either plain text (simpler) or pre-hashed values (advanced).
Docker-compose.yml:
```yaml
services:
pulse:
image: rcourtman/pulse:latest
container_name: pulse
ports:
- "7655:7655"
volumes:
- pulse_data:/data
env_file: .env
restart: unless-stopped
volumes:
pulse_data:
```
### Updating Your Stack
```bash
docker compose pull # Fetch the latest Pulse image
docker compose up -d # Recreate container with zero-downtime update
```
If you change anything in `.env`, run `docker compose up -d` again so the container picks up the new values.
## Generating Credentials (Optional)
**Note**: Since v4.5.0, plain text credentials are automatically hashed. Pre-hashing is optional for advanced users.
### Simple Approach (Recommended)
```bash
# Just use plain text - Pulse auto-hashes for you
docker run -d \
-e PULSE_AUTH_USER=admin \
-e PULSE_AUTH_PASS=mypassword \
-e API_TOKEN=mytoken123 \
rcourtman/pulse:latest
```
### Advanced: Pre-Hashing (Optional)
```bash
# Generate bcrypt hash for password
docker run --rm -it rcourtman/pulse:latest pulse hash-password
# Generate random API token
openssl rand -hex 32
```
## Data Persistence
All configuration and data is stored in `/data`:
- `.env` - Authentication credentials (if using setup wizard)
- `*.enc` - Encrypted node credentials
- `*.json` - Configuration files
- `.encryption.key` - Auto-generated encryption key
### Backup
```bash
docker run --rm -v pulse_data:/data -v $(pwd):/backup alpine tar czf /backup/pulse-backup.tar.gz -C /data .
```
### Restore
```bash
docker run --rm -v pulse_data:/data -v $(pwd):/backup alpine tar xzf /backup/pulse-backup.tar.gz -C /data
```
## Docker Workspace Highlights
Once the agent is reporting, open the **Docker** tab in Pulse to explore:
- **Host grid with issues column** surfaces restart loops, health-check failures, and highlights hosts that have missed their heartbeat.
- **Inline search** filter by host name, stack label, or container name; results update instantly in the grid and side drawer.
- **Container drawers** show CPU/memory charts, restart counters, last exit codes, mounted ports, and environment labels at a glance.
- **Time-since heartbeat** every host entry shows the last heartbeat timestamp so you can spot telemetry gaps quickly.
If a host remains offline, review [Troubleshooting → Docker Agent Shows Hosts Offline](TROUBLESHOOTING.md#docker-agent-shows-hosts-offline).
## Network Discovery
**New in v4.5.0+**: Pulse automatically scans common home/office networks when running in Docker!
### How It Works
1. Detects Docker environment automatically
2. Scans multiple common subnets in parallel:
- 192.168.1.0/24 (most routers)
- 192.168.0.0/24 (very common)
- 10.0.0.0/24 (some setups)
- 192.168.88.0/24 (MikroTik)
- 172.16.0.0/24 (enterprise)
**Result**: Finds all Proxmox nodes without any configuration!
### Custom Networks (Rarely Needed)
Only for non-standard subnets:
```yaml
environment:
DISCOVERY_SUBNET: "192.168.50.0/24" # Only if using unusual subnet
```
## Common Issues
### Can't Access After Upgrade
If upgrading from pre-v4.5.0:
1. You'll see the security setup wizard
2. Complete the setup - your nodes are preserved
3. Use your new credentials to login
### Lost Credentials
If you've lost your credentials:
```bash
# Stop container
docker stop pulse
# Remove auth configuration
docker exec pulse rm /data/.env
# Restart and go through setup again
docker restart pulse
```
### Setup Wizard Not Showing
This happens if you have auth environment variables set:
1. Remove environment variables from docker-compose.yml
2. Recreate the container
3. Access the UI to see the setup wizard
### Password Hash Issues
Common problems:
- **Hash truncated**: Must be exactly 60 characters
- **Not escaped in docker-compose**: Use `$$` instead of `$`
- **Wrong format**: Must start with `$2a$`, `$2b$`, or `$2y$`
## Security Best Practices
1. **Always use HTTPS in production** - Use a reverse proxy (nginx, Traefik, Caddy)
2. **Strong passwords** - Use the generated password or 16+ characters
3. **Protect API tokens** - Treat them like passwords
4. **Regular backups** - Backup the `/data` volume regularly
5. **Network isolation** - Don't expose port 7655 directly to the internet
## Environment Variables Reference
> **⚠️ Important**: Environment variables always override UI/system.json settings. If you set a value via env var (e.g., `DISCOVERY_SUBNET`), changes made in the UI for that setting will NOT take effect until you remove the env var. This follows standard container practices where env vars have highest precedence.
### Authentication
| Variable | Description | Example / Default |
|----------|-------------|-------------------|
| `PULSE_AUTH_USER` | Admin username | `admin` |
| `PULSE_AUTH_PASS` | Admin password (plain text auto-hashed or bcrypt hash) | `super-secret-password` or `$2a$12$...` |
| `API_TOKEN` | API access token (plain text or SHA3-256 hash) | `openssl rand -hex 24` |
| `DISABLE_AUTH` | Disable authentication entirely | `false` |
| `PULSE_AUDIT_LOG` | Enable security audit logging | `false` |
### Network
| Variable | Description | Default |
|----------|-------------|---------|
| `FRONTEND_PORT` | Port exposed for the UI inside the container | `7655` |
| `BACKEND_PORT` | API port (same as UI for the all-in-one container) | `7655` |
| `BACKEND_HOST` | Bind address for the backend | `0.0.0.0` |
| `PULSE_PUBLIC_URL` | External URL used in notifications/webhooks | *(unset)* |
| `ALLOWED_ORIGINS` | Additional CORS origins (comma separated) | Same-origin only |
| `DISCOVERY_SUBNET` | Override automatic network discovery CIDR | Auto-scans common networks |
| `CONNECTION_TIMEOUT` | Proxmox/PBS API timeout (seconds) | `10` |
| `PORT` | Legacy alias for `FRONTEND_PORT` | `7655` |
### System
| Variable | Description | Default |
|----------|-------------|---------|
| `TZ` | Timezone inside the container | `UTC` |
| `LOG_LEVEL` | Logging verbosity | `info` |
| `METRICS_RETENTION_DAYS` | Days of metrics history to keep | `7` |
## Advanced Configuration
### Custom Network
```yaml
services:
pulse:
image: rcourtman/pulse:latest
networks:
- monitoring
# ... rest of config
networks:
monitoring:
driver: bridge
```
### Resource Limits
```yaml
services:
pulse:
image: rcourtman/pulse:latest
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
# ... rest of config
```
### Health Check
```yaml
services:
pulse:
image: rcourtman/pulse:latest
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7655/api/health"]
interval: 30s
timeout: 10s
retries: 3
# ... rest of config
```

148
docs/DOCKER_MONITORING.md Normal file
View File

@@ -0,0 +1,148 @@
# Docker Monitoring Agent
Pulse is focused on Proxmox VE and PBS, but many homelabs also run application stacks in Docker. The optional Pulse Docker agent turns container health and resource usage into first-class metrics that show up alongside your hypervisor data.
## What the agent reports
Every check interval (30s by default) the agent collects:
- Host metadata (hostname, Docker version, CPU count, total memory, uptime)
- Container status (`running`, `exited`, `paused`) and health probe state
- Restart counters and exit codes
- CPU usage, memory consumption and limits
- Images, port mappings, network addresses, and start times
- Health-check failures, restart-loop windows, and recent exit codes (displayed in the UI under each container drawer)
Data is pushed to Pulse over HTTPS using your existing API token no inbound firewall rules required.
## Prerequisites
- Pulse v4.22.0 or newer with an API token enabled (`Settings → Security`)
- Docker 20.10+ on Linux (the agent uses the Docker Engine API via the local socket)
- Access to the Docker socket (`/var/run/docker.sock`) or a configured `DOCKER_HOST`
- Go 1.24+ if you plan to build the binary from source
## Installation
Grab the `pulse-docker-agent` binary from the release assets (or build it yourself):
```bash
# Build from source
cd /opt/pulse
GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent
```
Copy the binary to your Docker host (e.g. `/usr/local/bin/pulse-docker-agent`) and make it executable.
### Quick install from your Pulse server
Use the bundled installation script (ships with Pulse v4.22.0+) to deploy and manage the agent. Replace the token placeholder with an API token generated in **Settings → Security**.
```bash
curl -fsSL http://pulse.example.com/install-docker-agent.sh \
| sudo bash -s -- --url http://pulse.example.com --token <api-token>
```
Running the one-liner again from another Pulse server (with its own URL/token) will merge that server into the same agent automatically—no extra flags required.
To report to more than one Pulse instance from the same Docker host, repeat the `--target` flag (format: `https://pulse.example.com|<api-token>`) or export `PULSE_TARGETS` before running the script:
```bash
curl -fsSL http://pulse.example.com/install-docker-agent.sh \
| sudo bash -s -- \
--target https://pulse.example.com|<primary-token> \
--target https://pulse-dr.example.com|<dr-token>
```
## Running the agent
The agent needs to know where Pulse lives and which API token to use.
**Single instance:**
```bash
export PULSE_URL="http://pulse.lan:7655"
export PULSE_TOKEN="<your-api-token>"
sudo /usr/local/bin/pulse-docker-agent --interval 30s
```
**Multiple instances (one agent fan-out):**
```bash
export PULSE_TARGETS="https://pulse-primary.lan:7655|<token-primary>;https://pulse-dr.lan:7655|<token-dr>"
sudo /usr/local/bin/pulse-docker-agent --interval 30s
```
You can also repeat `--target https://pulse.example.com|<token>` on the command line instead of using `PULSE_TARGETS`; the agent will broadcast each heartbeat to every configured URL.
The binary reads standard Docker environment variables. If you already use TLS-secured remote sockets set `DOCKER_HOST`, `DOCKER_TLS_VERIFY`, etc. as normal. To skip TLS verification for Pulse (not recommended) add `--insecure` or `PULSE_INSECURE_SKIP_VERIFY=true`.
### Multiple Pulse instances
A single `pulse-docker-agent` process can now serve any number of Pulse backends. Each target entry keeps its own API token and TLS preference, and Pulse de-duplicates reports using the shared agent ID / machine ID. This avoids running duplicate agents on busy Docker hosts.
### Systemd unit example
```ini
[Unit]
Description=Pulse Docker Agent
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
Environment=PULSE_URL=https://pulse.example.com
Environment=PULSE_TOKEN=replace-me
Environment=PULSE_TARGETS=https://pulse.example.com|replace-me;https://pulse-dr.example.com|replace-me-dr
ExecStart=/usr/local/bin/pulse-docker-agent --interval 30s
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### Containerised agent (advanced)
If you prefer to run the agent inside a container, mount the Docker socket and supply the same environment variables:
```bash
docker run -d \
--name pulse-docker-agent \
-e PULSE_URL="https://pulse.example.com" \
-e PULSE_TOKEN="<token>" \
-e PULSE_TARGETS="https://pulse.example.com|<token>;https://pulse-dr.example.com|<token-dr>" \
-v /var/run/docker.sock:/var/run/docker.sock \
--restart unless-stopped \
ghcr.io/rcourtman/pulse-docker-agent:latest
```
> **Note**: Official images for `linux/amd64` and `linux/arm64` are published to `ghcr.io/rcourtman/pulse-docker-agent`. To test local changes, run `docker build --target agent_runtime -t pulse-docker-agent:test .` from the repository root.
## Configuration reference
| Flag / Env var | Description | Default |
| ----------------------- | --------------------------------------------------------- | --------------- |
| `--url`, `PULSE_URL` | Pulse base URL (http/https). | `http://localhost:7655` |
| `--token`, `PULSE_TOKEN`| Pulse API token (required). | — |
| `--target`, `PULSE_TARGETS` | One or more `url|token[|insecure]` entries to fan-out reports to multiple Pulse servers. Separate entries with `;` or repeat the flag. | — |
| `--interval`, `PULSE_INTERVAL` | Reporting cadence (supports `30s`, `1m`, etc.). | `30s` |
| `--hostname`, `PULSE_HOSTNAME` | Override host name reported to Pulse. | Docker info / OS hostname |
| `--agent-id`, `PULSE_AGENT_ID` | Stable ID for the agent (useful for clustering). | Docker engine ID / machine-id |
| `--insecure`, `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS cert validation (unsafe). | `false` |
The agent automatically discovers the Docker socket via the usual environment variables. To use SSH tunnels or TCP sockets, export `DOCKER_HOST` as you would for the Docker CLI.
## Testing and troubleshooting
- Run with `--interval 15s --insecure` in a terminal to see log output while testing.
- Ensure the Pulse API token has not expired or been regenerated.
- If `pulse-docker-agent` reports `Cannot connect to the Docker daemon`, verify the socket path and permissions.
- Check Pulse (`/docker` tab) for the latest heartbeat time. Hosts are marked offline if they stop reporting for >4× the configured interval.
- Use the search box above the host grid to filter by host name, stack label, or container name. Restart loops surface in the “Issues” column and display the last five exit codes.
## Removing the agent
Stop the systemd service or container and remove the binary. Pulse retains the last reported state until it ages out after a few minutes of inactivity.

189
docs/FAQ.md Normal file
View File

@@ -0,0 +1,189 @@
# FAQ
## Installation
### What's the easiest way to install?
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
### System requirements?
- 1 vCPU, 512MB RAM (1GB recommended), 1GB disk
- Network access to Proxmox API
## Configuration
### How do I add a node?
**Auto-discovery (Easiest)**: Settings → Nodes → Click "Setup Script" on discovered node → Run on Proxmox
**Manual**: Settings → Nodes → Add Node → Enter credentials → Save
![Node Configuration](images/06-settings.png)
### How do I disable network discovery?
Settings → System → Network Settings → Toggle "Enable Discovery" off → Save
Or set environment variable `DISCOVERY_ENABLED=false`
### How do I change the port?
Systemd: `sudo systemctl edit pulse`, add `Environment="FRONTEND_PORT=8080"`, restart
Docker: Use `-e FRONTEND_PORT=8080 -p 8080:8080` in your run command
See [Port Configuration Guide](PORT_CONFIGURATION.md) for details
### Why can't I change settings in the UI?
If a setting is disabled with an amber warning, it's being overridden by an environment variable.
Remove the env var (check `sudo systemctl show pulse | grep Environment`) and restart to enable UI configuration.
### What permissions needed?
- PVE: `PVEAuditor` minimum (includes VM.GuestAgent.Audit for disk usage in PVE 9+)
- PVE 8: Also needs `VM.Monitor` permission for VM disk usage via QEMU agent
- PBS: `DatastoreReader` minimum
### API tokens vs passwords?
API tokens are more secure. Create in Proxmox: Datacenter → Permissions → API Tokens
### Where are settings stored?
See [Configuration Guide](CONFIGURATION.md) for details
### How do I backup my configuration?
Settings → Security → Backup & Restore → Export Backup
- If logged in with password: Just enter your password or a custom passphrase
- If using API token only: Provide the API token when prompted
- Includes all settings, nodes, credentials (encrypted), and custom console URLs
### Can I filter backup history or focus on a specific time window?
Yes. The **Backups** workspace exposes a time-range picker above the chart (Last 24h / 7d / 30d / Custom). Selecting a range reflows the chart, highlights matching bars, and filters the grid below. Hovering the chart shows tooltips with the top jobs inside that window so you can jump directly to a backup task or snapshot.
Trouble with the picker? See [Troubleshooting → Backup View Filters Not Working](TROUBLESHOOTING.md#backup-view-filters-not-working).
### Can Pulse detect Proxmox clusters?
Yes! When you add one cluster node, Pulse automatically discovers and monitors all nodes
## Troubleshooting
### No data showing?
- Check Proxmox API is reachable (port 8006/8007)
- Verify credentials
- Check logs: `journalctl -u pulse -f`
### Connection refused?
- Check port 7655 is open
- Verify Pulse is running: `systemctl status pulse`
### PBS connection issues?
- PBS requires HTTPS (not HTTP) - use `https://your-pbs:8007`
- Default PBS port is 8007 (not 8006)
- Check firewall allows port 8007
### Invalid credentials?
- Check username includes realm (@pam, @pve)
- Verify API token not expired
- Confirm user has required permissions
### CORS errors in browser?
- By default, Pulse only allows same-origin requests
- Set `ALLOWED_ORIGINS` environment variable for cross-origin access
- Example: `ALLOWED_ORIGINS=https://app.example.com`
- Never use `*` in production
### Authentication issues?
- Password auth: Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
- API token: Verify `API_TOKEN` is set correctly
- Session expired: Log in again via web UI
- Account locked: Wait 15 minutes after 5 failed attempts
### High memory usage?
Reduce `metricsRetentionDays` in settings and restart
## Features
### Why do VMs show "-" for disk usage?
VMs show "-" because the QEMU Guest Agent is not installed or not working. This is normal and expected.
**How VM disk monitoring works:**
- Proxmox API always returns `disk=0` for VMs (this is normal, not a bug)
- To get real disk usage, Pulse queries the QEMU Guest Agent inside each VM
- Both API tokens and passwords work fine for this (no authentication method limitation)
- If guest agent is missing or not responding, Pulse shows "-" with a tooltip explaining why
**To get VM disk usage showing:**
1. **Install QEMU Guest Agent in the VM:**
- Linux: `apt install qemu-guest-agent && systemctl enable --now qemu-guest-agent`
- Windows: Install virtio-win guest tools
2. **Enable in VM config:**
- Proxmox UI: VM → Options → QEMU Guest Agent → Enable
- Or CLI: `qm set <VMID> --agent enabled=1`
3. **Restart the VM** for changes to take effect
4. **Verify it works:**
```bash
qm agent <VMID> ping
qm agent <VMID> get-fsinfo
```
5. **Check Pulse has permissions:**
- Proxmox 9: `PVEAuditor` role (includes `VM.GuestAgent.Audit`)
- Proxmox 8: `VM.Monitor` permission
- The setup script adds these automatically
**Note:** Container (LXC) disk usage always works without guest agent because containers share the host kernel.
**Still not working?** See [Troubleshooting Guide - VM Disk Monitoring](TROUBLESHOOTING.md#vm-disk-monitoring-issues) for detailed diagnostics.
### How do I see real disk usage for VMs?
See the previous question "Why do VMs show '-' for disk usage?" or the [VM Disk Monitoring Guide](VM_DISK_MONITORING.md) for full details.
### Multiple clusters?
Yes, add multiple nodes in Settings
### PBS push mode?
No, PBS push mode is not currently supported. PBS monitoring requires network connectivity from Pulse to the PBS server.
### Webhook providers?
Discord, Slack, Gotify, Telegram, ntfy.sh, Teams, generic JSON
### Works with reverse proxy?
Yes, ensure WebSocket support is enabled
### How do I disable alerts for specific metrics?
Go to **Alerts → Thresholds**, then set any threshold to `-1` to disable alerts for that metric.
**Examples:**
- Don't care about disk I/O alerts? Set "Disk R MB/s" and "Disk W MB/s" to `-1`
- Want to ignore network alerts on a specific VM? Set "Net In MB/s" and "Net Out MB/s" to `-1`
- Need to disable CPU alerts for a maintenance node? Set "CPU %" to `-1`
**To re-enable:** Click on any disabled threshold showing "Off" and it will restore to a default value. The trash icon beside **Global Defaults** resets that row instantly, and the search bar at the top of the tab filters resources live.
**Per-resource customization:** You can disable metrics globally (affects all resources) or individually (just one VM, container, node, etc.). Resources with custom settings show a blue "Custom" badge so you can spot overrides quickly.
### Can I set fractional thresholds or specify different trigger/clear values?
Yes. Pulse stores hysteresis thresholds in pairs: `trigger` (when to fire) and `clear` (when to recover). Both values accept decimal precision for example, set network thresholds to `12.5` / `9.5` MB/s. The UI shows the trigger value in the table and reveals the clear threshold in the sidebar drawer.
### How do I interpret the alert timeline graph?
Open **Alerts → History** and click an entry. The right-hand panel now shows a context timeline that plots alert start, acknowledgement, clearance, and any escalations so you can see at a glance how long the condition lasted and when notifications were sent. Hovering each marker reveals the exact timestamp and value Pulse captured at that step.
### Does Pulse monitor Ceph clusters?
Yes. When Ceph-backed storage (RBD or CephFS) is detected, Pulse queries `/cluster/ceph/status` and `/cluster/ceph/df` and surfaces the results on the **Storage → Ceph** drawer and via `/api/state` → `cephClusters`. You get cluster health, daemon counts, placement groups, and per-pool capacity without any additional configuration.
If those sections stay empty, follow [Troubleshooting → Ceph Cluster Data Missing](TROUBLESHOOTING.md#ceph-cluster-data-missing).
### Why does a Docker host show as offline in the Docker tab?
First, confirm the agent is still running (`systemctl status pulse-docker-agent` or `docker ps`). If it is, check the Issues column for restart-loop notes and verify the hosts last heartbeat under **Details**. Still stuck? Walk through [Troubleshooting → Docker Agent Shows Hosts Offline](TROUBLESHOOTING.md#docker-agent-shows-hosts-offline) for a step-by-step checklist.
## Updates
### How to update?
- **Docker**: Pull latest image, recreate container
- **Manual/systemd**: Run the install script again: `curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash`
### How do I install an older release (downgrade)?
- **Manual/systemd installs**: rerun the installer and pass the tag you want, e.g. `curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version v4.20.0`
- **Proxmox LXC appliance**: `pct exec <ctid> -- bash -lc "curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version v4.20.0"`
- **Docker**: launch with a versioned tag instead of `latest`, e.g. `docker run -d --name pulse -p 7655:7655 rcourtman/pulse:v4.20.0`
### Why can't I update from the UI?
For security reasons, Pulse cannot self-update. The UI will notify you when updates are available and show the appropriate update command for your deployment type.
### Will updates break config?
No, configuration is preserved

180
docs/INSTALL.md Normal file
View File

@@ -0,0 +1,180 @@
# Installation Guide
## Quick Install
The official installer automatically detects your environment and chooses the best installation method:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
The installer will prompt you for the port (default: 7655). To skip the prompt, set the environment variable:
```bash
FRONTEND_PORT=8080 curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
## Installation Methods
### Proxmox VE Hosts
When run on a Proxmox VE host, the installer automatically:
1. Creates a lightweight LXC container
2. Installs Pulse inside the container
3. Configures networking and security
**Quick Mode** (recommended):
- 1GB RAM, 4GB disk, 2 CPU cores
- Unprivileged container with firewall
- Auto-starts with your host
- Takes about 1 minute
**Advanced Mode**:
- Customize all container settings
- Choose specific network bridges and storage
- Configure static IP if needed
- Set custom port (default: 7655)
### Standard Linux Systems
On Debian/Ubuntu systems, the installer:
1. Installs required dependencies
2. Downloads the latest Pulse binary
3. Creates a systemd service
4. Starts Pulse automatically
### Docker
For containerized deployments:
```bash
docker run -d -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
```
See [Docker Guide](DOCKER.md) for advanced options.
## Updating
### Automatic Updates (Recommended)
Pulse can automatically install stable updates to ensure you're always running the latest secure version:
#### Enable During Installation
```bash
# Interactive prompt during fresh install
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
# Or force enable with flag
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --enable-auto-updates
```
#### Enable/Disable After Installation
```bash
# Via systemctl
systemctl enable --now pulse-update.timer # Enable auto-updates
systemctl disable --now pulse-update.timer # Disable auto-updates
systemctl status pulse-update.timer # Check status
# Via Settings UI
# Navigate to Settings → System → Enable "Automatic Updates"
```
#### How It Works
- Checks daily between 2-6 AM (randomized to avoid server load)
- Only installs stable releases (never release candidates)
- Creates backup before updating
- Automatically rolls back if update fails
- Logs all activity to systemd journal
#### View Update Logs
```bash
journalctl -u pulse-update # View all update logs
journalctl -u pulse-update -f # Follow logs in real-time
systemctl list-timers pulse-update # See next scheduled check
```
### Manual Updates
#### For LXC Containers
```bash
pct exec <container-id> -- update
```
#### For Standard Installations
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
#### For Docker
```bash
docker pull rcourtman/pulse:latest
docker stop pulse
docker rm pulse
docker run -d --name pulse -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
```
## Version Management
### Install Specific Version
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --version v4.8.0
```
### Install Release Candidate
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --rc
```
### Install from Source (Testing)
Build and install directly from the main branch to test the latest fixes before they're released:
```bash
# Install from main branch (latest development code)
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --main
# Install from a specific branch
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --source develop
```
**Note:** This builds Pulse from source code on your machine. Requires Go, Node.js, and npm.
## Troubleshooting
### Permission Denied
If you encounter permission errors, you may need to run with `sudo` on some systems, though most installations (including LXC containers) run as root and don't need it.
### Container Creation Failed
Ensure you have:
- Available container IDs (check with `pct list`)
- Sufficient storage space
- Network bridge configured
### Port Already in Use
Pulse uses port 7655 by default. You can change it during installation or check current usage with:
```bash
sudo netstat -tlnp | grep 7655
```
To use a different port during installation:
```bash
FRONTEND_PORT=8080 curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
## Uninstalling
### From LXC Container
```bash
pct stop <container-id>
pct destroy <container-id>
```
### From Standard System
```bash
sudo systemctl stop pulse
sudo systemctl disable pulse
sudo rm -rf /opt/pulse /etc/pulse
sudo rm /etc/systemd/system/pulse.service
```
### Docker
```bash
docker stop pulse
docker rm pulse
docker volume rm pulse_data # Warning: deletes all data
```

102
docs/MIGRATION.md Normal file
View File

@@ -0,0 +1,102 @@
# Migrating Pulse
## Quick Migration Guide
### ❌ DON'T: Copy files directly
Never copy `/etc/pulse` or `/var/lib/pulse` directories between systems:
- The encryption key is tied to the files
- Credentials may be exposed
- Configuration may not work on different systems
### ✅ DO: Use Export/Import
#### Exporting (Old Server)
1. Open Pulse web interface
2. Go to **Settings****Configuration Management**
3. Click **Export Configuration**
4. Enter a strong passphrase (you'll need this for import!)
5. Save the downloaded file securely
#### Importing (New Server)
1. Install fresh Pulse instance
2. Open Pulse web interface
3. Go to **Settings****Configuration Management**
4. Click **Import Configuration**
5. Select your exported file
6. Enter the same passphrase
7. Click Import
## What Gets Migrated
**Included:**
- All PVE/PBS nodes and credentials
- Alert settings and thresholds
- Email configuration
- Webhook configurations
- System settings
- Guest metadata (custom URLs, notes)
**Not Included:**
- Historical metrics data
- Alert history
- Authentication settings (passwords, API tokens)
- Each instance should configure its own authentication
## Common Scenarios
### Moving to New Hardware
1. Export from old server
2. Shut down old Pulse instance
3. Install Pulse on new hardware
4. Import configuration
5. Verify all nodes are connected
### Docker to Systemd (or vice versa)
The export/import process works across all installation methods:
- Docker → Systemd ✅
- Systemd → Docker ✅
- Docker → LXC ✅
### Backup Strategy
**Weekly Backups:**
1. Export configuration weekly
2. Store exports with date: `pulse-backup-2024-01-15.enc`
3. Keep last 4 backups
4. Store passphrase securely (password manager)
### Disaster Recovery
1. Install Pulse: `curl -sL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash`
2. Import latest backup
3. System restored in under 5 minutes!
## Security Notes
- **Passphrase Protection**: Exports are encrypted with PBKDF2 (100,000 iterations)
- **Safe to Store**: Encrypted exports can be stored in cloud backups
- **Minimum 12 characters**: Use a strong passphrase
- **Password Manager**: Store your passphrase securely
## Troubleshooting
**"Invalid passphrase" error**
- Ensure you're using the exact same passphrase
- Check for extra spaces or capitalization
**Missing nodes after import**
- Verify the export was taken after adding the nodes
- Check Settings to ensure nodes are listed
**Connection errors after import**
- Node IPs may have changed
- Update node addresses in Settings
## Pro Tips
1. **Test imports**: Try importing on a test instance first
2. **Document changes**: Note any manual configs not in Pulse
3. **Version matching**: Best to import into same or newer Pulse version
4. **Network access**: Ensure new server can reach all nodes
---
*Remember: Export/Import is the ONLY supported migration method. Direct file copying is not supported and may result in data loss.*

160
docs/OIDC.md Normal file
View File

@@ -0,0 +1,160 @@
# OpenID Connect (OIDC) Single Sign-On
Pulse ships with first-class OIDC support so you can authenticate through identity providers such as Authentik, Keycloak, Okta, Azure AD, and others.
## Requirements
- Pulse v4.16.0 or later
- A reachable Pulse public URL (used for redirect callbacks)
- An OIDC client registration on your IdP with the redirect URI:
```
https://<pulse-host>/api/oidc/callback
```
- Scopes that include at least `openid`
## Quick Start
1. Open **Settings → Security → Single sign-on (OIDC)**.
2. Toggle **Enable** and fill in the following fields:
- **Issuer URL** Base issuer endpoint from your IdP metadata document.
- **Client ID** and **Client secret** From your IdP application registration.
- **Redirect URL** Optional. Pulse auto-populates this based on the public URL; override only when necessary.
3. Use the **Advanced options** section to customise scopes, claim names, or access restrictions by group, domain, or email.
4. Click **Save changes**. After a successful save the OIDC login button appears on the Pulse sign-in page.
### Using the bundled mock IdP
For local end-to-end testing Pulse ships with a Dex-based mock server configuration. With Docker running:
```bash
./scripts/dev/start-oidc-mock.sh
```
This exposes an issuer at `http://127.0.0.1:5556/dex` with:
- Client ID: `pulse-dev`
- Client secret: `pulse-secret`
- Redirect URIs: `http://127.0.0.1:5173/api/oidc/callback`, `http://127.0.0.1:7655/api/oidc/callback`
- Test user: `admin@example.com` / `password`
Point the OIDC settings screen at that issuer, save, and use the SSO button to exercise the full login flow.
## Classic password login stays
OIDC is optional. Pulse continues to ship with the familiar username/password flow:
- First-run setup still prompts you to create an admin credential or you can pre-seed it via `PULSE_AUTH_USER` / `PULSE_AUTH_PASS`.
- If OIDC is **enabled**, the login page shows both the password form and the **Continue with Single Sign-On** button. Either path issues the same session cookie (`pulse_session`).
- To run **password-only**, leave OIDC disabled (the default). To go **OIDC-only**, set `DISABLE_AUTH=true` after you confirm SSO works.
- The `allowedGroups`, `allowedDomains`, and `allowedEmails` settings only affect OIDC logins; password authentication continues to honour the account you created locally.
## Provider Cheat-Sheet
You do not need to ship per-provider templates. Pulse speaks standard OIDC, so administrators bring their own identity provider and supply the issuer URL, client ID, and client secret they created for Pulse. Below are the high-level steps we tested against three common providers—share these with users who ask “what do I enter?”
### Authentik
1. In Authentik, create a new **Provider** of type **OAuth2/OpenID**.
- **Name**: Pulse
- **Client type**: Confidential
- **Redirect URIs**: `https://pulse.example.com/api/oidc/callback` (replace with your Pulse URL)
- **Scopes**: Include `openid`, `profile`, and `email`
- Note the generated **Client ID** and **Client Secret**
2. Create an **Application** and link it to the provider you just created.
3. In Pulse, configure OIDC with:
- **Issuer URL**: `https://auth.example.com/application/o/pulse/` (the full path to your application)
- **Client ID**: The client ID from your Authentik provider
- **Client Secret**: The client secret from your Authentik provider
- **Scopes**: `openid profile email`
4. In Authentik, open **Applications → [your Pulse app] → Advanced** and set a **Signing Key** that advertises the `RS256` algorithm (generate or assign an RSA key). Authentik defaults to `HS256` when no signing key is configured, which Pulse rejects with the error `unexpected signature algorithm "HS256"; expected ["RS256"]`.
5. For group-based access control:
- Set **Groups claim** to `groups` (Authentik's default)
- Add your allowed group names to **Allowed groups** (e.g., `admin`)
**Important notes**:
- If you see "invalid_id_token" errors, the issuer URL might not match what Authentik puts in tokens. Check your Pulse logs with `LOG_LEVEL=debug` to see the exact error. The issuer claim in the token must match your configured `OIDC_ISSUER_URL` exactly.
- When using OIDC behind a reverse proxy with HTTPS, ensure `PUBLIC_URL` or `OIDC_REDIRECT_URL` uses `https://` (not `http://`). If these are not set, Pulse will auto-detect the protocol from `X-Forwarded-Proto` headers.
### Dex / other self-hosted issuers
This matches the bundled Dex mock server:
1. Create a new OAuth 2.0 / OIDC application, mark it *confidential*, and note the generated `client_id` and `client_secret`.
2. Add every Pulse hostname you expose (for example `https://pulse.example.com/api/oidc/callback`) to the list of redirect URIs.
3. Ensure the application scopes include at least `openid profile email`.
4. Paste the issuer URL (for Dex that is `https://<issuer-host>/dex`) plus the client credentials into Pulse.
### Azure Active Directory
1. Register a **Web** app in Azure AD and capture the **Application (client) ID**.
2. Create a **Client secret** under *Certificates & secrets*.
3. Add a redirect URL `https://<pulse-host>/api/oidc/callback` (type **Web**).
4. Under *Token configuration*, add optional claims for `email` and `preferred_username`. If you plan to restrict by groups enable the *Groups* claim.
5. In Pulse, use `https://login.microsoftonline.com/<tenant-id>/v2.0` as the issuer and paste the client credentials.
### Okta
1. Create an **OIDC Web App** integration.
2. Trusted redirect URIs: `https://<pulse-host>/api/oidc/callback`.
3. Assign the integration to the users or groups who need access.
4. Copy the **Client ID**, **Client secret**, and **Okta domain**. Use `https://<your-okta-domain>/oauth2/default` as the issuer within Pulse.
### Group and domain restrictions
- Set `OIDC_GROUPS_CLAIM` to the claim that carries group names (default `groups`).
- Combine `allowedGroups`, `allowedDomains`, or `allowedEmails` in the UI to fence access without editing your IdP.
- Azure AD group names appear as GUIDs unless you enable *Security groups* in token configuration; Okta and Authentik emit the literal group name.
## Environment Overrides
All configuration can be provided via environment variables (see [`docs/CONFIGURATION.md`](./CONFIGURATION.md#oidc-variables-optional-overrides)). When any `OIDC_*` variable is present the UI is placed in read-only mode and values must be changed from the deployment configuration instead.
## Login Flow
- The login screen shows a **Continue with Single Sign-On** button when OIDC is enabled.
- Users are redirected to the configured issuer for authentication and returned to `/api/oidc/callback`.
- Pulse validates the ID token, enforces optional group/domain/email restrictions, then creates the usual session cookie (`pulse_session`).
- Existing username/password login remains available unless explicitly disabled in the environment.
## Troubleshooting
| Symptom | Resolution |
| --- | --- |
| `invalid_id_token` error | The issuer URL configured in Pulse doesn't match the `iss` claim in the ID token from your provider. Enable `LOG_LEVEL=debug` to see the exact verification error. For Authentik, try both `https://auth.domain.com` (base URL) and `https://auth.domain.com/application/o/pulse/` (application URL) to see which matches your provider's token issuer. |
| `unexpected signature algorithm "HS256"; expected ["RS256"]` in logs | Authentik falls back to HS256 if no signing key is configured. Assign an RSA signing key to the application (token settings → Signing key) so ID tokens are issued with RS256. |
| Redirect loops back to login | After successful OIDC login, if you're redirected back to the login page, check that: (1) cookies are enabled in your browser, (2) if behind a proxy, ensure `X-Forwarded-Proto` header is set correctly, (3) check browser console for cookie errors. |
| Users see `single sign-on failed` | Check `journalctl -u pulse.service` for detailed OIDC audit logs. Common causes include mismatched client IDs, incorrect redirect URLs, or group/domain restrictions. |
| UI shows "OIDC settings are managed by environment variables" | Remove the relevant `OIDC_*` environment variables or update them directly in your deployment. |
| Provider discovery fails | Verify the issuer URL is reachable from the Pulse server and returns valid OIDC discovery metadata at `/.well-known/openid-configuration`. |
| Group restrictions not working | Enable debug logging to see which groups the IdP is sending and verify the `groups_claim` setting matches your IdP's claim name. |
| Auto-redirect to OIDC when password auth still enabled | This is expected behavior when OIDC is enabled. Users can still use password auth by clicking "Use your admin credentials to sign in below" on the login page. To disable auto-redirect, comment out the auto-redirect code in the frontend. |
### Debug Logging
For detailed troubleshooting, set `LOG_LEVEL=debug` in your deployment and restart Pulse. Debug logs include:
- OIDC provider initialization (issuer URL, endpoints discovered)
- Authorization flow start (client ID, scopes requested)
- Token exchange details (success/failure with specific errors)
- ID token verification (subject extracted)
- Claims extraction (username, email, groups found)
- Access control checks (which emails/domains/groups were checked and why they passed or failed)
Example debug log output:
```
DBG Initializing OIDC provider issuer=https://auth.example.com redirect_url=https://pulse.example.com/api/oidc/callback scopes=[openid,profile,email]
DBG OIDC provider discovery successful issuer=https://auth.example.com auth_endpoint=https://auth.example.com/authorize token_endpoint=https://auth.example.com/token
DBG Starting OIDC login flow issuer=https://auth.example.com client_id=pulse-client
DBG Processing OIDC callback issuer=https://auth.example.com
DBG OIDC code exchange successful
DBG ID token verified successfully subject=user@example.com
DBG Extracted user identity from claims username=user@example.com email=user@example.com email_claim=email username_claim=preferred_username
DBG Checking group membership user_groups=[admins,users] allowed_groups=[admins] groups_claim=groups
DBG User group membership verified
```
After reviewing the logs, set `LOG_LEVEL=info` to reduce log volume.

View File

@@ -0,0 +1,81 @@
# Port Configuration Guide
Pulse supports multiple ways to configure the frontend port (default: 7655).
> **Development tip:** The hot-reload scripts (`scripts/dev-hot.sh`, `scripts/hot-dev.sh`, and `make dev-hot`) load `.env`, `.env.local`, and `.env.dev`. Set `FRONTEND_PORT` or `PULSE_DEV_API_PORT` there to run the backend on a different port while keeping the generated `curl` commands and Vite proxy in sync.
## Recommended Methods
### 1. During Installation (Easiest)
The installer prompts for the port. To skip the prompt, use:
```bash
FRONTEND_PORT=8080 curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash
```
### 2. Using systemd override (For existing installations)
```bash
sudo systemctl edit pulse
```
Add these lines:
```ini
[Service]
Environment="FRONTEND_PORT=8080"
```
Then restart: `sudo systemctl restart pulse`
### 3. Using system.json (Alternative method)
Edit `/etc/pulse/system.json`:
```json
{
"frontendPort": 8080
}
```
Then restart: `sudo systemctl restart pulse`
### 4. Using environment variables (Docker)
For Docker deployments:
```bash
docker run -e FRONTEND_PORT=8080 -p 8080:8080 rcourtman/pulse:latest
```
## Priority Order
Pulse checks for port configuration in this order:
1. `FRONTEND_PORT` environment variable
2. `PORT` environment variable (legacy)
3. `frontendPort` in system.json
4. Default: 7655
Environment variables always override configuration files.
## Why not .env?
The `/etc/pulse/.env` file is reserved exclusively for authentication credentials:
- `API_TOKEN` - API authentication token (hashed)
- `PULSE_AUTH_USER` - Web UI username
- `PULSE_AUTH_PASS` - Web UI password (hashed)
Keeping application configuration separate from authentication credentials:
- Makes it clear what's a secret vs what's configuration
- Allows different permission models if needed
- Follows the principle of separation of concerns
- Makes it easier to backup/share configs without exposing credentials
## Troubleshooting
### Port not changing after configuration?
1. Check which service name is in use:
```bash
systemctl list-units | grep pulse
```
It might be `pulse` or `pulse-backend` depending on your installation method.
2. Verify the configuration is loaded:
```bash
sudo systemctl show pulse | grep Environment
```
3. Check if another process is using the port:
```bash
sudo lsof -i :8080
```

251
docs/PROXY_AUTH.md Normal file
View File

@@ -0,0 +1,251 @@
# Proxy Authentication
Pulse supports proxy-based authentication for integration with SSO providers like Authentik, Authelia, Caddy, and others. This allows you to authenticate users via your existing reverse proxy authentication system while maintaining security.
> **When to use this**: If you already have an authentication proxy (Authentik, Authelia, etc.) protecting your services and want Pulse to trust that authentication instead of requiring its own login.
## Quick Start
1. Set `PROXY_AUTH_SECRET` to a random secret string
2. Configure your proxy to send this secret in the `X-Proxy-Secret` header
3. Set `PROXY_AUTH_USER_HEADER` to match your proxy's username header
4. (Optional) Configure role-based access control with `PROXY_AUTH_ROLE_HEADER`
## Configuration
Set the following environment variables to enable proxy authentication:
### Required Settings
```bash
# Shared secret between proxy and Pulse (required)
PROXY_AUTH_SECRET=your-secure-secret-here
# Header containing the authenticated username (optional but recommended)
PROXY_AUTH_USER_HEADER=X-Authentik-Username
```
### Optional Settings
```bash
# Header containing user roles/groups
PROXY_AUTH_ROLE_HEADER=X-Authentik-Groups
# Separator for multiple roles (default: |)
PROXY_AUTH_ROLE_SEPARATOR=|
# Role name that grants admin access (default: admin)
PROXY_AUTH_ADMIN_ROLE=admin
# URL to redirect users to for logout
PROXY_AUTH_LOGOUT_URL=/outpost.goauthentik.io/sign_out
```
## How It Works
1. **User visits Pulse** → Your proxy intercepts the request
2. **Proxy authenticates user** → Via its own login page/SSO
3. **Proxy adds headers** to the request:
- `X-Proxy-Secret`: Shared secret (prevents spoofing)
- Username header (e.g., `X-Authentik-Username`)
- Roles header (e.g., `X-Authentik-Groups`)
4. **Pulse validates** the secret and trusts the user identity
5. **No Pulse login required** → User sees the dashboard immediately
## Example Configurations
### Authentik with Traefik
```yaml
# docker-compose.yml environment variables
environment:
- PROXY_AUTH_SECRET=your-secure-secret-here
- PROXY_AUTH_USER_HEADER=X-Authentik-Username
- PROXY_AUTH_ROLE_HEADER=X-Authentik-Groups
- PROXY_AUTH_ROLE_SEPARATOR=|
- PROXY_AUTH_ADMIN_ROLE=admin
- PROXY_AUTH_LOGOUT_URL=/outpost.goauthentik.io/sign_out
```
Traefik middleware configuration:
```yaml
http:
middlewares:
proxy-header-secret:
headers:
customRequestHeaders:
X-Proxy-Secret: "your-secure-secret-here"
authentik-auth:
forwardAuth:
address: http://authentik:9000/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- X-Authentik-Username
- X-Authentik-Groups
- X-Authentik-Email
routers:
pulse:
rule: Host(`pulse.example.com`)
entryPoints:
- websecure
middlewares:
- authentik-auth
- proxy-header-secret
service: pulse-service
pulse-auth:
rule: Host(`pulse.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)
entryPoints:
- websecure
service: authentik-outpost
```
### Authelia Example
```yaml
# docker-compose.yml environment variables
environment:
- PROXY_AUTH_SECRET=your-secure-secret-here
- PROXY_AUTH_USER_HEADER=Remote-User
- PROXY_AUTH_ROLE_HEADER=Remote-Groups
- PROXY_AUTH_ROLE_SEPARATOR=,
- PROXY_AUTH_ADMIN_ROLE=admins
- PROXY_AUTH_LOGOUT_URL=/logout
```
Nginx configuration:
```nginx
location / {
# Authelia authorization
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
# Pass headers to Pulse
proxy_set_header X-Proxy-Secret "your-secure-secret-here";
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_pass http://pulse:7655;
}
```
### Caddy with Forward Auth
```caddyfile
pulse.example.com {
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Groups
}
header_downstream X-Proxy-Secret "your-secure-secret-here"
reverse_proxy pulse:7655
}
```
### Nginx Proxy Manager
In NPM's Advanced tab for your Pulse proxy host:
```nginx
# Custom Nginx Configuration
proxy_set_header X-Proxy-Secret "your-secure-secret-here";
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Authentik-Groups $http_x_authentik_groups;
```
## Security Considerations
1. **Use a strong secret**: Generate a secure random string for `PROXY_AUTH_SECRET`
2. **HTTPS only**: Always use HTTPS between the proxy and Pulse in production
3. **Network isolation**: Ensure Pulse is not directly accessible, only through the proxy
4. **Header validation**: Pulse validates all headers and the proxy secret on every request
## Combining with Other Auth Methods
Proxy authentication can work alongside other authentication methods:
- If `PROXY_AUTH_SECRET` is set, proxy auth takes precedence
- API tokens (`API_TOKEN`) still work for programmatic access
- Basic auth (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS`) can be used as fallback
## Troubleshooting
### Users can't access Pulse (401 Unauthorized)
1. **Check the secret header**:
```bash
# Test with curl
curl -H "X-Proxy-Secret: your-secret" \
-H "X-Authentik-Username: testuser" \
http://pulse:7655/api/state
```
2. **Verify headers are being sent**:
- Enable debug logging: `LOG_LEVEL=debug`
- Check Pulse logs: `docker logs pulse` or `journalctl -u pulse`
- Look for "Invalid proxy secret" or "Proxy auth user header not found"
3. **Common issues**:
- Typo in `PROXY_AUTH_SECRET`
- Header names are case-sensitive in configuration
- Proxy not forwarding headers correctly
### Admin features not available
Check if user is recognized as admin:
```bash
curl -H "X-Proxy-Secret: your-secret" \
-H "X-Authentik-Username: admin" \
-H "X-Authentik-Groups: users|admin" \
http://pulse:7655/api/security/status | jq '.proxyAuthIsAdmin'
```
- Ensure the roles header contains the admin role
- Verify `PROXY_AUTH_ADMIN_ROLE` matches your configuration
- Check the role separator matches your proxy's format (default: `|`)
### Logout doesn't work
- Verify `PROXY_AUTH_LOGOUT_URL` points to your proxy's logout endpoint
- Ensure the logout URL is accessible from the user's browser
- For Authentik: `/outpost.goauthentik.io/sign_out`
- For Authelia: `/logout` or custom path
### Testing your configuration
Test proxy auth without a reverse proxy:
```bash
# Should return 401
curl http://localhost:7655/api/state
# Should return 200 with state data
curl -H "X-Proxy-Secret: your-secret-here" \
-H "X-Your-User-Header: testuser" \
http://localhost:7655/api/state
```
## FAQ
**Q: Do I still need to set up Pulse authentication?**
A: No, when proxy auth is configured, Pulse trusts your proxy's authentication. Users won't see Pulse's login page.
**Q: Can I use this with Cloudflare Access or Tailscale?**
A: Yes, any service that can add custom headers after authentication will work.
**Q: What happens if someone bypasses my proxy?**
A: They can't authenticate. Without the correct `X-Proxy-Secret` header, all requests are rejected with 401.
**Q: Can I have some users with read-only access?**
A: Currently, Pulse has admin and non-admin roles. Non-admin users have read-only access to monitoring data.
**Q: Is the username displayed in Pulse?**
A: Yes, the authenticated username appears in the top-right corner of the UI.
**Q: Can I use both proxy auth and API tokens?**
A: Yes! API tokens still work for automation/scripts. Proxy auth is for human users via the web UI.

320
docs/REVERSE_PROXY.md Normal file
View File

@@ -0,0 +1,320 @@
# Reverse Proxy Configuration
Pulse uses WebSockets for real-time updates. Your reverse proxy **MUST** support WebSocket connections or Pulse will not work correctly.
## Important Requirements
1. **WebSocket Support Required** - Enable WebSocket proxying
2. **Proxy Headers** - Forward original host and IP headers
3. **Timeouts** - Increase timeouts for long-lived connections
4. **Buffer Sizes** - Increase for large state updates (64KB recommended)
## Authentication with Reverse Proxy
If you're using authentication at the reverse proxy level (Authentik, Authelia, etc.), you can disable Pulse's built-in authentication to avoid double login prompts:
```bash
# In your .env file or environment
DISABLE_AUTH=true
```
When `DISABLE_AUTH=true` is set:
- Pulse's built-in authentication is completely bypassed
- All endpoints become accessible without authentication
- The reverse proxy handles all authentication and authorization
- A warning is logged on startup to confirm auth is disabled
⚠️ **Warning**: Only use `DISABLE_AUTH=true` if your reverse proxy provides authentication. Never expose Pulse directly to the internet with authentication disabled.
## Nginx
```nginx
server {
listen 80;
server_name pulse.example.com;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name pulse.example.com;
# SSL configuration
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Proxy settings
location / {
proxy_pass http://localhost:7655;
proxy_http_version 1.1;
# Required for WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for WebSocket
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# Disable buffering for real-time updates
proxy_buffering off;
# Increase buffer sizes for large messages
proxy_buffer_size 64k;
proxy_buffers 8 64k;
proxy_busy_buffers_size 128k;
}
# API endpoints (optional, same config as above)
location /api/ {
proxy_pass http://localhost:7655/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
}
```
## Caddy v2
Caddy automatically handles WebSocket upgrades when reverse proxying.
```caddy
pulse.example.com {
reverse_proxy localhost:7655
}
```
For more control:
```caddy
pulse.example.com {
reverse_proxy localhost:7655 {
# Headers automatically handled by Caddy
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
# Increase timeouts for WebSocket
transport http {
dial_timeout 30s
response_header_timeout 30s
read_timeout 0
}
}
}
```
## Apache
```apache
<VirtualHost *:443>
ServerName pulse.example.com
SSLEngine on
SSLCertificateFile /path/to/cert.pem
SSLCertificateKeyFile /path/to/key.pem
# Enable necessary modules:
# a2enmod proxy proxy_http proxy_wstunnel headers
# WebSocket proxy
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
# Regular HTTP proxy
ProxyPass / http://localhost:7655/
ProxyPassReverse / http://localhost:7655/
# Preserve host headers
ProxyPreserveHost On
# Forward real IP
RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
RequestHeader set X-Forwarded-Proto "https"
# Disable buffering
ProxyIOBufferSize 65536
</VirtualHost>
```
## Traefik
```yaml
# docker-compose.yml
services:
pulse:
image: rcourtman/pulse:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
- "traefik.http.routers.pulse.tls=true"
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
# WebSocket support is automatic in Traefik 2.x+
```
Or using Traefik file configuration:
```yaml
# traefik-dynamic.yml
http:
routers:
pulse:
rule: "Host(`pulse.example.com`)"
service: pulse
tls: {}
services:
pulse:
loadBalancer:
servers:
- url: "http://localhost:7655"
```
## HAProxy
```haproxy
frontend https
bind *:443 ssl crt /path/to/cert.pem
# ACL for Pulse
acl host_pulse hdr(host) -i pulse.example.com
# WebSocket detection
acl is_websocket hdr(Upgrade) -i websocket
# Use backend
use_backend pulse if host_pulse
backend pulse
# Health check
option httpchk GET /api/health
# WebSocket support
option http-server-close
option forwardfor
# Timeouts for WebSocket
timeout client 3600s
timeout server 3600s
timeout tunnel 3600s
# Backend server
server pulse1 localhost:7655 check
```
## Cloudflare Tunnel
If using Cloudflare Tunnel (cloudflared):
```yaml
# config.yml
tunnel: YOUR_TUNNEL_ID
credentials-file: /path/to/credentials.json
ingress:
- hostname: pulse.example.com
service: http://localhost:7655
originRequest:
# Enable WebSocket
noTLSVerify: false
connectTimeout: 30s
# No additional config needed - WebSockets work by default
- service: http_status:404
```
## Testing WebSocket Connection
After configuring your reverse proxy, test that WebSockets work:
```bash
# Test basic connectivity
curl https://pulse.example.com/api/health
# Test WebSocket upgrade (should return 101 Switching Protocols)
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://pulse.example.com/api/ws
```
In browser console (F12):
```javascript
// Test WebSocket connection
const ws = new WebSocket('wss://pulse.example.com/api/ws');
ws.onopen = () => console.log('WebSocket connected!');
ws.onmessage = (e) => console.log('Received:', e.data);
ws.onerror = (e) => console.error('WebSocket error:', e);
```
## Common Issues
### "Connection Lost" or no real-time updates
- WebSocket upgrade not configured correctly
- Check proxy passes `Upgrade` and `Connection` headers
- Verify timeouts are increased for long connections
### CORS errors
- Pulse handles CORS internally
- Don't add additional CORS headers in proxy
- If needed, set `ALLOWED_ORIGINS` in Pulse configuration
### 502 Bad Gateway
- Pulse not running on expected port (default 7655)
- Check with: `curl http://localhost:7655/api/health`
- Verify Pulse service: `systemctl status pulse-backend`
### WebSocket closes immediately
- Timeout too short in proxy configuration
- Increase `proxy_read_timeout` (Nginx) or equivalent
- Set to at least 3600s (1 hour) or more
## Security Recommendations
1. **Always use HTTPS** for production deployments
2. **Set proper headers** to prevent clickjacking:
```nginx
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
```
3. **Rate limiting** for API endpoints:
```nginx
limit_req_zone $binary_remote_addr zone=pulse:10m rate=30r/s;
limit_req zone=pulse burst=50 nodelay;
```
4. **Hide proxy version**:
```nginx
proxy_hide_header X-Powered-By;
server_tokens off;
```
## Support
If WebSockets still don't work after following this guide:
1. Check browser console for errors (F12)
2. Verify Pulse logs: `journalctl -u pulse-backend -f`
3. Test without proxy first: `http://your-server:7655`
4. Report issues: https://github.com/rcourtman/Pulse/issues

29
docs/SCREENSHOTS.md Normal file
View File

@@ -0,0 +1,29 @@
# Pulse Screenshots
## Dashboard Overview (Dark Mode)
![Dashboard Overview](images/01-dashboard.png)
*Real-time monitoring dashboard showing 7 Proxmox nodes with 35 VMs and 56 containers. Color-coded resource usage (CPU, RAM, storage) with quick status indicators for running/stopped guests. Automatic layout adapts to cluster size - compact cards for 5-9 nodes. Professional dark theme optimized for 24/7 monitoring setups.*
## Storage Management
![Storage Management](images/02-storage.png)
*Comprehensive storage view displaying all storage pools across nodes with usage percentages, allocated vs used space, and visual indicators. Monitors local, ZFS, LVM, and network storage types in a unified interface.*
## Backup Central
![Unified Backup View](images/03-backups.png)
*Centralized backup management showing PBS backups, PVE backup tasks, and VM snapshots in one place. Track backup status, sizes, retention, and quickly identify failed or missing backups across your entire infrastructure. Time-range buttons (24h/7d/30d/custom) and the synchronized bar chart make it easy to focus on exactly the window you care about.*
## Alerts & Configuration
![Alerts and Configuration](images/04-alerts.png)
*Unified alerts view showing active alerts and configuration settings. Monitor current system alerts with severity indicators, affected resources, and acknowledgment status. Configure thresholds, notification settings, quiet hours, and alert grouping. Bulk actions allow managing multiple alerts simultaneously.*
## Alert History & Analytics
![Alert History](images/05-alert-history.png)
*Comprehensive alert history with frequency visualization showing 77 alerts over 28 time periods. Filter by severity (warnings, critical, info), search specific resources, and track resolution times. Visual timeline helps identify patterns and recurring issues.*
## Settings & Node Management
![Node Configuration](images/06-settings.png)
*Manage Proxmox nodes and PBS instances through the UI. Add/remove nodes, configure credentials securely (encrypted at rest), set polling intervals, and manage authentication settings. Quick health check shows connection status for all nodes.*
## Mobile Responsive Design
![Mobile View](images/08-mobile.png)
*Fully responsive mobile interface for monitoring on the go. Touch-optimized controls, collapsible navigation, and adaptive layouts ensure full functionality on smartphones and tablets without compromising usability.*

357
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,357 @@
# Pulse Security
## Mandatory Authentication
**Starting with v4.5.0, authentication setup is prompted for all new Pulse installations.** This protects your Proxmox API credentials from unauthorized access.
### First-Run Security Setup
When you first access Pulse, you'll be guided through a mandatory security setup:
- Create your admin username and password
- Automatic API token generation for automation
- Settings are applied immediately without restart
- **Your existing nodes and settings are preserved**
## Smart Security Context
### Public Access Detection
Pulse automatically detects when it's being accessed from public networks:
- **Private Networks**: Local/RFC1918 addresses (192.168.x.x, 10.x.x.x, etc.)
- **Public Networks**: Any non-private IP address
- **Stronger Warnings**: Red alerts when accessed from public IPs without authentication
### Trusted Networks Configuration (Deprecated)
**Note: Authentication is now mandatory regardless of network location.**
Legacy configuration (no longer applicable):
```bash
# Environment variable (comma-separated CIDR blocks)
PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24
# Or in systemd
sudo systemctl edit pulse-backend
[Service]
Environment="PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24"
```
When configured:
- Access from trusted networks: No auth required
- Access from outside: Authentication enforced
- Useful for: Mixed home/remote access scenarios
## Security Warning System
Pulse now includes a non-intrusive security warning system that helps you understand your security posture:
### Security Score
Your instance receives a score from 0-5 based on:
- ✅ Credentials encrypted at rest (always enabled)
- ✅ Export/import protection
- ⚠️ Authentication enabled
- ⚠️ HTTPS connection
- ⚠️ Audit logging
### Dismissing Warnings
If you're comfortable with your security setup, you can dismiss warnings:
- **For 1 day** - Reminder tomorrow
- **For 1 week** - Reminder next week
- **Forever** - Won't show again
## Credential Security
### Encrypted at Rest (AES-256-GCM)
- **Node Credentials**: Passwords and API tokens (`/etc/pulse/nodes.enc`)
- **Email Settings**: SMTP passwords (`/etc/pulse/email.enc`)
- **Webhook Data**: URLs and auth headers (`/etc/pulse/webhooks.enc`) - v4.1.9+
- **Encryption Key**: Auto-generated (`/etc/pulse/.encryption.key`)
### Security Features
- **Logs**: Token values masked with `***` in all outputs
- **API**: Frontend receives only `hasToken: true`, never actual values
- **Export**: Requires API_TOKEN authentication to extract credentials
- **Migration**: Use passphrase-protected export/import (see [Migration Guide](MIGRATION.md))
- **Auto-Migration**: Unencrypted configs automatically migrate to encrypted format
## Export/Import Protection
By default, configuration export/import is blocked for security. You have two options:
### Option 1: Set API Token (Recommended)
```bash
# Using systemd (secure)
sudo systemctl edit pulse-backend
# Add:
[Service]
Environment="API_TOKEN=your-48-char-hex-token"
# Then restart:
sudo systemctl restart pulse-backend
# Docker
docker run -e API_TOKEN=your-token rcourtman/pulse:latest
```
### Option 2: Allow Unprotected Export (Homelab)
```bash
# Using systemd
sudo systemctl edit pulse-backend
# Add:
[Service]
Environment="ALLOW_UNPROTECTED_EXPORT=true"
# Docker
docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest
```
**Note:** For production deployments, consider using Docker secrets or systemd environment variables instead of .env files for sensitive data.
## Security Features
### Core Protection
- **Encryption**: All credentials encrypted at rest (AES-256-GCM)
- **Export Protection**: Exports always encrypted with passphrase
- **Minimum Passphrase**: 12 characters required for exports
- **Security Tab**: Check status in Settings → Security
### Enterprise Security (When Authentication Enabled)
- **Password Security**:
- Bcrypt hashing with cost factor 12 (60-character hash)
- Passwords NEVER stored in plain text
- Automatic hashing on security setup
- **CRITICAL**: Bcrypt hashes MUST be exactly 60 characters
- **API Token Security**:
- 64-character hex tokens (32 bytes of entropy)
- SHA3-256 hashed before storage (64-char hash)
- Raw token shown only once during generation
- Tokens NEVER stored in plain text
- Live reloading when .env file changes
- API-only mode supported (no password auth required)
- **CSRF Protection**: All state-changing operations require CSRF tokens
- **Rate Limiting**:
- Authentication endpoints: 10 attempts/minute per IP
- General API: 500 requests/minute per IP
- Real-time endpoints exempt for functionality
- **Account Lockout Protection**:
- Locks after 5 failed login attempts
- 15-minute automatic lockout duration
- Clear feedback showing remaining attempts
- Time remaining displayed when locked
- Manual reset available via API for administrators
- **Session Management**:
- Secure HttpOnly cookies
- 24-hour session expiry
- Session invalidation on password change
- **Security Headers**:
- Content-Security-Policy with strict directives
- X-Frame-Options: DENY (prevents clickjacking)
- X-Content-Type-Options: nosniff
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy restricting sensitive APIs
- **Audit Logging**: All authentication events logged with IP addresses
### What's Encrypted in Exports
- Node credentials (passwords, API tokens)
- PBS credentials
- Email settings passwords
- Webhook URLs and authentication headers (v4.1.9+)
### What's NOT Encrypted
- Node hostnames and IPs
- Threshold settings
- General configuration
- Alert rules and schedules
## Authentication
Pulse supports multiple authentication methods that can be used independently or together:
### Password Authentication
#### Quick Security Setup (Recommended)
The easiest way to enable authentication is through the web UI:
1. Go to Settings → Security
2. Click "Enable Security Now"
3. Enter username and password
4. Save the generated API token (shown only once!)
5. Security is enabled immediately (no restart needed)
This automatically:
- Generates a secure random password
- Hashes it with bcrypt (cost factor 12)
- Creates secure API token (SHA3-256 hashed, raw token shown once)
- For systemd: Configures systemd with hashed credentials
- For Docker: Saves to `/data/.env` with hashed credentials (properly quoted to prevent shell expansion)
- Restarts service/container with authentication enabled
#### Manual Setup (Advanced)
```bash
# Using systemd (password will be hashed automatically)
sudo systemctl edit pulse-backend
# Add:
[Service]
Environment="PULSE_AUTH_USER=admin"
Environment="PULSE_AUTH_PASS=$2a$12$..." # Use bcrypt hash, not plain text!
# Docker (credentials persist in volume via .env file)
# IMPORTANT: Always quote bcrypt hashes to prevent shell expansion!
docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pulse:latest
# Or use Quick Security Setup and restart container
```
**Important**: Always use hashed passwords in configuration. Use the Quick Security Setup or generate bcrypt hashes manually.
#### Features
- Web UI login required when authentication enabled
- Change/remove password from Settings → Security
- Passwords ALWAYS hashed with bcrypt (cost 12)
- Session-based authentication with secure HttpOnly cookies
- 24-hour session expiry
- CSRF protection for all state-changing operations
- Session invalidation on password change
### API Token Authentication
For programmatic access and automation. API tokens are SHA3-256 hashed for security.
#### Token Setup via Quick Security
The Quick Security Setup automatically:
- Generates a cryptographically secure token
- Hashes it with SHA3-256
- Stores only the 64-character hash
#### Manual Token Setup
```bash
# Using systemd (use SHA3-256 hash, not plain text!)
sudo systemctl edit pulse-backend
# Add:
[Service]
Environment="API_TOKEN=<64-char-sha3-256-hash>"
# Docker
docker run -e API_TOKEN=<64-char-sha3-256-hash> rcourtman/pulse:latest
```
**Security Note**: API tokens are automatically hashed with SHA3-256. Never store plain text tokens in configuration.
#### Token Management (Settings → Security → API Token)
- Generate new tokens via web UI when authenticated
- View existing token anytime (authenticated users only)
- Regenerate tokens without disrupting service
- Delete tokens to disable API access
- All tokens stored as SHA3-256 hashes
#### Usage
```bash
# Include the ORIGINAL token (not hash) in X-API-Token header
curl -H "X-API-Token: your-original-token" http://localhost:7655/api/health
# Or in query parameter for export/import
curl "http://localhost:7655/api/export?token=your-original-token"
```
### Auto-Registration Security
#### Default Mode
- All access requires authentication
- Nodes can auto-register with the API token
- Setup scripts work without additional configuration
#### Secure Mode
- Require API token for all operations
- Protects auto-registration endpoint
- Enable by setting API_TOKEN environment variable
## CORS (Cross-Origin Resource Sharing)
By default, Pulse only allows same-origin requests (no CORS headers). This is the most secure configuration.
### Configuring CORS for External Access
If you need to access Pulse API from a different domain:
```bash
# Docker
docker run -e ALLOWED_ORIGINS="https://app.example.com" rcourtman/pulse:latest
# systemd
sudo systemctl edit pulse-backend
[Service]
Environment="ALLOWED_ORIGINS=https://app.example.com"
# Multiple origins (comma-separated)
ALLOWED_ORIGINS="https://app.example.com,https://dashboard.example.com"
# Development mode (allows localhost)
PULSE_DEV=true
```
**Security Note**: Never use `ALLOWED_ORIGINS=*` in production as it allows any website to access your API.
## Security Best Practices
### Credential Storage
-**DO**: Use Quick Security Setup for automatic hashing
-**DO**: Store only bcrypt hashes for passwords
-**DO**: Store only SHA3-256 hashes for API tokens
-**DON'T**: Store plain text passwords in config files
-**DON'T**: Store plain text API tokens in config files
-**DON'T**: Log credentials or include them in backups
### Authentication Setup
-**DO**: Use strong, unique passwords (16+ characters)
-**DO**: Rotate API tokens periodically
-**DO**: Use HTTPS in production environments
-**DON'T**: Share API tokens between users/services
-**DON'T**: Embed credentials in client-side code
### Verification
Run the security verification script to ensure no plain text credentials:
```bash
/opt/pulse/testing-tools/security-verification.sh
```
This checks:
- No hardcoded credentials in code
- No credentials exposed in logs
- All passwords/tokens properly hashed
- Secure file permissions
- No credential leaks in API responses
## Account Lockout and Recovery
### Lockout Behavior
- After **5 failed login attempts**, the account is locked for **15 minutes**
- Lockout applies to both username and IP address
- Login form shows remaining attempts after each failure
- Clear message when locked with time remaining
### Automatic Recovery
- Lockouts automatically expire after 15 minutes
- No action needed - just wait for the timer to expire
- Successful login clears all failed attempt counters
### Manual Recovery (Admin)
Administrators with API access can manually reset lockouts:
```bash
# Reset lockout for a specific username
curl -X POST http://localhost:7655/api/security/reset-lockout \
-H "X-API-Token: your-api-token" \
-H "Content-Type: application/json" \
-d '{"identifier":"username"}'
# Reset lockout for an IP address
curl -X POST http://localhost:7655/api/security/reset-lockout \
-H "X-API-Token: your-api-token" \
-H "Content-Type: application/json" \
-d '{"identifier":"192.168.1.100"}'
```
## Troubleshooting
**Account locked?** Wait 15 minutes or contact admin for manual reset
**Export blocked?** You're on a public network - login with password, set 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 API_TOKEN is correct (use original token, not hash)
**CORS errors?** Configure ALLOWED_ORIGINS for your domain
**Forgot password?** Start fresh - delete your Pulse data and restart

View File

@@ -0,0 +1,173 @@
# Temperature Monitoring
Pulse can display real-time CPU and NVMe temperatures directly in your dashboard, giving you instant visibility into your hardware health.
## Features
- **CPU Package Temperature**: Shows the overall CPU temperature
- **Individual Core Temperatures**: Tracks each CPU core
- **NVMe Drive Temperatures**: Monitors NVMe SSD temperatures
- **Color-Coded Display**:
- Green: < 60°C (normal)
- Yellow: 60-80°C (warm)
- Red: > 80°C (hot)
## How It Works
Temperature monitoring uses standard SSH key authentication (just like Ansible, Saltstack, and other automation tools) to securely collect sensor data from your nodes. Pulse connects via SSH and runs the `sensors` command to read hardware temperatures - that's it!
> **Important:** Run every setup command as the same user account that executes the Pulse service (typically `pulse`). The backend reads the SSH key from that users home directory; keys under `root` or other accounts will be ignored.
## Requirements
1. **SSH Key Authentication**: Your Pulse server needs SSH key access to nodes (no passwords)
2. **lm-sensors Package**: Installed on nodes to read hardware sensors
## Setup (Automatic)
The auto-setup script (Settings → Nodes → Setup Script) will prompt you to configure SSH access for temperature monitoring:
1. Run the auto-setup script on your Proxmox node
2. When prompted for SSH setup, choose "y"
3. Get your Pulse server's public key:
```bash
# On your Pulse server (run as the user running Pulse)
cat ~/.ssh/id_rsa.pub
```
4. Paste the public key when prompted
5. The script will:
- Add the key to `/root/.ssh/authorized_keys`
- Install `lm-sensors`
- Run `sensors-detect --auto`
## Setup (Manual)
If you skipped SSH setup during auto-setup, you can configure it manually:
### 1. Generate SSH Key (on Pulse server)
```bash
# Run as the user running Pulse (usually the pulse service account)
ssh-keygen -t rsa -N "" -f ~/.ssh/id_rsa
```
### 2. Copy Public Key to Proxmox Nodes
```bash
# Get your public key
cat ~/.ssh/id_rsa.pub
# Add it to each Proxmox node
ssh root@your-proxmox-node
mkdir -p /root/.ssh
chmod 700 /root/.ssh
echo "YOUR_PUBLIC_KEY_HERE" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
```
### 3. Install lm-sensors (on each Proxmox node)
```bash
apt-get update
apt-get install -y lm-sensors
sensors-detect --auto
```
### 4. Test SSH Connection
From your Pulse server:
```bash
ssh root@your-proxmox-node "sensors -j"
```
You should see JSON output with temperature data.
## How It Works
1. Pulse uses SSH to connect to each node as root
2. Runs `sensors -j` to get temperature data in JSON format
3. Parses CPU temperatures (coretemp/k10temp)
4. Parses NVMe temperatures (nvme-pci-*)
5. Displays the data in node cards with color coding
## Troubleshooting
### No Temperature Data Shown
**Check SSH access**:
```bash
# From Pulse server
ssh root@your-proxmox-node "echo test"
```
**Check lm-sensors**:
```bash
# On Proxmox node
sensors -j
```
**Check Pulse logs**:
```bash
journalctl -u pulse -f | grep -i temp
```
### Temperature Shows as Unavailable
- lm-sensors may not be installed
- Node may not have temperature sensors
- SSH key authentication may not be working
### ARM Devices (Raspberry Pi, etc.)
ARM devices typically don't have the same sensor interfaces. Temperature monitoring may not work or may show different sensors (like `thermal_zone0` instead of `coretemp`).
## Security & Architecture
### How Temperature Collection Works
Temperature monitoring uses **SSH key authentication** - the same trusted method used by automation tools like Ansible, Terraform, and Saltstack for managing infrastructure at scale.
**What Happens**:
1. Pulse connects to your node via SSH using a key (no passwords)
2. Runs `sensors -j` to get temperature readings in JSON format
3. Parses the data and displays it in the dashboard
4. Disconnects (entire operation takes <1 second)
**Security Design**:
- ✅ **Key-based authentication** - More secure than passwords, industry standard
- ✅ **Read-only operation** - `sensors` command only reads hardware data
- ✅ **Private key stays on Pulse server** - Never transmitted or exposed
- ✅ **Public key on nodes** - Safe to store, can't be used to gain access
- ✅ **Instantly revocable** - Remove key from authorized_keys to disable
- ✅ **Logged and auditable** - All connections logged in `/var/log/auth.log`
### What Pulse Uses SSH For
Pulse reuses the SSH access only for the actions already described in [Setup (Automatic)](#setup-automatic) and [How It Works](#how-it-works): adding the public key during setup (if you opt in) and polling `sensors -j` each cycle. It does nothing else—no extra commands, file changes, or config edits—and revoking the key stops temperature collection immediately.
This is the same security model used by thousands of organizations for infrastructure automation.
### Best Practices
1. **Dedicated key**: Generate a separate SSH key just for Pulse (recommended)
2. **Firewall rules**: Optionally restrict SSH to your Pulse server's IP
3. **Regular monitoring**: Review auth logs if you want extra visibility
4. **Secure your Pulse server**: Keep it updated and behind proper access controls
### Command Restrictions (Default)
Pulse now writes the temperature key with a forced command so the connection can only execute `sensors -j`. Port/X11/agent forwarding and PTY allocation are all disabled automatically when you opt in through the setup script. Re-running the script upgrades older installs to the restricted entry without touching any of your other SSH keys.
```bash
# Example entry in /root/.ssh/authorized_keys installed by Pulse
command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2E...
```
You can still manage the entry manually if you prefer, but no extra steps are required for new installations.
## Performance Impact
- Minimal: SSH connection is made once per polling cycle
- Timeout: 5 seconds (non-blocking)
- Falls back gracefully if SSH fails
- No impact if SSH is not configured

420
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,420 @@
# Pulse Troubleshooting Guide
## Common Issues and Solutions
### Authentication Problems
#### Forgot Password / Lost Access
**Solution: Use the built-in recovery endpoint**
Pulse ships with a guarded recovery API that lets you regain access without wiping configuration.
1. **From the Pulse host (localhost only)**
Generate a short-lived recovery token or temporarily disable auth:
```bash
# Create a 30 minute recovery token (returns JSON with the token value)
curl -s -X POST http://localhost:7655/api/security/recovery \
-H 'Content-Type: application/json' \
-d '{"action":"generate_token","duration":30}'
# OR force local-only recovery access (writes .auth_recovery in the data dir)
curl -s -X POST http://localhost:7655/api/security/recovery \
-H 'Content-Type: application/json' \
-d '{"action":"disable_auth"}'
```
2. **If you generated a token**, use it from a trusted workstation:
```bash
curl -s -X POST https://pulse.example.com/api/security/recovery \
-H 'Content-Type: application/json' \
-H 'X-Recovery-Token: YOUR_TOKEN' \
-d '{"action":"disable_auth"}'
```
The token is single-use and expires automatically.
3. **Log in and reset credentials** using Settings → Security, then re-enable auth:
```bash
curl -s -X POST http://localhost:7655/api/security/recovery \
-H 'Content-Type: application/json' \
-d '{"action":"enable_auth"}'
```
Alternatively, delete `/etc/pulse/.auth_recovery` (or `/data/.auth_recovery` for Docker) and restart Pulse.
Only fall back to nuking `/etc/pulse` if the recovery endpoint is unreachable.
**Prevention:**
- Use a password manager
- Store exported configuration backups securely
- Generate API tokens for automation instead of sharing passwords
#### Cannot login after setting up security
**Symptoms**: "Invalid username or password" error despite correct credentials
**Common causes and solutions:**
1. **Truncated bcrypt hash** (most common)
- Check hash is exactly 60 characters: `echo -n "$PULSE_AUTH_PASS" | wc -c`
- Look for error in logs: `Bcrypt hash appears truncated!`
- Solution: Use full 60-character hash or Quick Security Setup
2. **Docker Compose $ character issue**
- Docker Compose interprets `$` as variable expansion
- **Wrong**: `PULSE_AUTH_PASS='$2a$12$hash...'`
- **Right**: `PULSE_AUTH_PASS='$$2a$$12$$hash...'` (escape with $$)
- Alternative: Use a .env file where no escaping is needed
3. **Environment variable not loaded**
- Check if variable is set: `docker exec pulse env | grep PULSE_AUTH`
- Verify quotes around hash: Must use single quotes
- Restart container after changes
#### Password change fails
**Error**: `exec: "sudo": executable file not found`
**Solution**: Update to v4.3.8+ which removes sudo requirement. For older versions:
```bash
# Manually update .env file
docker exec pulse sh -c "echo \"PULSE_AUTH_PASS='new-hash'\" >> /data/.env"
docker restart pulse
```
#### Can't access Pulse - stuck at login
**Symptoms**: Can't access Pulse after upgrade, no credentials work
**Solution**:
- If upgrading from pre-v4.5.0, you need to complete security setup first
- Clear browser cache and cookies
- Access http://your-ip:7655 to see setup wizard
- Complete setup, then restart container
### Docker-Specific Issues
#### No .env file in /data
**This is expected behavior** when using environment variables. The .env file is only created by:
- Quick Security Setup wizard
- Password change through UI
- Manual creation
If you provide auth via `-e` flags or docker-compose environment section, no .env is created.
#### Container won't start
Check logs: `docker logs pulse`
Common issues:
- Port already in use: Change port mapping
- Volume permissions: Ensure volume is writable
- Invalid environment variables: Check syntax
### Installation Issues
#### Binary not found (v4.3.7)
**Error**: `/opt/pulse/pulse: No such file or directory`
**Cause**: v4.3.7 install script bug
**Solution**: Update to v4.3.8 or manually fix:
```bash
sudo mkdir -p /opt/pulse/bin
sudo mv /opt/pulse/pulse /opt/pulse/bin/pulse
sudo systemctl daemon-reload
sudo systemctl restart pulse
```
#### Service name confusion
Pulse uses different service names depending on installation method:
- **ProxmoxVE Script**: `pulse`
- **Manual Install**: `pulse-backend`
- **Docker**: N/A (container name)
To check which you have:
```bash
systemctl status pulse 2>/dev/null || systemctl status pulse-backend
```
### Notification Issues
#### Emails not sending
1. Check email configuration in Settings → Alerts
2. Verify SMTP settings and credentials
3. Check logs for errors: `docker logs pulse | grep -i email`
4. Test with a simple webhook first
#### Webhook not working
- Verify URL is accessible from Pulse server
- Check for SSL certificate issues
- Try a test service like webhook.site
- Check logs for response codes
### VM Disk Monitoring Issues
#### VMs show "-" for disk usage
**This is normal and expected** - VMs require QEMU Guest Agent to report disk usage.
**Quick fix:**
1. Install guest agent in VM: `apt install qemu-guest-agent` (Linux) or virtio-win tools (Windows)
2. Enable in Proxmox: VM → Options → QEMU Guest Agent → Enable
3. Restart the VM
4. Wait 10 seconds for Pulse to poll again
**Detailed troubleshooting:**
See [VM Disk Monitoring Guide](VM_DISK_MONITORING.md) for full setup instructions.
#### How to diagnose VM disk issues
**Step 1: Check if guest agent is running**
On Proxmox host:
```bash
# Check if agent is enabled in VM config
qm config <VMID> | grep agent
# Test if agent responds
qm agent <VMID> ping
# Get filesystem info (what Pulse uses)
qm agent <VMID> get-fsinfo
```
Inside the VM:
```bash
# Linux
systemctl status qemu-guest-agent
# Windows (PowerShell)
Get-Service QEMU-GA
```
**Step 2: Run diagnostic script**
```bash
# On Proxmox host
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash
```
Or if Pulse is installed:
```bash
/opt/pulse/scripts/test-vm-disk.sh
```
### Ceph Cluster Data Missing
**Symptoms**: Ceph pools or health section missing in Storage view even though the cluster uses Ceph.
**Checklist:**
1. Confirm the Proxmox node exposes Ceph-backed storage (`Datacenter → Storage`). Types must be `rbd`, `cephfs`, or `ceph`.
2. Ensure Pulse has permission to call `/cluster/ceph/status` (Pulses Proxmox account needs `Sys.Audit` as part of `PVEAuditor`, provided by the setup script).
3. Check the backend logs for `Ceph status unavailable preserving previous Ceph state`. Intermittent errors are usually network timeouts; steady errors point to permissions.
4. Run from the Pulse host:
```bash
curl -sk https://pve-node:8006/api2/json/cluster/ceph/status \
-H "Authorization: PVEAPIToken=pulse-monitor@pam!token=<value>"
```
If this fails, verify firewall / token scope.
**Tip**: Pulse polls Ceph after storage refresh. If you recently added Ceph storage, wait one poll cycle or restart the backend to force detection.
### Backup View Filters Not Working
**Symptoms**: Backup chart does not highlight the selected time range or the grid ignores the picker.
**Checklist:**
1. Make sure you are running Pulse v4.29.0 or newer (the interactive picker was introduced alongside the new timeline). Check **Settings → System → About**.
2. Verify your browser is not forcing Legacy mode if the top-right toggle shows “Lightweight UI”, switch back to default.
3. When filters appear stuck:
- Click **Reset Filters** in the toolbar.
- Clear any search chips under the chart.
- Pick a preset (24h / 7d / 30d) to re-seed the view, then move back to Custom.
4. If the grid still shows stale data, open DevTools console and ensure no errors mentioning `chartsSelection` appear. Any error here usually means a stale service worker; hard refresh (Ctrl+Shift+R) clears it.
**Tip**: Selecting bars in the chart cross-highlights matching rows. If that does not happen, confirm you do not have browser extensions that block pointer events on canvas elements.
### Docker Agent Shows Hosts Offline
**Symptoms**: `/docker` tab marks hosts as offline or missing container metrics.
**Checklist:**
1. Run the agent manually with verbose logs:
```bash
sudo /usr/local/bin/pulse-docker-agent --interval 15s --debug
```
Look for HTTP 401 (token mismatch) or socket errors.
2. Confirm the host sees Docker:
```bash
sudo docker info | head -n 20
```
3. Make sure the agent ID is stable. If running inside transient containers, set `--agent-id` explicitly so Pulse does not treat each restart as a new host.
4. Verify Pulse shows a recent heartbeat (`lastSeen`) in `/api/state` → `dockerHosts`. Hosts are marked offline after 4× the configured interval with no update.
5. For reverse proxies/TLS issues, append `--insecure` temporarily to confirm whether certificate validation is the culprit.
**Restart loops**: The Docker workspace Issues column lists the last exit codes. Investigate recurring non-zero codes in `docker logs <container>` and adjust restart policy if needed.
**Step 3: Check Pulse logs**
```bash
# Docker
docker logs pulse | grep -i "guest agent\|fsinfo"
# Systemd
journalctl -u pulse -f | grep -i "guest agent\|fsinfo"
```
Look for specific error reasons:
- `agent-not-running` - Agent service not started in VM
- `agent-disabled` - Not enabled in VM config
- `agent-timeout` - Agent not responding (may need restart)
- `permission-denied` - Check permissions (see below)
- `no-filesystems` - Agent returned no usable filesystem data
#### Permission denied errors
If Pulse logs show permission denied when querying guest agent:
**Check permissions:**
```bash
# On Proxmox host
pveum user permissions pulse-monitor@pam
```
**Required permissions:**
- **Proxmox 9:** `PVEAuditor` role (includes `VM.GuestAgent.Audit`)
- **Proxmox 8:** `VM.Monitor` permission
**Fix permissions:**
Re-run the Pulse setup script on the Proxmox node:
```bash
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/setup-pve.sh | bash
```
Or manually:
```bash
# Proxmox 9
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
# Proxmox 8
pveum role add PulseMonitor -privs VM.Monitor
pveum aclmod / -user pulse-monitor@pam -role PulseMonitor
```
**Important:** Both API tokens and passwords work fine for guest agent access. If you see permission errors, it's a permission configuration issue, not an authentication method limitation.
#### Guest agent installed but no disk data
If agent responds to ping but returns no filesystem info:
1. **Check agent version** - Update to latest:
```bash
# Linux
apt update && apt install --only-upgrade qemu-guest-agent
systemctl restart qemu-guest-agent
```
2. **Check filesystem permissions** - Agent needs read access to filesystem data
3. **Windows VMs** - Ensure VirtIO drivers are up to date from latest virtio-win ISO
4. **Special filesystems only** - If VM only has special filesystems (tmpfs, ISO mounts), this is normal for Live systems
#### Specific VM types
**Cloud images:**
- Most have guest agent pre-installed but disabled
- Enable with: `systemctl enable --now qemu-guest-agent`
**Windows VMs:**
- Must install VirtIO guest tools
- Ensure "QEMU Guest Agent" service is running
- May need "QEMU Guest Agent VSS Provider" for full functionality
**Container-based VMs (Docker/Kubernetes hosts):**
- Will show high disk usage due to container layers
- This is accurate - containers consume real disk space
- Consider monitoring container disk separately
### Performance Issues
#### High CPU usage
- Polling interval is fixed at 10 seconds (matches Proxmox update cycle)
- Check number of monitored nodes
- Disable unused features (snapshots, backups monitoring)
#### High memory usage
- Normal for monitoring many nodes
- Check metrics retention settings
- Restart container to clear any memory leaks
### Network Issues
#### Cannot connect to Proxmox nodes
1. Verify Proxmox API is accessible:
```bash
curl -k https://proxmox-ip:8006
```
2. Check credentials have proper permissions (PVEAuditor minimum)
3. Verify network connectivity between Pulse and Proxmox
4. Check for firewall rules blocking port 8006
#### PBS connection issues
- Ensure API token has Datastore.Audit permission
- Check PBS is accessible on port 8007
- Verify token format: `user@realm!tokenid=secret`
### Update Issues
#### Updates not showing
- Check update channel in Settings → System
- Verify internet connectivity
- Check GitHub API rate limits
- Manual update: Pull latest Docker image or run install script
#### Update fails to apply
**Docker**: Pull new image and recreate container
**Native**: Run install script again or check logs
### Data Recovery
#### Lost authentication
See [Forgot Password / Lost Access](#forgot-password--lost-access) section above.
**Recommended approach**: Start fresh. Delete your Pulse data and restart.
#### Corrupt configuration
Restore from backup or delete config files to start fresh:
```bash
# Docker
docker exec pulse rm /data/*.json /data/*.enc
docker restart pulse
# Native
sudo rm /etc/pulse/*.json /etc/pulse/*.enc
sudo systemctl restart pulse
```
## Getting Help
### Collect diagnostic information
```bash
# Version
curl http://localhost:7655/api/version
# Logs (last 100 lines)
docker logs --tail 100 pulse # Docker
journalctl -u pulse -n 100 # Native
# Environment
docker exec pulse env | grep -E "PULSE|API" # Docker
systemctl show pulse --property=Environment # Native
```
### Report issues
When reporting issues, include:
1. Pulse version
2. Deployment type (Docker/LXC/Manual)
3. Error messages from logs
4. Steps to reproduce
5. Expected vs actual behavior
Report at: https://github.com/rcourtman/Pulse/issues

243
docs/VM_DISK_MONITORING.md Normal file
View File

@@ -0,0 +1,243 @@
# VM Disk Usage Monitoring
Pulse can show actual disk usage for VMs (just like containers) when the QEMU Guest Agent is installed and configured properly.
## Quick Summary
**Without QEMU Guest Agent:**
- VMs show "-" for disk usage (no data available)
- Cannot monitor actual disk usage inside the VM
**With QEMU Guest Agent:**
- VMs show real disk usage like containers do (e.g., "5.2GB used of 32GB / 16%")
- Accurate threshold alerts based on actual usage
- Better capacity planning with real data
## How It Works
Proxmox doesn't track VM disk usage natively (unlike containers which share the host kernel). To get real disk usage from VMs:
1. Proxmox API returns `disk=0` and `maxdisk=<allocated_size>` (this is normal)
2. Pulse automatically queries the QEMU Guest Agent API to get filesystem info
3. Guest agent reports all mounted filesystems from inside the VM
4. Pulse aggregates the data (filtering out special filesystems) and displays it
**Important**: This works with both API tokens and password authentication. API tokens work fine for guest agent queries when permissions are set correctly.
## Requirements
### 1. Install QEMU Guest Agent in Your VMs
**Linux VMs:**
```bash
# Debian/Ubuntu
apt-get install qemu-guest-agent
systemctl enable --now qemu-guest-agent
# RHEL/Rocky/AlmaLinux
yum install qemu-guest-agent
systemctl enable --now qemu-guest-agent
# Alpine
apk add qemu-guest-agent
rc-update add qemu-guest-agent
rc-service qemu-guest-agent start
```
**Windows VMs:**
- Download virtio-win guest tools from: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/
- Install the guest tools package which includes the QEMU Guest Agent
- The service starts automatically after installation
### 2. Enable Guest Agent in VM Options
In Proxmox web UI:
1. Select your VM
2. Go to **Options****QEMU Guest Agent**
3. Check **Enabled**
4. Start/restart the VM
Or via CLI:
```bash
qm set <vmid> --agent enabled=1
```
### 3. Verify Guest Agent is Working
Check if the agent is responding:
```bash
qm agent <vmid> ping
```
Get filesystem info (what Pulse uses):
```bash
qm agent <vmid> get-fsinfo
```
### 4. Pulse Permissions
Pulse needs the right permissions to query the guest agent:
**Proxmox VE 8 and below:**
- Requires `VM.Monitor` permission
- Setup script automatically adds this
**Proxmox VE 9+:**
- Requires `VM.GuestAgent.Audit` permission (included in `PVEAuditor` role)
- Setup script automatically configures this
**Both API tokens and passwords work** - tokens do NOT have any limitation accessing guest agent data.
When you run the Pulse setup script, it automatically detects your Proxmox version and sets the correct permissions. If setting up manually:
```bash
# Proxmox 9+
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
# PVEAuditor includes VM.GuestAgent.Audit in PVE 9+
# Proxmox 8 and below
pveum role add PulseMonitor -privs VM.Monitor
pveum aclmod / -user pulse-monitor@pam -role PulseMonitor
```
## Troubleshooting
### Quick Diagnostic Tool
Pulse includes a diagnostic script that can identify why a VM isn't showing disk usage:
```bash
# Run on your Proxmox host (latest version from GitHub)
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash
# Or use the bundled copy installed with Pulse
/opt/pulse/scripts/test-vm-disk.sh
```
Enter the VM ID when prompted. The script will check:
- VM running status
- Guest agent configuration
- Guest agent runtime status
- Filesystem information
- API permissions
### Understanding Disk Display States
**Shows percentage** (e.g., "45%")
- Everything working correctly
- Guest agent installed and accessible
**Shows "-" with hover tooltip**
- Hover to see the specific reason
- Common reasons:
- "Guest agent not running" - Agent not installed or service not started
- "Guest agent disabled" - Not enabled in VM config
- "Permission denied" - Token/user lacks required permissions
- "Agent timeout" - Agent installed but not responding
- "No filesystems" - Agent returned no usable filesystem data
### Guest Agent Not Responding
**Check if agent is running inside VM:**
```bash
# Linux
systemctl status qemu-guest-agent
# Windows
Get-Service QEMU-GA
```
**Check VM configuration:**
```bash
# Should show "agent: 1"
qm config <vmid> | grep agent
```
**Check agent communication:**
```bash
# Should return without error
qm agent <vmid> ping
```
### Permission Denied Errors
If you see "permission denied" in Pulse logs when querying guest agent:
1. **Verify token/user permissions:**
```bash
pveum user permissions pulse-monitor@pam
```
2. **For Proxmox 9+:** Ensure user has `PVEAuditor` role or `VM.GuestAgent.Audit` permission
3. **For Proxmox 8:** Ensure user has `VM.Monitor` permission
4. **Re-run setup script** if you added the node before Pulse v4.7 (old scripts didn't add VM.Monitor)
### Disk Usage Still Not Showing
If the agent is working but Pulse still shows "-":
1. **Check Pulse logs** for specific error messages:
```bash
# Docker
docker logs pulse | grep -i "guest agent\|fsinfo"
# Systemd
journalctl -u pulse -f | grep -i "guest agent\|fsinfo"
```
2. **Test guest agent manually** from Proxmox host:
```bash
qm agent <vmid> get-fsinfo
```
If this works but Pulse doesn't show data, check Pulse permissions and logs
3. **Check agent version** - Older agents might not support filesystem info
4. **Windows VMs** - Ensure virtio-win drivers are up to date
### Network Filesystems
The agent reports all mounted filesystems. Pulse automatically filters out:
- Network mounts (NFS, CIFS, SMB)
- Special filesystems (proc, sys, tmpfs, devtmpfs, etc.)
- Special Windows partitions ("System Reserved")
- Bind mounts and overlays
- CD/DVD filesystems (iso9660, CDFS)
Only local disk usage is counted toward the VM's total.
## Best Practices
1. **Install guest agent in VM templates** - New VMs will have it ready
2. **Monitor agent status** - Set up alerts if critical VMs lose agent connectivity
3. **Keep agents updated** - Update guest agents when updating VM operating systems
4. **Test after VM migrations** - Verify agent still works after moving VMs between nodes
5. **Check logs regularly** - Monitor Pulse logs for guest agent errors
## Platform-Specific Notes
### Cloud-Init Images
Most cloud images include qemu-guest-agent pre-installed but may need to be enabled:
```bash
systemctl enable --now qemu-guest-agent
```
### Docker/Kubernetes VMs
Container workloads can show high disk usage due to container layers. Consider:
- Using separate disks for container storage
- Monitoring container disk usage separately
- Setting appropriate thresholds for container hosts
### Database VMs
Databases often pre-allocate space. The guest agent shows actual usage, which might be less than what the database reports internally.
## Benefits
With QEMU Guest Agent disk monitoring:
- **Accurate alerts** - Alert on real usage, not allocated space
- **Better planning** - See actual growth trends
- **Prevent surprises** - Know when VMs are actually running out of space
- **Optimize storage** - Identify over-provisioned VMs
- **Consistent monitoring** - VMs and containers use the same metrics

318
docs/WEBHOOKS.md Normal file
View File

@@ -0,0 +1,318 @@
# Webhook Configuration Guide
Pulse supports sending alert notifications to various webhook services including Discord, Slack, Microsoft Teams, Telegram, Gotify, ntfy, PagerDuty, and any custom webhook endpoint.
## Quick Start
1. Navigate to **Alerts****Notifications** tab
2. Configure email settings or add webhooks
3. Select your service type (Discord, Slack, Teams, Telegram, etc.)
4. Enter the webhook URL and configure settings
5. Test the webhook to ensure it's working
6. Save your configuration
![Alert Configuration](images/04-alerts.png)
*Alert configuration interface showing notification settings*
## Supported Services
### Discord
```
URL Format: https://discord.com/api/webhooks/{webhook_id}/{webhook_token}
```
1. In Discord, go to Server Settings → Integrations → Webhooks
2. Create a new webhook and copy the URL
3. Paste the URL in Pulse
### Telegram
```
URL Format: https://api.telegram.org/bot{bot_token}/sendMessage?chat_id={chat_id}
```
1. Create a bot with @BotFather on Telegram
2. Get your bot token from BotFather
3. Get your chat ID by messaging the bot and visiting: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
4. In Pulse, select "Telegram Bot" as the service type
5. Use the URL format: `https://api.telegram.org/bot<BOT_TOKEN>/sendMessage?chat_id=<CHAT_ID>`
6. **IMPORTANT**: The chat_id MUST be included in the URL as a parameter
7. Pulse automatically sends rich formatted messages with emojis and full alert details
### Slack
```
URL Format: https://hooks.slack.com/services/{webhook_path}
```
1. In Slack, go to Apps → Incoming Webhooks
2. Add to Slack and choose a channel
3. Copy the webhook URL
### Microsoft Teams
```
URL Format: https://{tenant}.webhook.office.com/webhookb2/{webhook_path}
```
1. In Teams channel, click ... → Connectors
2. Configure Incoming Webhook
3. Copy the URL
### Gotify
```
URL Format: https://your-gotify-server/message?token={your-app-token}
```
1. In Gotify, create a new application
2. Copy the application token
3. Use the URL format: `https://your-gotify-server/message?token=YOUR_APP_TOKEN`
4. The token MUST be included as a URL parameter
5. Pulse will send rich markdown-formatted notifications with emojis and full alert details
6. **View in Pulse links**: Automatically detected - links will work out of the box in most cases
### ntfy
```
URL Format: https://ntfy.sh/{topic} or https://your-ntfy-server/{topic}
```
1. Choose a unique topic name (e.g., 'pulse-alerts-x7k9m2')
- **Important**: Anyone who knows your topic name can send you notifications
- Use a unique/random suffix for privacy
2. For ntfy.sh: Use `https://ntfy.sh/YOUR_TOPIC`
3. For self-hosted: Use `https://your-ntfy-server/YOUR_TOPIC`
4. Subscribe to the same topic in your ntfy mobile/desktop app
5. For authentication (optional):
- Click "Custom Headers" section in webhook config
- Add header: `Authorization`
- Value: `Bearer YOUR_TOKEN` or `Basic base64_encoded_credentials`
6. Notifications include dynamic priority levels and emoji tags based on alert severity
7. **View in Pulse links**: Automatically detected - links will work out of the box in most cases
### PagerDuty
```
URL: https://events.pagerduty.com/v2/enqueue
```
1. In PagerDuty, go to Configuration → Services
2. Add an integration → Events API V2
3. Copy the Integration Key
4. Add the key as a header: `routing_key: YOUR_KEY`
## Custom Headers
For webhooks that require authentication or custom headers:
1. In the webhook configuration, expand the **Custom Headers** section
2. Click **+ Add Header** to add a new header
3. Enter the header name (e.g., `Authorization`, `X-API-Key`, `X-Auth-Token`)
4. Enter the header value (e.g., `Bearer YOUR_TOKEN`, your API key, etc.)
5. Add multiple headers as needed
6. Headers are sent with every webhook request
### Common Header Examples
| Service | Header Name | Header Value Format |
|---------|-------------|-------------------|
| Bearer Token | `Authorization` | `Bearer YOUR_TOKEN_HERE` |
| Basic Auth | `Authorization` | `Basic base64_encoded_user:pass` |
| API Key | `X-API-Key` | `your-api-key-here` |
| Custom Token | `X-Auth-Token` | `your-auth-token` |
| ntfy Auth | `Authorization` | `Bearer tk_your_ntfy_token` |
| Custom Service | `X-Service-Key` | `service-specific-key` |
## Custom Payload Templates
For generic webhooks, you can define custom JSON payloads using Go template syntax.
### Available Variables
| Variable | Description | Example Value |
|----------|-------------|---------------|
| `{{.ID}}` | Alert ID | "alert-123" |
| `{{.Level}}` | Alert level | "warning", "critical" |
| `{{.Type}}` | Resource type | "cpu", "memory", "disk" |
| `{{.ResourceName}}` | Name of the resource | "Web Server VM" |
| `{{.ResourceID}}` | Resource identifier | "vm-100" |
| `{{.Node}}` | Proxmox node name | "pve-node-01" |
| `{{.Instance}}` | Proxmox instance URL | "https://192.168.1.100:8006" |
| `{{.Message}}` | Alert message | "CPU usage exceeded 90%" |
| `{{.Value}}` | Current metric value | 95.5 |
| `{{.Threshold}}` | Alert threshold | 90.0 |
| `{{.Duration}}` | How long alert has been active | "5m" |
| `{{.Timestamp}}` | Current timestamp | "2024-01-15T10:30:00Z" |
| `{{.StartTime}}` | When alert started | "2024-01-15T10:25:00Z" |
### Template Functions
| Function | Description | Example |
|----------|-------------|---------|
| `{{.Level \| title}}` | Capitalize first letter | "Warning" |
| `{{.Level \| upper}}` | Uppercase | "WARNING" |
| `{{.Level \| lower}}` | Lowercase | "warning" |
| `{{printf "%.1f" .Value}}` | Format numbers | "95.5" |
### Example Templates
#### Simple JSON
```json
{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}
```
#### Formatted Alert
```json
{
"alert": {
"level": "{{.Level | upper}}",
"message": "{{.Message}}",
"details": {
"resource": "{{.ResourceName}}",
"node": "{{.Node}}",
"current_value": "{{printf "%.1f" .Value}}%",
"threshold": "{{printf "%.0f" .Threshold}}%",
"duration": "{{.Duration}}"
}
},
"timestamp": "{{.Timestamp}}"
}
```
#### Slack-Compatible Custom Format
```json
{
"text": "Pulse Alert",
"attachments": [{
"color": "{{if eq .Level "critical"}}danger{{else}}warning{{end}}",
"title": "{{.Level | title}} Alert: {{.ResourceName}}",
"text": "{{.Message}}",
"fields": [
{"title": "Value", "value": "{{printf "%.1f" .Value}}%", "short": true},
{"title": "Threshold", "value": "{{printf "%.0f" .Threshold}}%", "short": true},
{"title": "Node", "value": "{{.Node}}", "short": true},
{"title": "Duration", "value": "{{.Duration}}", "short": true}
],
"footer": "Pulse Monitoring",
"ts": {{.Timestamp}}
}]
}
```
#### Home Assistant
```json
{
"title": "Pulse Alert: {{.Level | title}}",
"message": "{{.Message}}",
"data": {
"entity_id": "sensor.{{.Node | lower}}_{{.Type}}",
"state": {{.Value}},
"attributes": {
"resource": "{{.ResourceName}}",
"threshold": {{.Threshold}},
"duration": "{{.Duration}}"
}
}
}
```
#### n8n / Node-RED
```json
{
"workflow": "pulse_alert",
"data": {
"alert_id": "{{.ID}}",
"level": "{{.Level}}",
"resource": "{{.ResourceName}}",
"node": "{{.Node}}",
"metric": {
"type": "{{.Type}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
},
"message": "{{.Message}}",
"timestamp": "{{.Timestamp}}"
}
}
```
## Testing Webhooks
1. After configuring a webhook, click the **Test** button
2. Pulse will send a test alert to verify the webhook is working
3. Check the receiving service to confirm the message arrived
4. If the test fails, verify:
- The URL is correct and accessible
- Any required authentication tokens are included
- The payload format matches what the service expects
## Troubleshooting
### Webhook Returns 400 Bad Request
- Check if the payload format is correct for your service
- For Telegram, ensure chat_id is in the URL (Pulse handles it automatically)
- Verify all required fields are present in custom templates
### Webhook Returns 401/403
- Check authentication tokens/keys
- Verify the webhook URL hasn't expired
- Ensure IP restrictions allow Pulse server
### No Notifications Received
- Verify the webhook is enabled
- Check alert thresholds are configured correctly
- Ensure notification cooldown period has passed
- Test the webhook manually using the Test button
## API Reference
### Create Webhook
```bash
POST /api/notifications/webhooks
Content-Type: application/json
{
"name": "My Webhook",
"url": "https://example.com/webhook",
"method": "POST",
"service": "generic",
"enabled": true,
"template": "{\"alert\": \"{{.Message}}\"}"
}
```
### Test Webhook
```bash
POST /api/notifications/webhooks/test
Content-Type: application/json
{
"name": "Test",
"url": "https://example.com/webhook",
"service": "generic",
"template": "{\"test\": true}"
}
```
### Update Webhook
```bash
PUT /api/notifications/webhooks/{id}
Content-Type: application/json
{
"name": "Updated Webhook",
"url": "https://example.com/new-webhook",
"enabled": false
}
```
### Delete Webhook
```bash
DELETE /api/notifications/webhooks/{id}
```
### List Webhooks
```bash
GET /api/notifications/webhooks
```
## Security Considerations
- **Never expose webhook URLs publicly** - they often contain authentication tokens
- **Use HTTPS URLs** when possible to encrypt data in transit
- **Rotate webhook URLs periodically** if they contain embedded tokens
- **Test webhooks carefully** to avoid sending test data to production channels
- **Limit webhook permissions** in the receiving service where possible

View File

@@ -0,0 +1,239 @@
# Mock Mode Development Guide
Mock mode allows you to develop and test Pulse without requiring real Proxmox infrastructure. It generates realistic mock data including nodes, VMs, containers, storage, backups, and alerts.
## Quick Start
### Toggling Mock Mode
During hot-dev mode (`scripts/hot-dev.sh`), use these npm commands to toggle mock mode:
```bash
# Enable mock mode
npm run mock:on
# Disable mock mode (use real infrastructure)
npm run mock:off
# Check current status
npm run mock:status
# Edit mock configuration
npm run mock:edit
```
The backend will **automatically reload** when you toggle mock mode - no manual restarts needed!
### Configuration
Mock mode is configured via `mock.env` in the project root. This file is **tracked in the repository** with sensible defaults, so mock mode works out of the box for all developers.
**Default configuration (mock.env):**
```bash
PULSE_MOCK_MODE=false # Disabled by default
PULSE_MOCK_NODES=7
PULSE_MOCK_VMS_PER_NODE=5
PULSE_MOCK_LXCS_PER_NODE=8
PULSE_MOCK_DOCKER_HOSTS=3
PULSE_MOCK_DOCKER_CONTAINERS=12
PULSE_MOCK_RANDOM_METRICS=true
PULSE_MOCK_STOPPED_PERCENT=20
```
**Local overrides (not tracked):**
Create `mock.env.local` for personal settings that won't be committed:
```bash
# mock.env.local - your personal settings
PULSE_MOCK_MODE=true # Always start in mock mode
PULSE_MOCK_NODES=3 # Fewer nodes for faster startup
```
The `.local` file overrides values from `mock.env`, and is gitignored to keep your personal preferences private.
**Configuration options:**
- `PULSE_MOCK_MODE`: Enable/disable mock mode (`true`/`false`)
- `PULSE_MOCK_NODES`: Number of nodes to generate (default: 7)
- `PULSE_MOCK_VMS_PER_NODE`: Average VMs per node (default: 5)
- `PULSE_MOCK_LXCS_PER_NODE`: Average containers per node (default: 8)
- `PULSE_MOCK_DOCKER_HOSTS`: Number of Docker hosts to generate (default: 3)
- `PULSE_MOCK_DOCKER_CONTAINERS`: Average containers per Docker host (default: 12)
- `PULSE_MOCK_RANDOM_METRICS`: Enable metric fluctuations (`true`/`false`)
- `PULSE_MOCK_STOPPED_PERCENT`: Percentage of guests in stopped state (default: 20)
### Data Isolation
Hot-dev mode now isolates mock data from production credentials automatically:
- **Mock mode:** data lives in `/opt/pulse/tmp/mock-data`
- **Production mode:** data lives in `/etc/pulse`
The toggle script exports `PULSE_DATA_DIR` before launching the backend, creates the temporary directory if needed, and cleans up on exit. This guarantees mock credentials never overwrite your real cluster configuration and makes it safe to flip between datasets repeatedly during a session.
## Hot-Dev Workflow
1. **Start hot-dev mode:**
```bash
scripts/hot-dev.sh
```
2. **Toggle mock mode as needed:**
```bash
npm run mock:on # Backend auto-reloads with mock data
npm run mock:off # Backend auto-reloads with real data
```
3. **Edit mock configuration:**
```bash
npm run mock:edit # Opens mock.env in your editor
# Save and exit - backend auto-reloads!
```
4. **Frontend changes:** Just save your files - Vite hot-reloads instantly
**No port changes. No manual restarts. Everything just works!**
## Mock Data Generation
Mock mode generates:
- **Nodes**: Mix of clustered and standalone nodes
- **Cluster**: First 5 nodes form `mock-cluster`, rest are standalone
- **VMs & Containers**: Realistic distribution with various states
- **Storage**: Local, ZFS, PBS, and shared NFS storage
- **Backups**: Both PVE and PBS backups with realistic metadata
- **Mail Gateway**: PMG instances with mail throughput, spam/virus totals, and quarantine counts
- **Alerts**: CPU, memory, disk, and connectivity alerts
- **Metrics**: Live-updating metrics every 2 seconds
### Node Characteristics
- **Clustered nodes** (`pve1`-`pve5`): Part of `mock-cluster`
- **Standalone nodes** (`standalone1`, etc.): Independent instances
- **Offline nodes**: `pve3` is always offline to test error handling
- **Host URLs**: Each node has `Host` field set (e.g., `https://pve1.local:8006`)
- **Cluster fields**: `IsClusterMember` and `ClusterName` properly set
## API Behavior in Mock Mode
### Fast, Cached Responses
In mock mode, `/api/state` returns **cached data instantly** - no locks, no delays, no timeouts. The mock data is stored in memory and updated every 2 seconds with realistic metric fluctuations.
### WebSocket Updates
The WebSocket connection receives updates every 2 seconds with changing metrics, just like production.
### Dashboard Grouping
Mock nodes include all required fields for proper dashboard grouping:
- `isClusterMember`: Boolean indicating cluster membership
- `clusterName`: Name of the cluster (e.g., "mock-cluster")
- `host`: Full node URL (e.g., "https://pve1.local:8006")
## Demo Server Usage
On the demo server, mock mode works the same way:
### Systemd Service
If using systemd (`pulse-dev` service):
```bash
# Toggle mock mode (restarts service)
sudo /opt/pulse/scripts/toggle-mock.sh on
sudo /opt/pulse/scripts/toggle-mock.sh off
# Check status
/opt/pulse/scripts/toggle-mock.sh status
```
### Manual Mode
If running the backend manually:
```bash
# Edit mock.env
nano /opt/pulse/mock.env
# The file watcher will detect changes and auto-reload the backend
# within 5 seconds (or immediately if fsnotify is working)
```
## File Watcher Details
The backend watches `mock.env` using:
1. **Primary**: `fsnotify` (instant notification on file changes)
2. **Fallback**: Polling every 5 seconds (if fsnotify fails)
When `mock.env` changes:
- Environment variables are updated
- Monitor is reloaded with new configuration
- New mock data is generated
- WebSocket clients receive updated state
**No manual process restarts required!**
## Troubleshooting
### Mock mode not updating
1. Check that mock.env exists: `ls -la /opt/pulse/mock.env`
2. Check file watcher logs: Look for "Detected mock.env file change" in logs
3. Verify environment variables: `env | grep PULSE_MOCK`
4. Try touching the file: `touch /opt/pulse/mock.env`
### Backend not reloading
1. Ensure hot-dev mode is running (not systemd service)
2. Check for errors in backend logs
3. Verify file watcher started successfully
4. Fall back to manual restart if needed
### Missing cluster grouping
1. Verify mock data includes `isClusterMember` and `clusterName`
2. Check API response: `curl http://localhost:7656/api/state | jq '.nodes[0]'`
3. Ensure frontend is receiving WebSocket updates
## Implementation Details
### Backend Components
- **Config Watcher** (`internal/config/watcher.go`): Watches both `.env` and `mock.env`
- **Mock Integration** (`internal/mock/integration.go`): Manages mock state and updates
- **Mock Generator** (`internal/mock/generator.go`): Generates realistic mock data
- **Monitor** (`internal/monitoring/monitor.go`): Returns cached mock data when enabled
### Auto-Reload Flow
1. User runs `npm run mock:on` (or edits `mock.env`)
2. `toggle-mock.sh` updates `mock.env` and touches the file
3. Config watcher detects file change (via fsnotify or polling)
4. Watcher triggers reload callback
5. ReloadableMonitor reloads with fresh config
6. New monitor instance starts with updated mock mode
7. WebSocket broadcasts new state to connected clients
### Performance
- Mock data generation: < 100ms for 7 nodes with 90+ guests
- State snapshot: Instant (returns cached data)
- Memory usage: ~50MB additional for mock data
- Update interval: 2 seconds for metric fluctuations
## Best Practices
1. **Use mock mode for frontend development** - Fast, predictable data
2. **Test with real data before PRs** - Ensure real infrastructure works
3. **Adjust mock config to test edge cases** - High load, many nodes, etc.
4. **Use mock.env.local for personal settings** - Your preferences won't be committed
5. **Keep mock.env defaults reasonable** - Other developers will use them
6. **Document any mock data assumptions** - Help other developers
## See Also
- [CONFIGURATION.md](../CONFIGURATION.md) - Production configuration
- [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) - Common issues
- [API.md](../API.md) - API documentation

View File

@@ -0,0 +1,27 @@
# mock.env.local.example
# Copy this to /opt/pulse/mock.env.local for your personal mock mode settings
# The .local file is gitignored and will override values from mock.env
#
# Usage:
# cp docs/development/mock.env.local.example mock.env.local
# # Edit mock.env.local with your preferences
# npm run mock:on
# Example: Always start in mock mode for frontend development
PULSE_MOCK_MODE=true
# Example: Use fewer nodes for faster startup
PULSE_MOCK_NODES=3
# Example: More VMs for testing high-density scenarios
# PULSE_MOCK_VMS_PER_NODE=10
# Example: Adjust Docker coverage
# PULSE_MOCK_DOCKER_HOSTS=2
# PULSE_MOCK_DOCKER_CONTAINERS=6
# Example: Disable metric fluctuations for consistent testing
# PULSE_MOCK_RANDOM_METRICS=false
# Example: All guests running (no stopped VMs/containers)
# PULSE_MOCK_STOPPED_PERCENT=0

View File

@@ -0,0 +1,82 @@
# Frontend UI Style Guide
This project now ships a handful of shared primitives to keep typography and form layouts consistent. The snippets below show the preferred usage.
## Section headers
Use `SectionHeader` for any inline card titles, modal headings, or sub-section titles instead of ad-hoc `<h2>`/`<h3>` elements.
```tsx
import { SectionHeader } from '@/components/shared/SectionHeader';
<SectionHeader
label="Overview"
title="Cluster health"
description="Key metrics across every node"
size="sm" // sm | md | lg (defaults to md)
align="left" // left | center (defaults to left)
/>
```
Pass `titleClass`/`descriptionClass` when you need to tweak color or emphasis without rebuilding the layout.
## Empty states
Whenever a panel needs to show a loading, error, or "no data" treatment, render `EmptyState` inside a `Card`.
```tsx
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
<Card padding="lg" tone="info">
<EmptyState
align="center" // center | left (defaults to center)
tone="info" // default | info | success | warning | danger
icon={<MyIcon class="h-12 w-12 text-blue-400" />}
title="No backups yet"
description="Run your first job or adjust the filters to see activity."
actions={(
<Button onClick={openScheduler}>Open Scheduler</Button>
)}
/>
</Card>
```
Icons and actions are optional; omit them when not needed.
## Form helpers
Shared form styles live in `@/components/shared/Form`. Import the helpers and apply them to each field container, label, and control for a uniform look.
```tsx
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Host URL <span class="text-red-500">*</span>
</label>
<input
type="url"
placeholder="https://cluster.example.com:8006"
class={controlClass('px-2 py-1.5 font-mono')}
/>
<p class={`${formHelpText} mt-1`}>
Use HTTPS on port 8006 for Proxmox VE and 8007 for PBS.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class={formCheckbox} />
Enable this integration
</label>
```
Helper summary:
- `formField`: wraps a label + control stack.
- `labelClass(extra?)`: base typography for labels, with optional extra classes.
- `controlClass(extra?)`: base input styling; append sizing tweaks (`px-2 py-1.5`) as needed.
- `formHelpText`: small secondary text (validation notes, hints).
- `formCheckbox`: shared checkbox styling for toggles inside copy-heavy forms.
Stick to these helpers when building new settings panels, modals, or detail cards. If a component needs a variant that the helpers do not cover, extend them in `Form.ts` so the convention remains centralized.

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

BIN
docs/images/02-storage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

BIN
docs/images/03-backups.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
docs/images/04-alerts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
docs/images/06-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
docs/images/08-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,16 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<title>Pulse Logo</title>
<style>
.pulse-bg { fill: #2563eb; }
.pulse-ring { fill: none; stroke: #ffffff; stroke-width: 14; opacity: 0.92; }
.pulse-center { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.pulse-bg { fill: #3b82f6; }
.pulse-ring { stroke: #dbeafe; }
.pulse-center { fill: #dbeafe; }
}
</style>
<circle class="pulse-bg" cx="128" cy="128" r="122"/>
<circle class="pulse-ring" cx="128" cy="128" r="84"/>
<circle class="pulse-center" cx="128" cy="128" r="26"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

98
docs/zfs-monitoring.md Normal file
View File

@@ -0,0 +1,98 @@
# ZFS Pool Monitoring
Pulse v4.15.0+ includes automatic ZFS pool health monitoring for Proxmox VE nodes.
## Features
- **Automatic Detection**: Detects ZFS storage and monitors associated pools
- **Health Status**: Monitors pool state (ONLINE, DEGRADED, FAULTED)
- **Error Tracking**: Tracks read, write, and checksum errors
- **Device Monitoring**: Monitors individual devices within pools
- **Alert Generation**: Creates alerts for degraded pools and device errors
- **Frontend Display**: Shows ZFS issues inline with storage information
## Requirements
### Proxmox Permissions
The Pulse user needs `Sys.Audit` permission on `/nodes/{node}/disks` to access ZFS information:
```bash
# Grant permission for ZFS monitoring (already included in standard Pulse role)
pveum acl modify /nodes -user pulse-monitor@pam -role PVEAuditor
```
### API Endpoints Used
- `/nodes/{node}/disks/zfs` - Lists ZFS pools
- `/nodes/{node}/disks/zfs/{pool}` - Gets detailed pool status
## Configuration
ZFS monitoring is **enabled by default** in Pulse v4.15.0+.
### Disabling ZFS Monitoring
If you want to disable ZFS monitoring (e.g., for performance reasons):
```bash
# Add to /opt/pulse/.env or environment
PULSE_DISABLE_ZFS_MONITORING=true
```
## Alert Types
### Pool State Alerts
- **Warning**: Pool is DEGRADED
- **Critical**: Pool is FAULTED or UNAVAIL
### Error Alerts
- **Warning**: Any read/write/checksum errors detected
- Alerts include error counts and affected devices
### Device Alerts
- **Warning**: Device has errors but is ONLINE
- **Critical**: Device is FAULTED or UNAVAIL
## Frontend Display
ZFS issues appear in the Storage tab:
- Yellow warning bar for degraded pools
- Red error counts for devices with issues
- Detailed device status for troubleshooting
## Performance Impact
- Adds 2 API calls per node with ZFS storage
- Typically adds <1 second to polling cycle
- Only queries nodes that have ZFS storage
## Troubleshooting
### No ZFS Data Appearing
1. Check permissions: `pveum user permissions pulse-monitor@pam`
2. Verify ZFS pools exist: `zpool list`
3. Check logs: `grep ZFS /opt/pulse/pulse.log`
### Permission Denied Errors
Grant the required permission:
```bash
pveum acl modify /nodes -user pulse-monitor@pam -role PVEAuditor
```
### High API Load
Disable ZFS monitoring if not needed:
```bash
echo "PULSE_DISABLE_ZFS_MONITORING=true" >> /opt/pulse/.env
systemctl restart pulse-backend
```
## Example Alert
```
Alert: ZFS pool 'rpool' is DEGRADED
Node: pve1
Pool: rpool
State: DEGRADED
Errors: 12 read, 0 write, 3 checksum
Device sdb2: DEGRADED with 12 read errors
```
This helps administrators identify failing drives before complete failure occurs.

View File

@@ -0,0 +1,3 @@
dist
node_modules
public

View File

@@ -0,0 +1,41 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:solid/typescript',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'solid'],
ignorePatterns: ['dist', 'node_modules'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-case-declarations': 'off',
'no-useless-escape': 'off',
'prefer-const': 'off',
'solid/reactivity': 'off',
'solid/prefer-for': 'off',
'solid/style-prop': 'off',
'solid/components-return-once': 'off',
'solid/self-closing-comp': 'off',
},
};

32
frontend-modern/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

View File

@@ -0,0 +1,3 @@
dist
node_modules
public

View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

4544
frontend-modern/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "pulse-modern",
"version": "1.0.0",
"description": "Modern type-safe frontend for Pulse monitoring",
"type": "module",
"author": "rcourtman",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/rcourtman/Pulse.git"
},
"bugs": {
"url": "https://github.com/rcourtman/Pulse/issues"
},
"homepage": "https://github.com/rcourtman/Pulse",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"generate-types": "cd ../scripts && go run generate-types.go",
"type-check": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\""
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"lucide-solid": "^0.545.0",
"simple-icons": "^13.21.0",
"solid-js": "^1.8.0",
"ws": "^8.18.3"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-solid": "^0.14.0",
"postcss": "^8.4.0",
"prettier": "^3.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.8.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,16 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<title>Pulse Logo</title>
<style>
.pulse-bg { fill: #2563eb; }
.pulse-ring { fill: none; stroke: #ffffff; stroke-width: 14; opacity: 0.92; }
.pulse-center { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.pulse-bg { fill: #3b82f6; }
.pulse-ring { stroke: #dbeafe; }
.pulse-center { fill: #dbeafe; }
}
</style>
<circle class="pulse-bg" cx="128" cy="128" r="122"/>
<circle class="pulse-ring" cx="128" cy="128" r="84"/>
<circle class="pulse-center" cx="128" cy="128" r="26"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1,98 @@
#!/bin/bash
set -e
# Get version from VERSION file
VERSION=$(cat VERSION)
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}Building Pulse v${VERSION} release binaries${NC}"
# Create release directory
mkdir -p release
# Build frontend first
echo -e "${BLUE}Building frontend...${NC}"
cd frontend-modern
npm run build
cd ..
# Copy frontend to internal/api for embedding
echo -e "${BLUE}Copying frontend for embedding...${NC}"
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
# Build for multiple architectures
echo -e "${BLUE}Building linux/amd64...${NC}"
GOOS=linux GOARCH=amd64 go build -o release/pulse-linux-amd64 ./cmd/pulse
echo -e "${BLUE}Building linux/arm64...${NC}"
GOOS=linux GOARCH=arm64 go build -o release/pulse-linux-arm64 ./cmd/pulse
echo -e "${BLUE}Building linux/arm (v7)...${NC}"
GOOS=linux GOARCH=arm GOARM=7 go build -o release/pulse-linux-armv7 ./cmd/pulse
# Create tarballs for each architecture
echo -e "${BLUE}Creating architecture-specific tarballs...${NC}"
# AMD64
cd release
mkdir -p temp-amd64
cp pulse-linux-amd64 temp-amd64/pulse
cp ../VERSION temp-amd64/VERSION
mkdir -p temp-amd64/frontend-modern
cp -r ../internal/api/frontend-modern/dist/* temp-amd64/frontend-modern/
tar -czf pulse-v${VERSION}-linux-amd64.tar.gz -C temp-amd64 .
rm -rf temp-amd64
# ARM64
mkdir -p temp-arm64
cp pulse-linux-arm64 temp-arm64/pulse
cp ../VERSION temp-arm64/VERSION
mkdir -p temp-arm64/frontend-modern
cp -r ../internal/api/frontend-modern/dist/* temp-arm64/frontend-modern/
tar -czf pulse-v${VERSION}-linux-arm64.tar.gz -C temp-arm64 .
rm -rf temp-arm64
# ARMv7
mkdir -p temp-armv7
cp pulse-linux-armv7 temp-armv7/pulse
cp ../VERSION temp-armv7/VERSION
mkdir -p temp-armv7/frontend-modern
cp -r ../internal/api/frontend-modern/dist/* temp-armv7/frontend-modern/
tar -czf pulse-v${VERSION}-linux-armv7.tar.gz -C temp-armv7 .
rm -rf temp-armv7
# Create universal tarball with all binaries
echo -e "${BLUE}Creating universal tarball...${NC}"
mkdir -p temp-universal
cp pulse-linux-amd64 temp-universal/
cp pulse-linux-arm64 temp-universal/
cp pulse-linux-armv7 temp-universal/
ln -sf pulse-linux-amd64 temp-universal/pulse
cp ../VERSION temp-universal/VERSION
mkdir -p temp-universal/frontend-modern
cp -r ../internal/api/frontend-modern/dist/* temp-universal/frontend-modern/
tar -czf pulse-v${VERSION}.tar.gz -C temp-universal .
rm -rf temp-universal
# Generate checksums
echo -e "${BLUE}Generating checksums...${NC}"
sha256sum pulse-v${VERSION}-linux-amd64.tar.gz > checksums.txt
sha256sum pulse-v${VERSION}-linux-arm64.tar.gz >> checksums.txt
sha256sum pulse-v${VERSION}-linux-armv7.tar.gz >> checksums.txt
sha256sum pulse-v${VERSION}.tar.gz >> checksums.txt
cd ..
echo -e "${GREEN}✓ Build complete!${NC}"
echo ""
echo "Release files:"
ls -lh release/
echo ""
echo "Checksums:"
cat release/checksums.txt

1019
frontend-modern/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import { createSignal, onMount } from 'solid-js';
export default function SimpleApp() {
const [status, setStatus] = createSignal('Initializing...');
const [data, setData] = createSignal<any>(null);
const [wsStatus, setWsStatus] = createSignal('Not connected');
onMount(() => {
setStatus('Testing API connection...');
// Test API
fetch('/api/health')
.then((res) => {
setStatus(`API Status: ${res.status}`);
return res.json();
})
.then((d) => {
setData(d);
setStatus('API Connected! Testing WebSocket...');
// Test WebSocket
const ws = new WebSocket(`ws://${window.location.host}/ws`);
ws.onopen = () => {
setWsStatus('WebSocket CONNECTED');
setStatus('Everything working!');
};
ws.onerror = (e) => {
setWsStatus('WebSocket ERROR');
console.error('WS Error:', e);
};
ws.onclose = (e) => {
setWsStatus(`WebSocket CLOSED: ${e.code} - ${e.reason}`);
};
ws.onmessage = (e) => {
setWsStatus('WebSocket receiving data!');
try {
const msg = JSON.parse(e.data);
setData((prev) => ({ ...prev, lastMessage: msg.type }));
} catch (err) {
console.error('Parse error:', err);
}
};
})
.catch((err) => {
setStatus(`API Error: ${err}`);
console.error(err);
});
});
return (
<div style={{ padding: '20px', 'font-family': 'monospace' }}>
<h1>Pulse System Test</h1>
<hr />
<p>
<strong>Status:</strong> {status()}
</p>
<p>
<strong>WebSocket:</strong> {wsStatus()}
</p>
<hr />
<h3>API Data:</h3>
<pre>{JSON.stringify(data(), null, 2)}</pre>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function Test() {
return (
<div style={{ padding: '20px', 'font-size': '24px' }}>
<h1>TEST - APP IS WORKING!</h1>
<p>If you see this, the basic app infrastructure works.</p>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import type { Alert } from '@/types/api';
import type { AlertConfig } from '@/types/alerts';
import { apiFetchJSON } from '@/utils/apiClient';
// Error handling utilities available for future use
// import { handleError, createErrorBoundary } from '@/utils/errorHandler';
export class AlertsAPI {
private static baseUrl = '/api/alerts';
static async getActive(): Promise<Alert[]> {
return apiFetchJSON(`${this.baseUrl}/active`);
}
static async getHistory(params?: {
limit?: number;
offset?: number;
startTime?: string;
endTime?: string;
severity?: 'warning' | 'critical' | 'all';
resourceId?: string;
}): Promise<Alert[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, value.toString());
}
});
}
return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`);
}
// Removed unused config methods - not implemented in backend
static async acknowledge(alertId: string, user?: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/acknowledge`, {
method: 'POST',
body: JSON.stringify({ user }),
});
}
static async unacknowledge(alertId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/unacknowledge`, {
method: 'POST',
});
}
// Alert configuration methods
static async getConfig(): Promise<AlertConfig> {
return apiFetchJSON(`${this.baseUrl}/config`);
}
static async updateConfig(config: AlertConfig): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/config`, {
method: 'PUT',
body: JSON.stringify(config),
});
}
static async clearAlert(alertId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(alertId)}/clear`, {
method: 'POST',
});
}
static async clearHistory(): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/history`, {
method: 'DELETE',
});
}
static async bulkAcknowledge(
alertIds: string[],
user?: string,
): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
return apiFetchJSON(`${this.baseUrl}/bulk/acknowledge`, {
method: 'POST',
body: JSON.stringify({ alertIds, user }),
});
}
static async bulkClear(
alertIds: string[],
): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
return apiFetchJSON(`${this.baseUrl}/bulk/clear`, {
method: 'POST',
body: JSON.stringify({ alertIds }),
});
}
}

View File

@@ -0,0 +1,41 @@
// Guest Metadata API
import { apiFetchJSON } from '@/utils/apiClient';
export interface GuestMetadata {
id: string;
customUrl?: string;
description?: string;
tags?: string[];
}
export class GuestMetadataAPI {
private static baseUrl = '/api/guests/metadata';
// Get metadata for a specific guest
static async getMetadata(guestId: string): Promise<GuestMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(guestId)}`);
}
// Get all guest metadata
static async getAllMetadata(): Promise<Record<string, GuestMetadata>> {
return apiFetchJSON(this.baseUrl);
}
// Update metadata for a guest
static async updateMetadata(
guestId: string,
metadata: Partial<GuestMetadata>,
): Promise<GuestMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(guestId)}`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
// Delete metadata for a guest
static async deleteMetadata(guestId: string): Promise<void> {
await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(guestId)}`, {
method: 'DELETE',
});
}
}

View File

@@ -0,0 +1,59 @@
import type { State, Performance, Stats } from '@/types/api';
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
export class MonitoringAPI {
private static baseUrl = '/api';
static async getState(): Promise<State> {
return apiFetchJSON(`${this.baseUrl}/state`);
}
static async getPerformance(): Promise<Performance> {
return apiFetchJSON(`${this.baseUrl}/performance`);
}
static async getStats(): Promise<Stats> {
return apiFetchJSON(`${this.baseUrl}/stats`);
}
static async exportDiagnostics(): Promise<Blob> {
const response = await apiFetch(`${this.baseUrl}/diagnostics/export`);
return response.blob();
}
static async deleteDockerHost(hostId: string): Promise<void> {
const response = await apiFetch(
`${this.baseUrl}/agents/docker/hosts/${encodeURIComponent(hostId)}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
if (response.status === 404) {
// Host already gone; treat as success so UI state stays consistent
return;
}
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_jsonErr) {
// ignore JSON parse errors, fallback to raw text
}
}
} catch (_err) {
// ignore read error, keep default message
}
throw new Error(message);
}
}
}

View File

@@ -0,0 +1,59 @@
import { NodeConfig } from '../types/nodes';
import { apiFetchJSON } from '@/utils/apiClient';
export class NodesAPI {
private static readonly baseUrl = '/api/config/nodes';
static async getNodes(): Promise<NodeConfig[]> {
// The API returns an array of nodes directly
const nodes: NodeConfig[] = await apiFetchJSON(this.baseUrl);
return nodes;
}
static async addNode(node: NodeConfig): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(this.baseUrl, {
method: 'POST',
body: JSON.stringify(node),
});
}
static async updateNode(
nodeId: string,
node: NodeConfig,
): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}`, {
method: 'PUT',
body: JSON.stringify(node),
});
}
static async deleteNode(nodeId: string): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}`, {
method: 'DELETE',
});
}
static async testConnection(node: NodeConfig): Promise<{
status: string;
message?: string;
isCluster?: boolean;
nodeCount?: number;
clusterNodeCount?: number;
datastoreCount?: number;
}> {
return apiFetchJSON(`${this.baseUrl}/test-connection`, {
method: 'POST',
body: JSON.stringify(node),
});
}
static async testExistingNode(nodeId: string): Promise<{
status: string;
message?: string;
latency?: number;
}> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}/test`, {
method: 'POST',
});
}
}

View File

@@ -0,0 +1,176 @@
import { apiFetchJSON } from '@/utils/apiClient';
export interface EmailProvider {
id?: string;
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
server?: string;
port?: number;
security?: 'none' | 'tls' | 'starttls';
}
export interface WebhookTemplate {
id?: string;
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: string;
description?: string;
template?: {
url?: string;
method?: string;
headers?: Record<string, string>;
body?: string;
};
}
export interface EmailConfig {
enabled: boolean;
provider: string;
server: string;
port: number;
username: string;
password?: string;
from: string;
to: string[];
tls: boolean;
startTLS: boolean;
}
export interface Webhook {
id: string;
name: string;
url: string;
method: string;
headers: Record<string, string>;
template?: string;
enabled: boolean;
service?: string; // Added to support Discord, Slack, etc.
customFields?: Record<string, string>;
}
export interface NotificationTestRequest {
type: 'email' | 'webhook';
config?: Record<string, unknown>; // Backend expects different format than frontend types
webhookId?: string;
}
export class NotificationsAPI {
private static baseUrl = '/api/notifications';
// Email configuration
static async getEmailConfig(): Promise<EmailConfig> {
const backendConfig = await apiFetchJSON<Record<string, unknown>>(`${this.baseUrl}/email`);
// Backend already returns fields with correct names (server, port)
return {
enabled: (backendConfig.enabled as boolean) || false,
provider: (backendConfig.provider as string) || '',
server: (backendConfig.server as string) || '',
port: (backendConfig.port as number) || 587,
username: (backendConfig.username as string) || '',
password: (backendConfig.password as string) || '',
from: (backendConfig.from as string) || '',
to: (backendConfig.to as string[]) || [],
tls: (backendConfig.tls as boolean) || false,
startTLS: (backendConfig.startTLS as boolean) || false,
};
}
static async updateEmailConfig(config: EmailConfig): Promise<{ success: boolean }> {
// Backend expects fields with these names (server, port)
const backendConfig = {
enabled: config.enabled,
server: config.server,
port: config.port,
username: config.username,
password: config.password,
from: config.from,
to: config.to,
tls: config.tls || false,
startTLS: config.startTLS || false,
provider: config.provider || '',
};
return apiFetchJSON(`${this.baseUrl}/email`, {
method: 'PUT',
body: JSON.stringify(backendConfig),
});
}
// Webhook management
static async getWebhooks(): Promise<Webhook[]> {
const data = await apiFetchJSON<Webhook[] | null>(`${this.baseUrl}/webhooks`);
return Array.isArray(data) ? data : [];
}
static async createWebhook(webhook: Omit<Webhook, 'id'>): Promise<Webhook> {
return apiFetchJSON(`${this.baseUrl}/webhooks`, {
method: 'POST',
body: JSON.stringify(webhook),
});
}
static async updateWebhook(id: string, webhook: Partial<Webhook>): Promise<Webhook> {
return apiFetchJSON(`${this.baseUrl}/webhooks/${id}`, {
method: 'PUT',
body: JSON.stringify(webhook),
});
}
static async deleteWebhook(id: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/webhooks/${id}`, {
method: 'DELETE',
});
}
// Templates and providers
static async getEmailProviders(): Promise<EmailProvider[]> {
return apiFetchJSON(`${this.baseUrl}/email-providers`);
}
static async getWebhookTemplates(): Promise<WebhookTemplate[]> {
return apiFetchJSON(`${this.baseUrl}/webhook-templates`);
}
// Testing
static async testNotification(
request: NotificationTestRequest,
): Promise<{ success: boolean; message?: string }> {
const body: { method: string; config?: Record<string, unknown>; webhookId?: string } = {
method: request.type,
};
// Include config if provided for testing without saving
if (request.config) {
body.config = request.config;
}
// Include webhookId for webhook testing
if (request.webhookId) {
body.webhookId = request.webhookId;
}
return apiFetchJSON(`${this.baseUrl}/test`, {
method: 'POST',
body: JSON.stringify(body),
});
}
static async testWebhook(
webhook: Omit<Webhook, 'id'>,
): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/webhooks/test`, {
method: 'POST',
body: JSON.stringify(webhook),
});
}
}

View File

@@ -0,0 +1,47 @@
import type { SettingsResponse, SettingsUpdateRequest } from '@/types/settings';
import type { SystemConfig } from '@/types/config';
import { apiFetchJSON } from '@/utils/apiClient';
// Response types
export interface ApiResponse<T = unknown> {
success?: boolean;
status?: string;
message?: string;
data?: T;
}
export class SettingsAPI {
private static baseUrl = '/api';
static async getSettings(): Promise<SettingsResponse> {
return apiFetchJSON(`${this.baseUrl}/settings`) as Promise<SettingsResponse>;
}
// Full settings update (legacy - avoid using)
static async updateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/settings/update`, {
method: 'POST',
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
// System settings update (preferred) - uses SystemConfig type from config.ts
static async updateSystemSettings(settings: Partial<SystemConfig>): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/system/settings/update`, {
method: 'POST',
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
// Get system settings - returns SystemConfig
static async getSystemSettings(): Promise<SystemConfig> {
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemConfig>;
}
static async validateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/settings/validate`, {
method: 'POST',
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
}

View File

@@ -0,0 +1,25 @@
// System API for managing system settings
import { apiFetchJSON } from '@/utils/apiClient';
export interface SystemSettings {
// Note: PVE polling is hardcoded to 10s server-side
updateChannel?: string;
autoUpdateEnabled: boolean;
autoUpdateCheckInterval?: number;
autoUpdateTime?: string;
// apiToken removed - now handled via security API
}
export class SystemAPI {
// System Settings
static async getSystemSettings(): Promise<SystemSettings> {
return apiFetchJSON('/api/system/settings');
}
static async updateSystemSettings(settings: Partial<SystemSettings>): Promise<void> {
await apiFetchJSON('/api/system/settings/update', {
method: 'POST',
body: JSON.stringify(settings),
});
}
}

View File

@@ -0,0 +1,109 @@
// Remove apiRequest import - use fetch directly
import { apiFetchJSON } from '@/utils/apiClient';
export interface UpdateInfo {
available: boolean;
currentVersion: string;
latestVersion: string;
releaseNotes: string;
releaseDate: string;
downloadUrl: string;
isPrerelease: boolean;
warning?: string;
}
export interface UpdateStatus {
status: string;
progress: number;
message: string;
error?: string;
updatedAt: string;
}
export interface VersionInfo {
version: string;
build: string;
runtime: string;
channel?: string;
isDocker: boolean;
isDevelopment: boolean;
deploymentType?: string;
}
export interface UpdatePlan {
canAutoUpdate: boolean;
instructions?: string[];
prerequisites?: string[];
estimatedTime?: string;
requiresRoot: boolean;
rollbackSupport: boolean;
downloadUrl?: string;
}
export interface UpdateHistoryEntry {
event_id: string;
timestamp: string;
action: 'update' | 'rollback';
channel: string;
version_from: string;
version_to: string;
deployment_type: string;
initiated_by: 'user' | 'auto' | 'api';
initiated_via: 'ui' | 'cli' | 'script' | 'webhook';
status: 'in_progress' | 'success' | 'failed' | 'rolled_back' | 'cancelled';
duration_ms: number;
backup_path?: string;
log_path?: string;
error?: {
message: string;
code?: string;
details?: string;
};
download_bytes?: number;
related_event_id?: string;
notes?: string;
}
export class UpdatesAPI {
static async checkForUpdates(channel?: string): Promise<UpdateInfo> {
const url = channel ? `/api/updates/check?channel=${channel}` : '/api/updates/check';
return apiFetchJSON(url);
}
static async applyUpdate(downloadUrl: string): Promise<{ status: string; message: string }> {
return apiFetchJSON('/api/updates/apply', {
method: 'POST',
body: JSON.stringify({ downloadUrl }),
});
}
static async getUpdateStatus(): Promise<UpdateStatus> {
return apiFetchJSON('/api/updates/status');
}
static async getVersion(): Promise<VersionInfo> {
return apiFetchJSON('/api/version');
}
static async getUpdatePlan(version: string, channel?: string): Promise<UpdatePlan> {
const url = channel
? `/api/updates/plan?version=${version}&channel=${channel}`
: `/api/updates/plan?version=${version}`;
return apiFetchJSON(url);
}
static async getUpdateHistory(
limit?: number,
status?: string
): Promise<UpdateHistoryEntry[]> {
const params = new URLSearchParams();
if (limit) params.append('limit', limit.toString());
if (status) params.append('status', status);
const url = `/api/updates/history${params.toString() ? `?${params.toString()}` : ''}`;
return apiFetchJSON(url);
}
static async getUpdateHistoryEntry(eventId: string): Promise<UpdateHistoryEntry> {
return apiFetchJSON(`/api/updates/history/entry?id=${eventId}`);
}
}

View File

@@ -0,0 +1,333 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
import {
formField,
labelClass,
controlClass,
formHelpText,
formCheckbox,
} from '@/components/shared/Form';
interface EmailProvider {
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
}
interface EmailConfig {
enabled: boolean;
provider: string;
server: string;
port: number;
from: string;
username: string;
password: string;
to: string[];
tls: boolean;
startTLS: boolean;
replyTo: string;
maxRetries: number;
retryDelay: number;
rateLimit: number;
}
interface EmailProviderSelectProps {
config: EmailConfig;
onChange: (config: EmailConfig) => void;
onTest: () => void;
testing?: boolean;
}
export function EmailProviderSelect(props: EmailProviderSelectProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [showInstructions, setShowInstructions] = createSignal(false);
// Load email providers once
createEffect(async () => {
try {
const data = await NotificationsAPI.getEmailProviders();
setProviders(data);
} catch (err) {
console.error('Failed to load email providers:', err);
}
});
const applyProvider = (provider: EmailProvider | undefined) => {
if (!provider) {
props.onChange({ ...props.config, provider: '' });
setShowInstructions(false);
return;
}
props.onChange({
...props.config,
provider: provider.name,
server: provider.smtpHost,
port: provider.smtpPort,
tls: provider.tls,
startTLS: provider.startTLS,
username: provider.name === 'SendGrid' ? 'apikey' : props.config.username,
});
setShowInstructions(true);
};
const handleProviderChange = (value: string) => {
if (!value) {
applyProvider(undefined);
return;
}
const provider = providers().find((p) => p.name === value);
applyProvider(provider);
};
const currentProvider = () => providers().find((p) => p.name === props.config.provider);
const instructionBoxClass = "mt-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs leading-relaxed text-blue-900 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200";
return (
<div class="space-y-4 text-sm overflow-hidden">
<div class={formField}>
<label class={labelClass()}>Email provider</label>
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<select
value={props.config.provider}
onChange={(e) => handleProviderChange(e.currentTarget.value)}
class={`${controlClass('px-2 py-1.5')} sm:w-auto sm:min-w-[180px]`}
>
<option value="">Manual configuration</option>
<For each={providers()}>
{(provider) => (
<option value={provider.name}>
{provider.name} ({provider.smtpHost}:{provider.smtpPort})
</option>
)}
</For>
</select>
<Show when={props.config.provider}>
<button
type="button"
onClick={() => {
const provider = currentProvider();
if (provider) applyProvider(provider);
}}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Reapply defaults
</button>
</Show>
</div>
</div>
<Show when={currentProvider()}>
<div class="sm:hidden w-full">
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-300"
>
{showInstructions() ? 'Hide setup instructions' : 'Show setup instructions'}
</button>
<Show when={showInstructions()}>
<div class={instructionBoxClass}>
{currentProvider()!.instructions}
</div>
</Show>
</div>
<div class="hidden w-full sm:block">
<div class={instructionBoxClass}>
{currentProvider()!.instructions}
</div>
</div>
</Show>
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>SMTP server</label>
<input
type="text"
value={props.config.server}
onInput={(e) => props.onChange({ ...props.config, server: e.currentTarget.value })}
placeholder="smtp.example.com"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>SMTP port</label>
<input
type="number"
value={props.config.port}
onInput={(e) =>
props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })
}
placeholder="587"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>From address</label>
<input
type="email"
value={props.config.from}
onInput={(e) => props.onChange({ ...props.config, from: e.currentTarget.value })}
placeholder="noreply@example.com"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>Reply-to address</label>
<input
type="email"
value={props.config.replyTo || ''}
onInput={(e) => props.onChange({ ...props.config, replyTo: e.currentTarget.value })}
placeholder="admin@example.com"
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>Username</label>
<input
type="text"
value={props.config.username}
onInput={(e) => props.onChange({ ...props.config, username: e.currentTarget.value })}
placeholder={props.config.provider === 'SendGrid' ? 'apikey' : 'username@example.com'}
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>Password / API key</label>
<input
type="password"
value={props.config.password}
onInput={(e) => props.onChange({ ...props.config, password: e.currentTarget.value })}
placeholder="••••••••"
class={controlClass('px-2 py-1.5')}
/>
</div>
</div>
<div class={formField}>
<label class={labelClass()}>Recipients (one per line)</label>
<textarea
value={props.config.to.join('\n')}
onInput={(e) => {
const recipients = e.currentTarget.value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
props.onChange({ ...props.config, to: recipients });
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
rows={3}
class={controlClass('px-2 py-1.5 font-mono leading-snug')}
placeholder={`Leave empty to use ${props.config.from || 'the from address'}\nOr add one recipient per line`}
/>
</div>
<div class="border-t border-gray-200 pt-3 dark:border-gray-700">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced())}
class="text-xs font-semibold uppercase tracking-wide text-gray-600 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{showAdvanced() ? 'Hide advanced options' : 'Show advanced options'}
</button>
<Show when={showAdvanced()}>
<div class="mt-3 space-y-3 text-xs text-gray-700 dark:text-gray-300">
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) =>
props.onChange({ ...props.config, tls: e.currentTarget.checked })
}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use TLS</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) =>
props.onChange({ ...props.config, startTLS: e.currentTarget.checked })
}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use STARTTLS</span>
</label>
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Rate limit</label>
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) =>
props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })
}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
<span class={formHelpText}>/min</span>
</div>
</div>
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Max retries</label>
<input
type="number"
value={props.config.maxRetries || 3}
min={0}
max={5}
onInput={(e) =>
props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })
}
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
Retry delay (seconds)
</label>
<input
type="number"
value={props.config.retryDelay || 5}
min={1}
max={60}
onInput={(e) =>
props.onChange({ ...props.config, retryDelay: parseInt(e.currentTarget.value) })
}
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
</div>
</div>
</Show>
</div>
<div class="flex justify-end pt-2">
<button
type="button"
onClick={props.onTest}
disabled={props.testing || !props.config.enabled}
class="rounded border border-blue-500 px-3 py-1.5 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/30"
>
{props.testing ? 'Sending test email…' : 'Send test email'}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Override {
id?: string; // Full guest ID (e.g. "Main-node1-105")
guestName: string;
vmid: number;
type: string;
node: string;
instance?: string;
disabled?: boolean; // Completely disable alerts for this guest
thresholds: {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
};
}
interface OverrideModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (override: Override) => void;
existingOverride?: Override;
guests: Array<{
id: string;
name: string;
vmid: number;
type: string;
node: string;
instance: string;
}>;
}
export function OverrideModal(props: OverrideModalProps) {
// Initialize state only when modal opens, not on every render
const [selectedGuest, setSelectedGuest] = createSignal<string>('');
const [alertsDisabled, setAlertsDisabled] = createSignal(false);
// Store the select element ref
let selectRef: HTMLSelectElement | undefined;
const [thresholds, setThresholds] = createSignal({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0,
});
const [enabledMetrics, setEnabledMetrics] = createSignal({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false,
});
// Maintain select value when guests change
createEffect(() => {
if (selectRef && selectedGuest()) {
const currentValue = selectedGuest();
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
if (selectRef) {
selectRef.value = currentValue;
}
});
}
});
// Reset state when modal opens
createEffect(() => {
if (props.isOpen) {
if (props.existingOverride) {
setSelectedGuest(`${props.existingOverride.vmid}`);
setAlertsDisabled(props.existingOverride.disabled || false);
setThresholds({
cpu: props.existingOverride.thresholds.cpu || 80,
memory: props.existingOverride.thresholds.memory || 80,
disk: props.existingOverride.thresholds.disk || 80,
diskRead: props.existingOverride.thresholds.diskRead || 0,
diskWrite: props.existingOverride.thresholds.diskWrite || 0,
networkIn: props.existingOverride.thresholds.networkIn || 0,
networkOut: props.existingOverride.thresholds.networkOut || 0,
});
setEnabledMetrics({
cpu: props.existingOverride.thresholds.cpu !== undefined,
memory: props.existingOverride.thresholds.memory !== undefined,
disk: props.existingOverride.thresholds.disk !== undefined,
diskRead: props.existingOverride.thresholds.diskRead !== undefined,
diskWrite: props.existingOverride.thresholds.diskWrite !== undefined,
networkIn: props.existingOverride.thresholds.networkIn !== undefined,
networkOut: props.existingOverride.thresholds.networkOut !== undefined,
});
} else {
// Reset to defaults for new override
setSelectedGuest('');
setAlertsDisabled(false);
setThresholds({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0,
});
setEnabledMetrics({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false,
});
}
}
});
const handleSave = () => {
const guest = props.guests.find((g) => g.vmid.toString() === selectedGuest());
if (!guest) return;
const enabledThresholds: Override['thresholds'] = {};
const enabled = enabledMetrics();
const thresh = thresholds();
if (enabled.cpu && thresh.cpu !== undefined) enabledThresholds.cpu = thresh.cpu;
if (enabled.memory && thresh.memory !== undefined) enabledThresholds.memory = thresh.memory;
if (enabled.disk && thresh.disk !== undefined) enabledThresholds.disk = thresh.disk;
if (enabled.diskRead && thresh.diskRead) enabledThresholds.diskRead = thresh.diskRead;
if (enabled.diskWrite && thresh.diskWrite) enabledThresholds.diskWrite = thresh.diskWrite;
if (enabled.networkIn && thresh.networkIn) enabledThresholds.networkIn = thresh.networkIn;
if (enabled.networkOut && thresh.networkOut) enabledThresholds.networkOut = thresh.networkOut;
props.onSave({
id: guest.id, // Pass the full guest ID
guestName: guest.name,
vmid: guest.vmid,
type: guest.type,
node: guest.node,
instance: guest.instance,
disabled: alertsDisabled(),
thresholds: enabledThresholds,
});
};
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<SectionHeader
title={props.existingOverride ? 'Edit guest override' : 'Add guest override'}
size="md"
titleClass="text-gray-800 dark:text-gray-200"
/>
</div>
{/* Content */}
<div class="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-8rem)]">
{/* Guest Selection */}
<Show when={!props.existingOverride}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Guest
</label>
<select
class="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
onChange={(e) => {
const value = e.currentTarget.value;
setSelectedGuest(value);
}}
ref={(el) => {
selectRef = el;
}}
>
<option value="">Choose a guest...</option>
<For each={props.guests}>
{(guest) => (
<option value={guest.vmid.toString()}>
{guest.name} ({guest.vmid}) - {guest.type} on {guest.node}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Disable Alerts Option */}
<div class="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<input
type="checkbox"
id="disable-alerts"
checked={alertsDisabled()}
onChange={(e) => setAlertsDisabled(e.currentTarget.checked)}
class="rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500"
/>
<label for="disable-alerts" class="flex-1">
<span class="text-sm font-medium text-red-800 dark:text-red-200">
Disable all alerts for this guest
</span>
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
No alerts will be generated for this guest, regardless of resource usage
</p>
</label>
</div>
{/* Threshold Overrides */}
<div class={`space-y-4 ${alertsDisabled() ? 'opacity-50 pointer-events-none' : ''}`}>
<SectionHeader
title="Threshold overrides"
size="sm"
titleClass="text-gray-700 dark:text-gray-300"
/>
{/* CPU */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().cpu}
onChange={(e) =>
setEnabledMetrics({ ...enabledMetrics(), cpu: e.currentTarget.checked })
}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">CPU Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().cpu || 80}
onChange={(v) => setThresholds({ ...thresholds(), cpu: v })}
type="cpu"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().cpu || 80}%
</span>
</div>
</div>
</div>
{/* Memory */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().memory}
onChange={(e) =>
setEnabledMetrics({ ...enabledMetrics(), memory: e.currentTarget.checked })
}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Memory Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().memory || 85}
onChange={(v) => setThresholds({ ...thresholds(), memory: v })}
type="memory"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().memory || 85}%
</span>
</div>
</div>
</div>
{/* Disk */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().disk}
onChange={(e) =>
setEnabledMetrics({ ...enabledMetrics(), disk: e.currentTarget.checked })
}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().disk || 90}
onChange={(v) => setThresholds({ ...thresholds(), disk: v })}
type="disk"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().disk || 90}%
</span>
</div>
</div>
</div>
{/* I/O Metrics */}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskRead}
onChange={(e) =>
setEnabledMetrics({
...enabledMetrics(),
diskRead: e.currentTarget.checked,
})
}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Read</label>
<select
value={thresholds().diskRead}
onChange={(e) =>
setThresholds({
...thresholds(),
diskRead: parseInt(e.currentTarget.value),
})
}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskWrite}
onChange={(e) =>
setEnabledMetrics({
...enabledMetrics(),
diskWrite: e.currentTarget.checked,
})
}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Write</label>
<select
value={thresholds().diskWrite}
onChange={(e) =>
setThresholds({
...thresholds(),
diskWrite: parseInt(e.currentTarget.value),
})
}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button
type="button"
onClick={props.onClose}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!selectedGuest() && !props.existingOverride}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Override
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,813 @@
import { createSignal, createEffect, Show, For, Index } from 'solid-js';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import {
formField,
labelClass,
controlClass,
formHelpText,
formCheckbox,
} from '@/components/shared/Form';
interface WebhookTemplate {
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: string;
}
interface WebhookConfigProps {
webhooks: Webhook[];
onAdd: (webhook: Omit<Webhook, 'id'>) => void;
onUpdate: (webhook: Webhook) => void;
onDelete: (id: string) => void;
onTest: (id: string, webhookData?: Omit<Webhook, 'id'>) => void;
testing?: string | null;
}
type HeaderInput = { id: string; key: string; value: string };
type CustomFieldPreset = {
key: string;
label: string;
placeholder?: string;
required?: boolean;
};
type CustomFieldInput = HeaderInput & {
label?: string;
placeholder?: string;
required?: boolean;
};
const customFieldPresets: Record<string, CustomFieldPreset[]> = {
pushover: [
{
key: 'app_token',
label: 'Application Token',
placeholder: 'Your Pushover application token',
required: true,
},
{
key: 'user_token',
label: 'User Key',
placeholder: 'Primary user key or group key',
required: true,
},
],
};
const buildMapFromInputs = (inputs: Array<{ key: string; value: string }>): Record<string, string> => {
const map: Record<string, string> = {};
inputs.forEach(({ key, value }) => {
if (key) {
map[key] = value;
}
});
return map;
};
const createCustomFieldInputs = (
service: string,
existing: Record<string, string> = {},
): CustomFieldInput[] => {
const presets = customFieldPresets[service];
const timestamp = Date.now();
if (!presets) {
return Object.entries(existing).map(([key, value], index) => ({
id: `custom-${key}-${timestamp}-${index}`,
key,
value,
}));
}
const inputs: CustomFieldInput[] = presets.map((preset, index) => ({
id: `custom-${preset.key}-${timestamp}-${index}`,
key: preset.key,
value: existing[preset.key] ?? '',
label: preset.label,
placeholder: preset.placeholder,
required: preset.required,
}));
Object.entries(existing)
.filter(([key]) => !presets.some((preset) => preset.key === key))
.forEach(([key, value], index) => {
inputs.push({
id: `custom-${key}-${timestamp}-${presets.length + index}`,
key,
value,
});
});
return inputs;
};
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<
Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }
>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: '',
customFields: {},
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
// Track header inputs separately to avoid focus loss
const [headerInputs, setHeaderInputs] = createSignal<HeaderInput[]>([]);
const [customFieldInputs, _setCustomFieldInputs] = createSignal<CustomFieldInput[]>([]);
const setCustomFieldInputs = (inputs: CustomFieldInput[]) => {
_setCustomFieldInputs(inputs);
setFormData((prev) => ({
...prev,
customFields: buildMapFromInputs(inputs),
}));
};
const updateCustomFieldInputs = (
updater: (inputs: CustomFieldInput[]) => CustomFieldInput[],
) => {
_setCustomFieldInputs((prev) => {
const next = updater(prev);
setFormData((prevForm) => ({
...prevForm,
customFields: buildMapFromInputs(next),
}));
return next;
});
};
const ensurePresetCustomFields = (service: string) => {
if (!customFieldPresets[service]) {
return;
}
const existing = formData().customFields || {};
const inputs = createCustomFieldInputs(service, existing);
setCustomFieldInputs(inputs);
};
// Load webhook templates
createEffect(async () => {
try {
const data = await NotificationsAPI.getWebhookTemplates();
setTemplates(data);
} catch (err) {
console.error('Failed to load webhook templates:', err);
}
});
const saveWebhook = () => {
const data = formData();
if (!data.name || !data.url) return;
// Build headers from headerInputs
const headers: Record<string, string> = {};
headerInputs().forEach((input) => {
if (input.key) {
headers[input.key] = input.value;
}
});
const customFields = buildMapFromInputs(customFieldInputs());
if (editingId()) {
props.onUpdate({
...data,
id: editingId()!,
headers,
service: data.service,
template: data.payloadTemplate,
customFields,
});
setEditingId(null);
setAdding(false);
setHeaderInputs([]);
setCustomFieldInputs([]);
} else {
// onAdd expects a webhook without id, but with service
const newWebhook: Omit<Webhook, 'id'> = {
name: data.name,
url: data.url,
method: data.method,
headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate,
customFields,
};
props.onAdd(newWebhook);
// Reset form and close the adding panel
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: '',
customFields: {},
});
setHeaderInputs([]);
setCustomFieldInputs([]);
setAdding(false);
}
};
const cancelForm = () => {
setAdding(false);
setEditingId(null);
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: '',
customFields: {},
});
setHeaderInputs([]);
setCustomFieldInputs([]);
};
const editWebhook = (webhook: Webhook) => {
if (webhook.id) {
setEditingId(webhook.id);
}
setFormData({
...webhook,
service: webhook.service || 'generic',
payloadTemplate: webhook.template || '',
customFields: webhook.customFields || {},
});
// Set up header inputs for editing
const headers = webhook.headers || {};
setHeaderInputs(
Object.entries(headers).map(([key, value], index) => ({
id: `header-${Date.now()}-${index}`,
key,
value,
})),
);
const service = webhook.service || 'generic';
const existingCustomFields = webhook.customFields || {};
if (customFieldPresets[service] || Object.keys(existingCustomFields).length > 0) {
setCustomFieldInputs(createCustomFieldInputs(service, existingCustomFields));
} else {
setCustomFieldInputs([]);
}
setAdding(true);
};
const selectService = (service: string) => {
const template = templates().find((t) => t.service === service);
if (template) {
setFormData({
...formData(),
service: template.service,
method: template.method,
headers: { ...template.headers },
name: formData().name || template.name,
// Clear the payload template when switching services
// Only generic service should have custom payloads
payloadTemplate: service === 'generic' ? formData().payloadTemplate : '',
});
// Update header inputs when switching services
const headers = template.headers || {};
setHeaderInputs(
Object.entries(headers).map(([key, value], index) => ({
id: `header-${Date.now()}-${index}`,
key,
value,
})),
);
} else {
setFormData({
...formData(),
service,
});
}
ensurePresetCustomFields(service);
setShowServiceDropdown(false);
};
const currentTemplate = () => templates().find((t) => t.service === formData().service);
const serviceName = (service: string) => {
const names: Record<string, string> = {
generic: 'Generic',
discord: 'Discord',
slack: 'Slack',
telegram: 'Telegram',
teams: 'Microsoft Teams',
'teams-adaptive': 'Teams (Adaptive)',
pagerduty: 'PagerDuty',
pushover: 'Pushover',
gotify: 'Gotify',
ntfy: 'ntfy',
};
return names[service] || service;
};
const toggleAllWebhooks = (enabled: boolean) => {
props.webhooks.forEach((webhook) => {
props.onUpdate({ ...webhook, enabled });
});
};
const allEnabled = () => props.webhooks.every((w) => w.enabled);
const someEnabled = () => props.webhooks.some((w) => w.enabled);
return (
<div class="space-y-6 min-w-0 w-full">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3 w-full">
{/* Quick Actions Bar */}
<div class="flex flex-col gap-2 rounded border border-gray-200 px-3 py-3 text-xs dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div class="text-gray-600 dark:text-gray-400 sm:text-sm">
{props.webhooks.filter((w) => w.enabled).length} of {props.webhooks.length} webhooks
enabled
</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => toggleAllWebhooks(false)}
disabled={!someEnabled()}
class="w-full rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 sm:w-auto"
>
Disable All
</button>
<button
onClick={() => toggleAllWebhooks(true)}
disabled={allEnabled()}
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20 sm:w-auto"
>
Enable All
</button>
</div>
</div>
<For each={props.webhooks}>
{(webhook) => (
<div class="w-full px-3 py-3 border border-gray-200 text-xs dark:border-gray-700 sm:text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-gray-800 dark:text-gray-200">{webhook.name}</span>
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${
webhook.enabled
? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20'
: 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{webhook.enabled ? 'Enabled' : 'Disabled'}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-gray-600 dark:text-gray-400 sm:text-xs">
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{serviceName(webhook.service || 'generic')}
</span>
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{webhook.method}
</span>
<span class="rounded bg-gray-200 px-2 py-0.5 text-gray-700 dark:bg-gray-600 dark:text-gray-200">
ID: {webhook.id || '—'}
</span>
</div>
<p class="mt-2 break-all text-[11px] font-mono text-gray-500 dark:text-gray-400 sm:text-xs">
{webhook.url}
</p>
<div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 pt-2 dark:border-gray-700 sm:justify-end w-full">
<button
onClick={() => webhook.id && props.onTest(webhook.id)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
{props.testing === webhook.id ? 'Testing…' : 'Test'}
</button>
<button
onClick={() => editWebhook(webhook)}
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/20"
>
Edit
</button>
<button
onClick={() => webhook.id && props.onDelete(webhook.id)}
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900/20"
>
Delete
</button>
</div>
</div>
)}
</For>
</div>
</Show>
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4 text-sm">
{/* Service Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Service Type
</label>
<button
type="button"
onClick={() => setShowServiceDropdown(!showServiceDropdown())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{serviceName(formData().service)}
</button>
</div>
<Show when={showServiceDropdown()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 border border-gray-200 dark:border-gray-700 px-3 py-2 mb-3 text-xs">
<For
each={[
'generic',
'discord',
'slack',
'telegram',
'teams',
'teams-adaptive',
'pagerduty',
'pushover',
'gotify',
'ntfy',
]}
>
{(service) => (
<button
type="button"
onClick={() => selectService(service)}
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
formData().service === service
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30'
}`}
>
<div class="font-medium text-xs text-gray-800 dark:text-gray-200">
{serviceName(service)}
</div>
<div class="text-[11px] text-gray-600 dark:text-gray-400 mt-1">
{service === 'generic'
? 'Custom webhook endpoint'
: service === 'discord'
? 'Discord server webhook'
: service === 'slack'
? 'Slack incoming webhook'
: service === 'telegram'
? 'Telegram bot notifications'
: service === 'teams'
? 'Microsoft Teams webhook'
: service === 'teams-adaptive'
? 'Teams with Adaptive Cards'
: service === 'pushover'
? 'Mobile push notifications'
: service === 'gotify'
? 'Self-hosted push notifications'
: service === 'ntfy'
? 'Push notifications via ntfy.sh'
: 'PagerDuty Events API v2'}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
{currentTemplate()!.instructions}
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>Name</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder={currentTemplate()?.name || 'My Webhook'}
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>HTTP method</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div class={formField}>
<label class={labelClass()}>Webhook URL</label>
<input
type="url"
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder={currentTemplate()?.urlPattern || 'https://example.com/webhook'}
class={controlClass('px-2 py-1.5 font-mono')}
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom payload template (JSON)
<span class="text-xs text-gray-500 dark:text-gray-400">
Optional leave empty to use default
</span>
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) =>
setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })
}
placeholder={`{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}`}
rows={8}
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
/>
<p class={formHelpText + ' mt-1'}>
Available variables:{' '}
{
'{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}'
}
</p>
</div>
</Show>
{/* Custom Fields Section */}
<Show when={customFieldInputs().length > 0 || formData().service === 'pushover'}>
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom fields
<span class="text-xs text-gray-500 dark:text-gray-400">
Available as{' '}
<code class="font-mono text-[11px] text-gray-600 dark:text-gray-300">
{'{{.CustomFields.<name>}}'}
</code>{' '}
in templates
</span>
</label>
<div class="space-y-2 text-xs">
<Index each={customFieldInputs()}>
{(field, index) => (
<div class="flex gap-2 text-xs">
<div class="flex flex-1 flex-col gap-1">
<Show when={field().label}>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{field().label}
</span>
</Show>
<input
type="text"
value={field().key}
disabled={field().required}
onInput={(e) => {
const newKey = e.currentTarget.value;
updateCustomFieldInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], key: newKey };
return next;
});
}}
placeholder="Field name"
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
</div>
<input
type="text"
value={field().value}
onInput={(e) => {
const newValue = e.currentTarget.value;
updateCustomFieldInputs((inputs) => {
const next = [...inputs];
next[index] = { ...next[index], value: newValue };
return next;
});
}}
placeholder={field().placeholder || 'Value'}
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<Show when={!field().required}>
<button
type="button"
onClick={() => {
updateCustomFieldInputs((inputs) =>
inputs.filter((_, i) => i !== index),
);
}}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
Remove
</button>
</Show>
</div>
)}
</Index>
<button
type="button"
onClick={() => {
const newId = `custom-${Date.now()}-${Math.random()}`;
updateCustomFieldInputs((inputs) => [
...inputs,
{
id: newId,
key: '',
value: '',
},
]);
}}
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
+ Add custom field
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Need Pushover? Provide your Application Token and User Key here.
</p>
</div>
</Show>
{/* Custom Headers Section */}
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
Custom headers
<span class="text-xs text-gray-500 dark:text-gray-400">
Add authentication tokens or custom headers
</span>
</label>
<div class="space-y-2 text-xs">
<Index each={headerInputs()}>
{(header, index) => (
<div class="flex gap-2 text-xs">
<input
type="text"
value={header().key}
onInput={(e) => {
const newKey = e.currentTarget.value;
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], key: newKey };
return newInputs;
});
}}
placeholder="Header name"
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<input
type="text"
value={header().value}
onInput={(e) => {
const newValue = e.currentTarget.value;
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], value: newValue };
return newInputs;
});
}}
placeholder="Header value"
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
/>
<button
type="button"
onClick={() => {
setHeaderInputs((inputs) => inputs.filter((_, i) => i !== index));
}}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
Remove
</button>
</div>
)}
</Index>
<button
type="button"
onClick={() => {
const newId = `header-${Date.now()}-${Math.random()}`;
setHeaderInputs([
...headerInputs(),
{
id: newId,
key: '',
value: '',
},
]);
}}
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
+ Add header
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Common headers: Authorization (Bearer token), X-API-Key, X-Auth-Token
</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().enabled}
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
class={formCheckbox}
/>
<span>Enable this webhook</span>
</label>
</div>
<div class="flex justify-end gap-2 text-xs">
<button
onClick={cancelForm}
class="px-3 py-1.5 border border-gray-300 rounded text-xs hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
<Show when={formData().url && formData().name}>
<button
onClick={() => {
// Test the webhook with current form data
// Build headers from headerInputs
const headers: Record<string, string> = {};
headerInputs().forEach((input) => {
if (input.key) {
headers[input.key] = input.value;
}
});
const customFields = buildMapFromInputs(customFieldInputs());
// Use a consistent temporary ID for this form session
const tempId = editingId() || 'temp-new-webhook';
props.onTest(tempId, { ...formData(), headers, customFields });
}}
disabled={props.testing === (editingId() || 'temp-new-webhook')}
class="px-3 py-1.5 border border-gray-300 rounded text-xs hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200"
>
{props.testing === (editingId() || 'temp-new-webhook') ? 'Testing...' : 'Test'}
</button>
</Show>
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId() ? 'Update' : 'Add'} Webhook
</button>
</div>
</div>
</Show>
{/* Add Webhook Button */}
<Show when={!adding()}>
<button
onClick={() => {
setAdding(true);
// Initialize with default Content-Type header
setHeaderInputs([
{
id: `header-${Date.now()}-0`,
key: 'Content-Type',
value: 'application/json',
},
]);
setCustomFieldInputs([]);
}}
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
+ Add Webhook
</button>
</Show>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
const Backups: Component = () => {
const { state, connected } = useWebSocket();
return (
<div class="space-y-3">
<ProxmoxSectionNav current="backups" />
{/* Loading State */}
<Show when={connected() && !state.pveBackups && !state.pbs}>
<Card padding="lg">
<EmptyState
icon={
<div class="inline-flex items-center justify-center w-12 h-12">
<svg
class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
}
title="Loading backup information..."
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title="Connection lost"
description="Unable to connect to the backend server. Attempting to reconnect..."
tone="danger"
/>
</Card>
</Show>
{/* Main Content - Unified Backups View */}
<Show when={connected() && (state.pveBackups || state.pbs)}>
<UnifiedBackups />
</Show>
</div>
);
};
export default Backups;

View File

@@ -0,0 +1,521 @@
import { Component, Show, For, createSignal, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
interface BackupsFilterProps {
search: () => string;
setSearch: (value: string) => void;
viewMode: () => 'all' | 'snapshot' | 'pve' | 'pbs';
setViewMode: (value: 'all' | 'snapshot' | 'pve' | 'pbs') => void;
groupBy: () => 'date' | 'guest';
setGroupBy: (value: 'date' | 'guest') => void;
searchInputRef?: (el: HTMLInputElement) => void;
typeFilter?: () => 'all' | 'VM' | 'LXC' | 'Host';
setTypeFilter?: (value: 'all' | 'VM' | 'LXC' | 'Host') => void;
hasHostBackups?: () => boolean;
sortKey: () => string;
setSortKey: (value: string) => void;
sortDirection: () => 'asc' | 'desc';
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
onReset?: () => void;
}
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
const sortOptions = props.sortOptions ?? [
{ value: 'backupTime', label: 'Time' },
{ value: 'name', label: 'Guest Name' },
{ value: 'node', label: 'Node' },
{ value: 'vmid', label: 'VMID' },
{ value: 'backupType', label: 'Backup Type' },
{ value: 'size', label: 'Size' },
{ value: 'storage', label: 'Storage' },
{ value: 'verified', label: 'Verified' },
{ value: 'type', label: 'Guest Type' },
{ value: 'owner', label: 'Owner' },
];
const historyManager = createSearchHistoryManager(STORAGE_KEYS.BACKUPS_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
let searchInputEl: HTMLInputElement | undefined;
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
onMount(() => {
setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
const updated = historyManager.add(term);
setSearchHistory(updated);
};
const deleteHistoryEntry = (term: string) => {
setSearchHistory(historyManager.remove(term));
};
const clearHistory = () => {
setSearchHistory(historyManager.clear());
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
let suppressBlurCommit = false;
const markSuppressCommit = () => {
suppressBlurCommit = true;
queueMicrotask(() => {
suppressBlurCommit = false;
});
};
return (
<Card class="backups-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search backups or node:nodename"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) {
return;
}
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'backups-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search backups by name or filter by node"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="backups-search-help"
intro="Quick examples"
tips={[
{ code: 'media', description: 'Backups with "media" in the name' },
{ code: 'node:pve1', description: 'Show backups from a specific node' },
{ code: 'vm-104', description: 'Locate backups for VM 104' },
]}
footerHighlight="node:pve1 vm-104"
footerText="Mix terms to focus on the exact backups you need."
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Your recent backup searches will show here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
</div>
{/* Filters */}
<div class="flex flex-wrap items-center gap-2">
{/* Source Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'snapshot' ? 'all' : 'snapshot')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'snapshot'
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Snapshots
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'pve' ? 'all' : 'pve')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pve'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PVE
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'pbs' ? 'all' : 'pbs')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pbs'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PBS
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Type Filter - Only show when there are Host backups */}
<Show
when={
props.hasHostBackups &&
props.hasHostBackups() &&
props.typeFilter &&
props.setTypeFilter
}
>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setTypeFilter!('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All Types
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'VM' ? 'all' : 'VM')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'VM'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VM
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'LXC' ? 'all' : 'LXC')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'LXC'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXC
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'Host' ? 'all' : 'Host')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'Host'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PMG
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Group By Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setGroupBy('date')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'date'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Date
</button>
<button
type="button"
onClick={() => props.setGroupBy(props.groupBy() === 'guest' ? 'date' : 'guest')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'guest'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Guest
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sort
</span>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
>
{sortOptions.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={`Sort ${props.sortDirection() === 'asc' ? 'descending' : 'ascending'}`}
onClick={() =>
props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')
}
class="inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
class={`h-4 w-4 transition-transform ${props.sortDirection() === 'asc' ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
if (props.onReset) {
props.onReset();
} else {
props.setSearch('');
props.setViewMode('all');
props.setGroupBy('date');
}
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="ml-1 hidden sm:inline">Reset</span>
</button>
</div>
</div>
</Card>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
import { Component, Show, createMemo } from 'solid-js';
import type { Node } from '@/types/api';
import { formatUptime } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
interface CompactNodeCardProps {
node: Node;
variant: 'compact' | 'ultra-compact';
onClick?: () => void;
isSelected?: boolean;
}
const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
const { activeAlerts } = useWebSocket();
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0;
const cpuPercent = createMemo(() => Math.round(props.node.cpu * 100));
const memPercent = createMemo(() => Math.round(props.node.memory?.usage || 0));
const diskPercent = createMemo(() => {
if (!props.node.disk || props.node.disk.total === 0) return 0;
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
});
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() =>
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
);
const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged));
// Get status color
const getMetricColor = (value: number, type: 'cpu' | 'mem' | 'disk') => {
const thresholds = {
cpu: { high: 90, warn: 80 },
mem: { high: 85, warn: 75 },
disk: { high: 90, warn: 80 },
};
const t = thresholds[type];
if (value >= t.high) return 'text-red-500';
if (value >= t.warn) return 'text-yellow-500';
return 'text-gray-600 dark:text-gray-400';
};
// Mini progress bar for compact mode
const MiniProgressBar = (props: { value: number; type: 'cpu' | 'mem' | 'disk' }) => (
<div class="w-[80px] h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
class={`h-full transition-all ${
props.value >= 90 ? 'bg-red-500' : props.value >= 75 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${props.value}%` }}
/>
</div>
);
if (props.variant === 'ultra-compact') {
// Single line format for 10+ nodes
return (
<Card
padding="none"
border={false}
hoverable
class={`flex items-center gap-2 px-3 py-1.5 ${
props.isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: !isOnline()
? 'border-red-500'
: alertStyles.hasUnacknowledgedAlert
? 'border-orange-500'
: alertStyles.hasAcknowledgedOnlyAlert
? 'border-gray-400 dark:border-gray-600'
: 'border-gray-200 dark:border-gray-700'
} border transition-all cursor-pointer hover:scale-[1.01]`}
onClick={props.onClick}
>
{/* Status dot */}
<span
class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`}
/>
{/* Node name */}
<a
href={props.node.host || `https://${props.node.name}:8006`}
target="_blank"
class="font-medium text-sm w-24 truncate hover:text-blue-600 dark:hover:text-blue-400"
title={
showActualName()
? `${displayName()}${props.node.name}`
: props.node.name
}
>
{displayName()}
</a>
{/* Cluster/Standalone indicator */}
<Show when={props.node.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{props.node.isClusterMember ? props.node.clusterName?.slice(0, 3).toUpperCase() : 'SA'}
</span>
</Show>
{/* Alert indicator */}
<Show when={alertStyles.hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
</Show>
{/* Metrics */}
<div class="flex gap-4 text-xs font-mono">
<span class={getMetricColor(cpuPercent(), 'cpu')}>
C:{cpuPercent().toString().padStart(3)}%
</span>
<span class={getMetricColor(memPercent(), 'mem')}>
M:{memPercent().toString().padStart(3)}%
</span>
<span class={getMetricColor(diskPercent(), 'disk')}>
D:{diskPercent().toString().padStart(3)}%
</span>
</div>
{/* Uptime */}
<span class="ml-auto text-xs text-gray-500 dark:text-gray-400">
{formatUptime(props.node.uptime)}
</span>
</Card>
);
}
// Compact bar format for 5-9 nodes
return (
<Card
padding="sm"
border={false}
hoverable
class={`border ${
props.isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: !isOnline()
? 'border-red-500'
: alertStyles.hasUnacknowledgedAlert
? 'border-orange-500'
: alertStyles.hasAcknowledgedOnlyAlert
? 'border-gray-400 dark:border-gray-600'
: 'border-gray-200 dark:border-gray-700'
} cursor-pointer transition-all hover:scale-[1.02]`}
onClick={props.onClick}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span
class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`}
/>
<a
href={props.node.host || `https://${props.node.name}:8006`}
target="_blank"
class="font-semibold text-sm hover:text-blue-600 dark:hover:text-blue-400"
>
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
</Show>
{/* Cluster/Standalone indicator */}
<Show when={props.node.isClusterMember !== undefined}>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
</span>
</Show>
<Show when={alertStyles.hasUnacknowledgedAlert}>
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
</Show>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatUptime(props.node.uptime)}
</span>
</div>
{/* Metric bars */}
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">CPU</span>
<MiniProgressBar value={cpuPercent()} type="cpu" />
<span class={`text-xs ${getMetricColor(cpuPercent(), 'cpu')}`}>{cpuPercent()}%</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Mem</span>
<MiniProgressBar value={memPercent()} type="mem" />
<span class={`text-xs ${getMetricColor(memPercent(), 'mem')}`}>{memPercent()}%</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Disk</span>
<MiniProgressBar value={diskPercent()} type="disk" />
<span class={`text-xs ${getMetricColor(diskPercent(), 'disk')}`}>{diskPercent()}%</span>
</div>
</div>
</Card>
);
};
export default CompactNodeCard;

View File

@@ -0,0 +1,897 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow } from './GuestRow';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { DashboardFilter } from './DashboardFilter';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { isNodeOnline } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes';
interface DashboardProps {
vms: VM[];
containers: Container[];
nodes: Node[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
const ws = useWebSocket();
const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws;
const [search, setSearch] = createSignal('');
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [guestMetadata, setGuestMetadata] = createSignal<Record<string, GuestMetadata>>({});
// Initialize from localStorage with proper type checking
const storedViewMode = localStorage.getItem('dashboardViewMode');
const [viewMode, setViewMode] = createSignal<ViewMode>(
storedViewMode === 'all' || storedViewMode === 'vm' || storedViewMode === 'lxc'
? storedViewMode
: 'all',
);
const storedStatusMode = localStorage.getItem('dashboardStatusMode');
const [statusMode, setStatusMode] = createSignal<StatusMode>(
storedStatusMode === 'all' || storedStatusMode === 'running' || storedStatusMode === 'stopped'
? storedStatusMode
: 'all',
);
// Grouping mode - grouped by node or flat list
const storedGroupingMode = localStorage.getItem('dashboardGroupingMode');
const [groupingMode, setGroupingMode] = createSignal<GroupingMode>(
storedGroupingMode === 'grouped' || storedGroupingMode === 'flat'
? storedGroupingMode
: 'grouped',
);
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('dashboardShowFilters') !== null
? localStorage.getItem('dashboardShowFilters') === 'true'
: false, // Default to collapsed
);
// Sorting state - default to VMID ascending (matches Proxmox order)
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
try {
const metadata = await GuestMetadataAPI.getAllMetadata();
setGuestMetadata(metadata || {});
} catch (err) {
// Silently fail - metadata is optional for display
console.debug('Failed to load guest metadata:', err);
}
});
// Create a mapping from node ID to node object
const nodeByInstance = createMemo(() => {
const map: Record<string, Node> = {};
props.nodes.forEach((node) => {
map[node.id] = node;
});
return map;
});
const resolveParentNode = (guest: VM | Container): Node | undefined => {
if (!guest) return undefined;
const nodes = nodeByInstance();
if (guest.id) {
const lastDash = guest.id.lastIndexOf('-');
if (lastDash > 0) {
const nodeId = guest.id.slice(0, lastDash);
if (nodes[nodeId]) {
return nodes[nodeId];
}
}
}
const compositeKey = `${guest.instance}-${guest.node}`;
if (nodes[compositeKey]) {
return nodes[compositeKey];
}
return undefined;
};
// Persist filter states to localStorage
createEffect(() => {
localStorage.setItem('dashboardViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('dashboardStatusMode', statusMode());
});
createEffect(() => {
localStorage.setItem('dashboardGroupingMode', groupingMode());
});
createEffect(() => {
localStorage.setItem('dashboardShowFilters', showFilters().toString());
});
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
if (sortKey() === key) {
// Toggle direction for the same column
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
// New column - set key and default direction
setSortKey(key);
// Set default sort direction based on column type
if (
key === 'cpu' ||
key === 'memory' ||
key === 'disk' ||
key === 'diskRead' ||
key === 'diskWrite' ||
key === 'networkIn' ||
key === 'networkOut' ||
key === 'uptime'
) {
setSortDirection('desc');
} else {
setSortDirection('asc');
}
}
};
const getDiskUsagePercent = (guest: VM | Container): number | null => {
const disk = guest?.disk;
if (!disk) return null;
const clamp = (value: number) => Math.min(100, Math.max(0, value));
if (typeof disk.usage === 'number' && Number.isFinite(disk.usage)) {
// Some sources report usage as a ratio (0-1), others as a percentage (0-100)
const usageValue = disk.usage > 1 ? disk.usage : disk.usage * 100;
return clamp(usageValue);
}
if (
typeof disk.used === 'number' &&
Number.isFinite(disk.used) &&
typeof disk.total === 'number' &&
Number.isFinite(disk.total) &&
disk.total > 0
) {
return clamp((disk.used / disk.total) * 100);
}
return null;
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
const isInputField =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear (including tag filters and node selection)
const hasActiveFilters =
search().trim() ||
sortKey() !== 'vmid' ||
sortDirection() !== 'asc' ||
selectedNode() !== null ||
viewMode() !== 'all' ||
statusMode() !== 'all';
if (hasActiveFilters) {
// Clear ALL filters including search text, tag filters, node selection, and view modes
setSearch('');
setIsSearchLocked(false);
setSortKey('vmid');
setSortDirection('asc');
setSelectedNode(null);
setViewMode('all');
setStatusMode('all');
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else {
// No active filters, toggle the filters section visibility
setShowFilters(!showFilters());
}
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
const vms = props.vms || [];
const containers = props.containers || [];
const guests: (VM | Container)[] = [...vms, ...containers];
return guests;
});
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();
// Filter by selected node using both instance and node name for uniqueness
const selectedNodeId = selectedNode();
if (selectedNodeId) {
// Find the node to get both instance and name for precise matching
const node = props.nodes.find((n) => n.id === selectedNodeId);
if (node) {
guests = guests.filter(
(g) => g.instance === node.instance && g.node === node.name,
);
}
}
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter((g) => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
guests = guests.filter((g) => g.type === 'lxc');
}
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter((g) => g.status === 'running');
} else if (statusMode() === 'stopped') {
guests = guests.filter((g) => g.status !== 'running');
}
// Apply search/filter
const searchTerm = search().trim();
if (searchTerm) {
// Split by commas first
const searchParts = searchTerm
.split(',')
.map((t) => t.trim())
.filter((t) => t);
// Separate filters from text searches
const filters: string[] = [];
const textSearches: string[] = [];
searchParts.forEach((part) => {
if (part.includes('>') || part.includes('<') || part.includes(':')) {
filters.push(part);
} else {
textSearches.push(part.toLowerCase());
}
});
// Apply filters if any
if (filters.length > 0) {
// Join filters with AND operator
const filterString = filters.join(' AND ');
const stack = parseFilterStack(filterString);
if (stack.filters.length > 0) {
guests = guests.filter((g) => evaluateFilterStack(g, stack));
}
}
// Apply text search if any
if (textSearches.length > 0) {
guests = guests.filter((g) =>
textSearches.some(
(term) =>
g.name.toLowerCase().includes(term) ||
g.vmid.toString().includes(term) ||
g.node.toLowerCase().includes(term) ||
g.status.toLowerCase().includes(term),
),
);
}
}
// Don't filter by thresholds anymore - dimming is handled in GuestRow component
return guests;
});
// Group by node or return flat list based on grouping mode
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
// If flat mode, return all guests in a single group
if (groupingMode() === 'flat') {
const groups: Record<string, (VM | Container)[]> = { '': guests };
// Sort the flat list
const key = sortKey();
const dir = sortDirection();
if (key) {
groups[''] = groups[''].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as
| string
| number
| boolean
| null
| undefined;
let bVal: string | number | boolean | null | undefined = b[key] as
| string
| number
| boolean
| null
| undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? a.memory.usage || 0 : 0;
bVal = b.memory ? b.memory.usage || 0 : 0;
} else if (key === 'disk') {
aVal = getDiskUsagePercent(a);
bVal = getDiskUsagePercent(b);
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
}
return groups;
}
// Group by node ID (instance + node name) to match Node.ID format
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach((guest) => {
// Node.ID is formatted as "instance-nodename", so we need to match that
const nodeId = `${guest.instance}-${guest.node}`;
if (!groups[nodeId]) {
groups[nodeId] = [];
}
groups[nodeId].push(guest);
});
// Sort within each node group
const key = sortKey();
const dir = sortDirection();
if (key) {
Object.keys(groups).forEach((node) => {
groups[node] = groups[node].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as
| string
| number
| boolean
| null
| undefined;
let bVal: string | number | boolean | null | undefined = b[key] as
| string
| number
| boolean
| null
| undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? a.memory.usage || 0 : 0;
bVal = b.memory ? b.memory.usage || 0 : 0;
} else if (key === 'disk') {
aVal = getDiskUsagePercent(a);
bVal = getDiskUsagePercent(b);
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
});
}
return groups;
});
const totalStats = createMemo(() => {
const guests = filteredGuests();
const running = guests.filter((g) => g.status === 'running').length;
const vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc').length;
return {
total: guests.length,
running,
stopped: guests.length - running,
vms,
containers,
};
});
const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => {
console.log('handleNodeSelect called:', nodeId, nodeType);
// Track selected node for filtering (independent of search)
if (nodeType === 'pve' || nodeType === null) {
setSelectedNode(nodeId);
console.log('Set selected node to:', nodeId);
// Show filters if a node is selected
if (nodeId && !showFilters()) {
setShowFilters(true);
}
}
};
const handleTagClick = (tag: string) => {
const currentSearch = search().trim();
const tagFilter = `tags:${tag}`;
// Check if this tag filter already exists
if (currentSearch.includes(tagFilter)) {
// Remove the tag filter
let newSearch = currentSearch;
// Handle different cases of where the tag filter might be
if (currentSearch === tagFilter) {
// It's the only filter
newSearch = '';
} else if (currentSearch.startsWith(tagFilter + ',')) {
// It's at the beginning
newSearch = currentSearch.replace(tagFilter + ',', '').trim();
} else if (currentSearch.endsWith(', ' + tagFilter)) {
// It's at the end
newSearch = currentSearch.replace(', ' + tagFilter, '').trim();
} else if (currentSearch.includes(', ' + tagFilter + ',')) {
// It's in the middle
newSearch = currentSearch.replace(', ' + tagFilter + ',', ',').trim();
} else if (currentSearch.includes(tagFilter + ', ')) {
// It's at the beginning with space after comma
newSearch = currentSearch.replace(tagFilter + ', ', '').trim();
}
setSearch(newSearch);
if (!newSearch) {
setIsSearchLocked(false);
}
} else {
// Add the tag filter
if (!currentSearch || isSearchLocked()) {
setSearch(tagFilter);
setIsSearchLocked(false);
} else {
// Add tag filter to existing search with comma separator
setSearch(`${currentSearch}, ${tagFilter}`);
}
// Make sure filters are visible
if (!showFilters()) {
setShowFilters(true);
}
}
};
return (
<div class="space-y-3">
<ProxmoxSectionNav current="overview" />
{/* Unified Node Selector */}
<UnifiedNodeSelector
currentTab="dashboard"
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc')}
searchTerm={search()}
/>
{/* Dashboard Filter */}
<DashboardFilter
search={search}
setSearch={setSearch}
isSearchLocked={isSearchLocked}
viewMode={viewMode}
setViewMode={setViewMode}
statusMode={statusMode}
setStatusMode={setStatusMode}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => (searchInputRef = el)}
/>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="mx-auto h-12 w-12 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title="Loading dashboard data..."
description={
reconnecting()
? 'Reconnecting to monitoring service…'
: 'Connecting to monitoring service'
}
/>
</Card>
</Show>
{/* Empty State - No PVE Nodes Configured */}
<Show
when={
connected() &&
initialDataReceived() &&
props.nodes.filter((n) => n.type === 'pve').length === 0 &&
props.vms.length === 0 &&
props.containers.length === 0
}
>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No Proxmox VE nodes configured"
description="Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure."
actions={
<button
type="button"
onClick={() => {
const settingsTab = document.querySelector(
'[role="tab"]:last-child',
) as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title="Connection lost"
description={
reconnecting()
? 'Attempting to reconnect…'
: 'Unable to connect to the backend server'
}
tone="danger"
actions={
!reconnecting() ? (
<button
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Reconnect now
</button>
) : undefined
}
/>
</Card>
</Show>
{/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="900px">
<table class="w-full min-w-[900px] table-fixed border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[220px] xl:w-[260px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[60px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[70px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] xl:w-[200px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] xl:w-[200px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] xl:w-[200px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskRead')}
>
Disk Read{' '}
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskWrite')}
>
Disk Write{' '}
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkOut')}
>
Net Out{' '}
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
// Sort by friendly node name first, falling back to instance ID for stability
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
const nameCompare = labelA.localeCompare(labelB);
if (nameCompare !== 0) return nameCompare;
// If labels match (unlikely), fall back to the instance IDs
return instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} colspan={11} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => (
<ComponentErrorBoundary name="GuestRow">
{(() => {
// Match backend ID generation logic: standalone nodes use "node-vmid", clusters use "instance-node-vmid"
const guestId =
guest.id ||
(guest.instance === guest.node
? `${guest.node}-${guest.vmid}`
: `${guest.instance}-${guest.node}-${guest.vmid}`);
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts)}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
/>
);
})()}
</ComponentErrorBoundary>
)}
</For>
</>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Card>
</ComponentErrorBoundary>
</Show>
<Show
when={
connected() &&
initialDataReceived() &&
filteredGuests().length === 0 &&
(props.vms.length > 0 || props.containers.length > 0)
}
>
<Card padding="lg" class="mb-4">
<EmptyState
icon={
<svg
class="h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
}
title="No guests found"
description={
search() && search().trim() !== ''
? `No guests match your search "${search()}"`
: 'No guests match your current filters'
}
/>
</Card>
</Show>
{/* Stats */}
<Show when={connected() && initialDataReceived()}>
<div class="mb-4">
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-green-500 rounded-full"></span>
{totalStats().running} running
</span>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-red-500 rounded-full"></span>
{totalStats().stopped} stopped
</span>
</div>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,436 @@
import { Component, Show, For, createSignal, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
interface DashboardFilterProps {
search: () => string;
setSearch: (value: string) => void;
isSearchLocked: () => boolean;
viewMode: () => 'all' | 'vm' | 'lxc';
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
statusMode: () => 'all' | 'running' | 'stopped';
setStatusMode: (value: 'all' | 'running' | 'stopped') => void;
groupingMode: () => 'grouped' | 'flat';
setGroupingMode: (value: 'grouped' | 'flat') => void;
setSortKey: (value: string) => void;
setSortDirection: (value: string) => void;
searchInputRef?: (el: HTMLInputElement) => void;
}
export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
const historyManager = createSearchHistoryManager(STORAGE_KEYS.DASHBOARD_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
let searchInputEl: HTMLInputElement | undefined;
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
onMount(() => {
setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
const updated = historyManager.add(term);
setSearchHistory(updated);
};
const deleteHistoryEntry = (term: string) => {
setSearchHistory(historyManager.remove(term));
};
const clearHistory = () => {
setSearchHistory(historyManager.clear());
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
let suppressBlurCommit = false;
const markSuppressCommit = () => {
suppressBlurCommit = true;
queueMicrotask(() => {
suppressBlurCommit = false;
});
};
return (
<Card class="dashboard-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search or filter..."
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) {
return;
}
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'dashboard-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class={`w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all`}
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="dashboard-search-help"
intro="Combine filters to narrow results"
tips={[
{ code: 'media', description: 'Guests with "media" in the name' },
{ code: 'cpu>80', description: 'Highlight guests using more than 80% CPU' },
{ code: 'memory<20', description: 'Find guests under 20% memory usage' },
{ code: 'tags:prod', description: 'Filter by tag' },
{ code: 'node:pve1', description: 'Show guests on a specific node' },
]}
footerHighlight="node:pve1 cpu>60"
footerText="Stack filters to get laser-focused results."
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
</div>
{/* Filters */}
<div class="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VMs
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXCs
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setStatusMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Running
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Stopped
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Grouping Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setGroupingMode('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Group by node"
>
Grouped
</button>
<button
type="button"
onClick={() => props.setGroupingMode(props.groupingMode() === 'flat' ? 'grouped' : 'flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Flat list view"
>
List
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
props.setSearch('');
props.setSortKey('vmid');
props.setSortDirection('asc');
props.setViewMode('all');
props.setStatusMode('all');
props.setGroupingMode('grouped');
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
props.search() ||
props.viewMode() !== 'all' ||
props.statusMode() !== 'all' ||
props.groupingMode() !== 'grouped'
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="ml-1 hidden sm:inline">Reset</span>
</button>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,139 @@
import { Component, createMemo, Show } from 'solid-js';
import type { PhysicalDisk } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface DiskHealthSummaryProps {
disks: PhysicalDisk[];
}
export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
const diskStats = createMemo(() => {
const disks = props.disks || [];
const total = disks.length;
const healthy = disks.filter((d) => d.health === 'PASSED').length;
const failing = disks.filter((d) => d.health === 'FAILED').length;
const unknown = disks.filter((d) => d.health === 'UNKNOWN' || !d.health).length;
const lowLife = disks.filter((d) => d.wearout > 0 && d.wearout < 10).length;
const avgWearout =
disks.filter((d) => d.wearout > 0).reduce((sum, d) => sum + d.wearout, 0) /
disks.filter((d) => d.wearout > 0).length || 0;
// Group by node
const byNode: Record<string, number> = {};
disks.forEach((d) => {
byNode[d.node] = (byNode[d.node] || 0) + 1;
});
return {
total,
healthy,
failing,
unknown,
lowLife,
avgWearout,
byNode,
};
});
const healthColor = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'text-red-600 dark:text-red-400';
if (stats.lowLife > 0) return 'text-yellow-600 dark:text-yellow-400';
if (stats.unknown > 0) return 'text-gray-600 dark:text-gray-400';
return 'text-green-600 dark:text-green-400';
});
const healthBg = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
if (stats.lowLife > 0)
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
});
return (
<Show when={diskStats().total > 0}>
<Card padding="md" border={false} class={`${healthBg()}`}>
<div class="flex items-center justify-between mb-3">
<SectionHeader
title="Disk health summary"
size="sm"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<span class={`text-2xl font-bold ${healthColor()}`}>
{diskStats().healthy}/{diskStats().total}
</span>
</div>
<div class="space-y-2">
{/* Health Status */}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">Status</span>
<div class="flex gap-2">
<Show when={diskStats().healthy > 0}>
<span class="px-1.5 py-0.5 text-xs rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
{diskStats().healthy} healthy
</span>
</Show>
<Show when={diskStats().failing > 0}>
<span class="px-1.5 py-0.5 text-xs rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
{diskStats().failing} failed
</span>
</Show>
<Show when={diskStats().lowLife > 0}>
<span class="px-1.5 py-0.5 text-xs rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">
{diskStats().lowLife} low
</span>
</Show>
<Show when={diskStats().unknown > 0}>
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-400">
{diskStats().unknown} unknown
</span>
</Show>
</div>
</div>
{/* Average SSD Life */}
<Show when={diskStats().avgWearout > 0}>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">Avg SSD Life</span>
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
class={`h-1.5 rounded-full transition-all ${
diskStats().avgWearout >= 50
? 'bg-green-500'
: diskStats().avgWearout >= 20
? 'bg-yellow-500'
: diskStats().avgWearout >= 10
? 'bg-orange-500'
: 'bg-red-500'
}`}
style={`width: ${diskStats().avgWearout}%`}
/>
</div>
<span class="text-gray-700 dark:text-gray-300 font-medium">
{Math.round(diskStats().avgWearout)}%
</span>
</div>
</div>
</Show>
{/* Disk Distribution */}
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Distribution</div>
<div class="flex flex-wrap gap-1">
{Object.entries(diskStats().byNode).map(([node, count]) => (
<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">
{node}: {count}
</span>
))}
</div>
</div>
</div>
</Card>
</Show>
);
};

View File

@@ -0,0 +1,93 @@
import { For, Show } from 'solid-js';
import type { Disk } from '@/types/api';
import { formatBytes } from '@/utils/format';
interface DiskListProps {
disks: Disk[];
diskStatusReason?: string;
}
export function DiskList(props: DiskListProps) {
const getUsagePercent = (disk: Disk) => {
if (!disk.total || disk.total <= 0) return 0;
return (disk.used / disk.total) * 100;
};
const getBarColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500/60 dark:bg-red-500/50';
if (percentage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
return 'bg-green-500/60 dark:bg-green-500/50';
};
const getDiskStatusTooltip = () => {
const reason = props.diskStatusReason;
switch (reason) {
case 'agent-not-running':
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
case 'agent-timeout':
return 'Guest agent timeout. Agent may need to be restarted.';
case 'permission-denied':
return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).';
case 'agent-disabled':
return 'Guest agent is disabled in VM configuration. Enable it in VM Options.';
case 'no-filesystems':
return 'No filesystems found. VM may be booting or using a Live ISO.';
case 'special-filesystems-only':
return 'Only special filesystems detected (ISO/squashfs). This is normal for Live systems.';
case 'agent-error':
return 'Error communicating with guest agent.';
case 'no-data':
return 'No disk data available from Proxmox API.';
default:
return 'Disk stats unavailable. Guest agent may not be installed.';
}
};
return (
<Show
when={props.disks && props.disks.length > 0}
fallback={
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
-
</span>
}
>
<div class="flex flex-col gap-1.5">
<For each={props.disks}>
{(disk) => {
const usage = getUsagePercent(disk);
const label = disk.mountpoint || disk.device || 'Unknown';
const hasCapacity = disk.total && disk.total > 0;
return (
<div class="rounded border border-gray-200 bg-gray-50 px-1.5 py-1 text-[10px] leading-tight shadow-sm dark:border-gray-700 dark:bg-gray-800/80">
<div
class="truncate text-gray-700 dark:text-gray-200"
title={label !== 'Unknown' ? label : undefined}
>
{label}
</div>
<div class="mt-0.5 text-[9px] text-gray-500 dark:text-gray-400">
{hasCapacity
? `${formatBytes(disk.used)}/${formatBytes(disk.total)}`
: 'Usage unavailable'}
</div>
<div class="relative mt-1 h-1.5 w-full overflow-hidden rounded bg-gray-200 dark:bg-gray-600">
<div
class={`absolute inset-y-0 left-0 ${getBarColor(usage)}`}
style={{ width: `${Math.min(usage, 100)}%` }}
/>
</div>
<div class="mt-0.5 flex items-center justify-between text-[9px] font-medium text-gray-600 dark:text-gray-300">
<span>{hasCapacity ? `${usage.toFixed(0)}%` : '—'}</span>
<span>{disk.type?.toUpperCase() ?? ''}</span>
</div>
</div>
);
}}
</For>
</div>
</Show>
);
}

View File

@@ -0,0 +1,512 @@
import { createMemo, createSignal, createEffect, on, Show, For } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric';
import { TagBadges } from './TagBadges';
import { DiskList } from './DiskList';
import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status';
type Guest = VM | Container;
const drawerState = new Map<string, boolean>();
const buildGuestId = (guest: Guest) => {
if (guest.id) return guest.id;
if (guest.instance === guest.node) {
return `${guest.node}-${guest.vmid}`;
}
return `${guest.instance}-${guest.node}-${guest.vmid}`;
};
// Type guard for VM vs Container
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
interface GuestRowProps {
guest: Guest;
alertStyles?: {
rowClass: string;
indicatorClass: string;
badgeClass: string;
hasAlert: boolean;
alertCount: number;
severity: 'critical' | 'warning' | null;
hasPoweredOffAlert?: boolean;
hasNonPoweredOffAlert?: boolean;
hasUnacknowledgedAlert?: boolean;
unacknowledgedCount?: number;
acknowledgedCount?: number;
hasAcknowledgedOnlyAlert?: boolean;
};
customUrl?: string;
onTagClick?: (tag: string) => void;
activeSearch?: string;
parentNodeOnline?: boolean;
}
export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const initialGuestId = buildGuestId(props.guest);
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(initialGuestId) ?? false);
const guestId = createMemo(() => buildGuestId(props.guest));
const hasMultipleDisks = createMemo(() => (props.guest.disks?.length ?? 0) > 1);
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
const networkInterfaces = createMemo(() => props.guest.networkInterfaces ?? []);
const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0);
const osName = createMemo(() => props.guest.osName?.trim() ?? '');
const osVersion = createMemo(() => props.guest.osVersion?.trim() ?? '');
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
// Update custom URL when prop changes
createEffect(() => {
setCustomUrl(props.customUrl);
});
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return props.guest.memory.usage || 0;
});
const memoryUsageLabel = createMemo(() => {
if (!props.guest.memory) return undefined;
const used = props.guest.memory.used ?? 0;
const total = props.guest.memory.total ?? 0;
return `${formatBytes(used)}/${formatBytes(total)}`;
});
const memoryExtraLines = createMemo(() => {
if (!props.guest.memory) return undefined;
const lines: string[] = [];
const total = props.guest.memory.total ?? 0;
if (
props.guest.memory.balloon &&
props.guest.memory.balloon > 0 &&
props.guest.memory.balloon !== total
) {
lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon)}`);
}
if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) {
const swapUsed = props.guest.memory.swapUsed ?? 0;
lines.push(`Swap: ${formatBytes(swapUsed)} / ${formatBytes(props.guest.memory.swapTotal)}`);
}
return lines.length > 0 ? lines : undefined;
});
const memoryTooltip = createMemo(() =>
memoryExtraLines()?.join('\n') ?? undefined,
);
const canShowDrawer = createMemo(() =>
hasOsInfo() ||
ipAddresses().length > 0 ||
(memoryExtraLines() && memoryExtraLines()!.length > 0) ||
hasMultipleDisks() ||
hasNetworkInterfaces(),
);
createEffect(on(guestId, (id) => {
const stored = drawerState.get(id);
if (stored !== undefined) {
setDrawerOpen(stored);
} else {
setDrawerOpen(false);
}
}));
createEffect(() => {
drawerState.set(guestId(), drawerOpen());
});
createEffect(() => {
if (!canShowDrawer() && drawerOpen()) {
setDrawerOpen(false);
drawerState.set(guestId(), false);
}
});
const toggleDrawer = (event: MouseEvent) => {
if (!canShowDrawer()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
return;
}
setDrawerOpen((prev) => !prev);
};
const diskPercent = createMemo(() => {
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
// Check if usage is -1 (unknown/no guest agent)
if (props.guest.disk.usage === -1) return -1;
return (props.guest.disk.used / props.guest.disk.total) * 100;
});
const hasDiskUsage = createMemo(() => {
if (!props.guest.disk) return false;
if (props.guest.disk.total <= 0) return false;
return diskPercent() !== -1;
});
const parentOnline = createMemo(() => props.parentNodeOnline !== false);
const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline()));
const showGuestMetrics = createMemo(() => shouldDisplayGuestMetrics(props.guest, parentOnline()));
const lockLabel = createMemo(() => (props.guest.lock || '').trim());
// Get helpful tooltip for disk status
const getDiskStatusTooltip = () => {
if (!isVM(props.guest)) return 'Disk stats unavailable';
const vm = props.guest as VM;
const reason = vm.diskStatusReason;
switch (reason) {
case 'agent-not-running':
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
case 'agent-timeout':
return 'Guest agent timeout. Agent may need to be restarted.';
case 'permission-denied':
return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).';
case 'agent-disabled':
return 'Guest agent is disabled in VM configuration. Enable it in VM Options.';
case 'no-filesystems':
return 'No filesystems found. VM may be booting or using a Live ISO.';
case 'special-filesystems-only':
return 'Only special filesystems detected (ISO/squashfs). This is normal for Live systems.';
case 'agent-error':
return 'Error communicating with guest agent.';
case 'no-data':
return 'No disk data available from Proxmox API.';
default:
return 'Disk stats unavailable. Guest agent may not be installed.';
}
};
const hasUnacknowledgedAlert = createMemo(() => !!props.alertStyles?.hasUnacknowledgedAlert);
const hasAcknowledgedOnlyAlert = createMemo(() => !!props.alertStyles?.hasAcknowledgedOnlyAlert);
const showAlertHighlight = createMemo(
() => hasUnacknowledgedAlert() || hasAcknowledgedOnlyAlert(),
);
const alertAccentColor = createMemo(() => {
if (!showAlertHighlight()) return undefined;
if (hasUnacknowledgedAlert()) {
return props.alertStyles?.severity === 'critical' ? '#ef4444' : '#eab308';
}
return '#9ca3af';
});
const drawerDisabled = createMemo(() => !isRunning());
// Get row styling - include alert styles if present
const rowClass = createMemo(() => {
const base = 'transition-all duration-200 relative';
const hover = 'hover:shadow-sm';
const alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20'
: '';
const defaultHover = hasUnacknowledgedAlert()
? ''
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
const clickable = canShowDrawer() ? 'cursor-pointer' : '';
const expanded = drawerOpen() && !hasUnacknowledgedAlert()
? 'bg-gray-50 dark:bg-gray-800/40'
: '';
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
});
// Get first cell styling
const firstCellClass = createMemo(() => {
const base = 'py-0.5 pr-2 whitespace-nowrap relative';
const indent = 'pl-6';
return `${base} ${indent}`;
});
// Get row styles including box-shadow for alert border
const rowStyle = createMemo(() => {
if (!showAlertHighlight()) return {};
const color = alertAccentColor();
if (!color) return {};
return {
'box-shadow': `inset 4px 0 0 0 ${color}`,
};
});
return (
<>
<tr class={rowClass()} style={rowStyle()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
{/* Name - Sticky column */}
<td class={firstCellClass()}>
<div class="flex items-center gap-2">
{/* Name - clickable if custom URL is set */}
<Show
when={customUrl()}
fallback={
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"
title={props.guest.name}
>
{props.guest.name}
</span>
}
>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer truncate"
title={`${props.guest.name} - Click to open custom URL`}
onClick={(event) => event.stopPropagation()}
>
{props.guest.name}
</a>
</Show>
{/* Tag badges */}
<div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
</div>
<Show when={lockLabel()}>
<span
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
title={`Guest is locked (${lockLabel()})`}
>
Lock: {lockLabel()}
</span>
</Show>
</div>
</td>
{/* Type */}
<td class="py-0.5 px-2 whitespace-nowrap">
<div class="flex h-[24px] items-center">
<span
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</div>
</td>
{/* VMID */}
<td class="py-0.5 px-2 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 align-middle">
{props.guest.vmid}
</td>
{/* Uptime */}
<td
class={`py-0.5 px-2 text-sm whitespace-nowrap align-middle ${
props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
}`}
>
<Show when={isRunning()} fallback="-">
{formatUptime(props.guest.uptime)}
</Show>
</td>
{/* CPU */}
<td class="py-0.5 px-2 w-[160px] xl:w-[200px]">
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`}
sublabel={
props.guest.cpus
? `${((props.guest.cpu || 0) * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores`
: undefined
}
type="cpu"
/>
</Show>
</td>
{/* Memory */}
<td class="py-0.5 px-2 w-[160px] xl:w-[200px]">
<div title={memoryTooltip() ?? undefined}>
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={memoryUsageLabel()}
type="memory"
/>
</Show>
</div>
</td>
{/* Disk surface usage even if guest is currently stopped so users can see last reported values */}
<td class="py-0.5 px-2 w-[160px] xl:w-[200px]">
<Show
when={hasDiskUsage()}
fallback={
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
-
</span>
}
>
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={
props.guest.disk
? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}`
: undefined
}
type="disk"
/>
</Show>
</td>
{/* Disk I/O */}
<td class="py-0.5 px-2">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</div>
</td>
<td class="py-0.5 px-2">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</div>
</td>
{/* Network I/O */}
<td class="py-0.5 px-2">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</div>
</td>
<td class="py-0.5 px-2">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</div>
</td>
</tr>
<Show when={drawerOpen() && canShowDrawer()}>
<tr
class={`text-[11px] ${
isRunning() && props.parentNodeOnline !== false
? 'bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300'
: 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
}`}
aria-hidden={!isRunning() || props.parentNodeOnline === false}
>
<td class="px-4 py-2" colSpan={11}>
<div
class={`flex flex-wrap gap-3 justify-start ${
drawerDisabled() ? 'opacity-50 saturate-75 pointer-events-none' : ''
}`}
>
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
<div class="mt-1 space-y-1">
<Show when={hasOsInfo()}>
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium" title={osName()}>{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500"></span>
</Show>
<Show when={osVersion().length > 0}>
<span title={osVersion()}>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Memory</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
<Show when={hasMultipleDisks() && props.guest.disks && props.guest.disks.length > 0}>
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Filesystems</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
<Show when={hasNetworkInterfaces()}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network Interfaces</div>
<div class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">Row charts show current rate; totals below are cumulative since boot.</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={networkInterfaces()}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="space-y-1 rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700">
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
{iface.mac}
</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1">
<For each={addresses}>
{(ip) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
<span>Total RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>Total TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
</td>
</tr>
</Show>
</>
);
}

View File

@@ -0,0 +1,49 @@
import { createMemo, Show, createEffect, createSignal } from 'solid-js';
import { formatSpeed } from '@/utils/format';
import { AnimatedMetric } from '@/components/shared/AnimatedMetric';
interface IOMetricProps {
value: (() => number) | number;
disabled?: boolean;
}
export function IOMetric(props: IOMetricProps) {
// Handle both accessor functions and direct values
const getValue = () => {
return typeof props.value === 'function' ? props.value() : props.value;
};
// Create a local signal that tracks the value
const [currentValue, setCurrentValue] = createSignal(getValue() || 0);
// Update the signal when value changes
createEffect(() => {
const newValue = getValue() || 0;
const oldValue = currentValue();
if (newValue !== oldValue) {
setCurrentValue(newValue);
}
});
// Color based on speed (MB/s) - matching current dashboard
const colorClass = createMemo(() => {
if (props.disabled) return 'text-gray-400 dark:text-gray-500';
const mbps = currentValue() / (1024 * 1024);
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
if (mbps < 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
});
return (
<Show
when={!props.disabled}
fallback={<div class="min-h-[24px] flex items-center text-sm text-gray-400">-</div>}
>
<div class={`min-h-[24px] text-sm font-mono ${colorClass()} flex items-center`}>
<AnimatedMetric value={currentValue()} formatter={(v) => formatSpeed(v, 0)} />
</div>
</Show>
);
}

View File

@@ -0,0 +1,68 @@
import { createMemo } from 'solid-js';
interface MetricBarProps {
value: number;
label: string;
sublabel?: string;
type?: 'cpu' | 'memory' | 'disk' | 'generic';
}
export function MetricBar(props: MetricBarProps) {
const width = createMemo(() => Math.min(props.value, 100));
// Get color based on percentage and metric type (matching original)
const getColor = createMemo(() => {
const percentage = props.value;
const metric = props.type || 'generic';
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else {
if (percentage >= 90) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
}
});
// Map color to CSS classes
const progressColorClass = createMemo(() => {
const colorMap = {
red: 'bg-red-500/60 dark:bg-red-500/50',
yellow: 'bg-yellow-500/60 dark:bg-yellow-500/50',
green: 'bg-green-500/60 dark:bg-green-500/50',
};
return colorMap[getColor()] || 'bg-gray-500/60 dark:bg-gray-500/50';
});
// Combine label and sublabel for display text
const displayText = createMemo(() => {
if (props.sublabel) {
return `${props.label} (${props.sublabel})`;
}
return props.label;
});
return (
<div class="metric-text">
<div class="relative min-w-[120px] w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${progressColorClass()}`}
style={{ width: `${width()}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="whitespace-nowrap px-0.5">{displayText()}</span>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { Component, Show, createMemo, createEffect } from 'solid-js';
import type { Node } from '@/types/api';
import { formatUptime } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
interface NodeCardProps {
node: Node;
isSelected?: boolean;
}
const NodeCard: Component<NodeCardProps> = (props) => {
const { activeAlerts } = useWebSocket();
// Early return if node data is incomplete
if (!props.node || !props.node.memory || !props.node.disk) {
return (
<Card padding="sm" class="flex w-[180px] flex-col gap-1">
<div class="text-sm text-gray-500">Loading node data...</div>
</Card>
);
}
const isOnline = () =>
props.node.status === 'online' &&
props.node.uptime > 0 &&
props.node.connectionHealth !== 'error';
// Memoize CPU percent to avoid multiple calculations
const cpuPercent = createMemo(() => {
const percent = Math.round(props.node.cpu * 100);
return percent;
});
// Track CPU updates (logging removed for cleaner output)
createEffect(() => {
cpuPercent(); // Just track the value changes
});
const memPercent = createMemo(() => {
if (!props.node.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return Math.round(props.node.memory.usage || 0);
});
const diskPercent = createMemo(() => {
if (!props.node.disk || props.node.disk.total === 0) return 0;
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
});
const displayName = () => getNodeDisplayName(props.node);
const showActualName = () => hasAlternateDisplayName(props.node);
// Calculate normalized load (load average / cpu count)
const normalizedLoad = () => {
if (props.node.loadAverage && props.node.loadAverage.length > 0) {
const load1m = props.node.loadAverage[0];
if (typeof load1m === 'number' && !isNaN(load1m)) {
// Use CPU cores from cpuInfo if available, otherwise assume 4
const cpuCount = props.node.cpuInfo?.cores || 4;
return (load1m / cpuCount).toFixed(2);
}
}
return 'N/A';
};
// Helper function to create compact progress bar
const createProgressBar = (percentage: number, label: string, colorClass: string) => {
const bgColorClass = 'bg-gray-200 dark:bg-gray-600';
const progressColorClass =
{
red: 'bg-red-500/70 dark:bg-red-500/60',
yellow: 'bg-yellow-500/70 dark:bg-yellow-500/60',
green: 'bg-green-500/70 dark:bg-green-500/60',
}[colorClass] || 'bg-gray-500/70 dark:bg-gray-500/60';
return (
<div class="w-[140px]">
<div class="flex justify-between items-center mb-0.5">
<span class="text-[10px] font-medium text-gray-600 dark:text-gray-400">{label}</span>
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300">
{percentage}%
</span>
</div>
<div class={`relative w-full h-2 rounded-full overflow-hidden ${bgColorClass}`}>
<div
class={`absolute top-0 left-0 h-full transition-all duration-300 ${progressColorClass}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};
// Get color based on percentage and metric type
const getColor = (percentage: number, metric: 'cpu' | 'memory' | 'disk') => {
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
}
return 'green';
};
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() =>
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
);
const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged));
// Determine border/ring style based on status and alerts
const getBorderClass = () => {
// Selected nodes get blue ring
if (props.isSelected) {
return 'ring-2 ring-blue-500 border-blue-200 dark:border-blue-500';
}
// Offline nodes get red ring
if (!isOnline()) {
return 'ring-2 ring-red-500 border-red-200 dark:border-red-600';
}
// Alert nodes get colored ring based on severity
if (alertStyles.hasUnacknowledgedAlert) {
return alertStyles.severity === 'critical'
? 'ring-2 ring-red-500 border-red-200 dark:border-red-600'
: 'ring-2 ring-orange-500 border-orange-200 dark:border-orange-500';
}
if (alertStyles.hasAcknowledgedOnlyAlert) {
return 'ring-2 ring-gray-400 border-gray-200 dark:border-gray-600 dark:ring-gray-500';
}
// Normal nodes get standard border
return '';
};
// Get background class from alert styles but remove the border-l-4 part
const getBackgroundClass = () => {
if (!alertStyles.rowClass) return '';
// Remove border classes from rowClass to avoid conflicts
return alertStyles.rowClass.replace(/border-[^\s]+/g, '').trim();
};
return (
<Card
padding="sm"
class={`flex w-[180px] flex-col gap-2 ${getBorderClass()} ${getBackgroundClass()}`.trim()}
hoverable
>
{/* Header */}
<div class="flex justify-between items-center">
<h3 class="text-xs font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-1">
<a
href={
props.node.host ||
(props.node.name.includes(':')
? `https://${props.node.name}`
: `https://${props.node.name}:8006`)
}
target="_blank"
rel="noopener noreferrer"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${props.node.name} web interface`}
>
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
</Show>
{/* Cluster/Standalone indicator - more compact */}
<Show when={props.node.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
title={props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
>
{props.node.isClusterMember ? 'C' : 'S'}
</span>
</Show>
<Show when={alertStyles.hasUnacknowledgedAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
<Show when={(alertStyles.unacknowledgedCount || 0) > 1}>
<AlertCountBadge
count={alertStyles.unacknowledgedCount || 0}
severity={alertStyles.severity!}
alerts={unacknowledgedNodeAlerts()}
/>
</Show>
</div>
</Show>
</h3>
<span
class={`h-2 w-2 rounded-full flex-shrink-0 ${isOnline() ? 'bg-green-500' : 'bg-red-500'}`}
title={isOnline() ? 'Online' : 'Offline'}
/>
</div>
{/* Metrics - Compact */}
<div class="space-y-1.5">
{createProgressBar(cpuPercent(), 'CPU', getColor(cpuPercent(), 'cpu'))}
{createProgressBar(memPercent(), 'Memory', getColor(memPercent(), 'memory'))}
{createProgressBar(diskPercent(), 'Disk', getColor(diskPercent(), 'disk'))}
</div>
{/* Footer Info - More compact */}
<div class="flex justify-between text-[9px] text-gray-500 dark:text-gray-400 mt-1">
<span title={`Uptime: ${formatUptime(props.node.uptime)}`}>
{formatUptime(props.node.uptime)}
</span>
<Show
when={props.node.temperature?.available}
fallback={<span title={`Load: ${normalizedLoad()}`}>{normalizedLoad()}</span>}
>
<span
class={`font-medium ${
(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0) > 80
? 'text-red-500'
: (props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0) > 60
? 'text-yellow-500'
: 'text-green-500'
}`}
title={`CPU: ${Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C${props.node.temperature!.nvme && props.node.temperature!.nvme.length > 0 ? ` | NVMe: ${props.node.temperature!.nvme.map((n) => `${n.device}: ${Math.round(n.temp)}°C`).join(', ')}` : ''}`}
>
🌡{Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C
</span>
</Show>
</div>
</Card>
);
};
export default NodeCard;

View File

@@ -0,0 +1,98 @@
import { Component, For, Show } from 'solid-js';
import { getTagColorWithSpecial } from '@/utils/tagColors';
import { useDarkMode } from '@/App';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
interface TagBadgesProps {
tags?: string[];
maxVisible?: number;
isDarkMode?: boolean;
onTagClick?: (tag: string) => void;
activeSearch?: string;
}
export const TagBadges: Component<TagBadgesProps> = (props) => {
const maxVisible = () => props.maxVisible ?? 3;
const darkModeSignal = useDarkMode();
const isDark = () => props.isDarkMode ?? darkModeSignal();
const visibleTags = () => props.tags?.slice(0, maxVisible()) || [];
const hiddenTags = () => props.tags?.slice(maxVisible()) || [];
const TagDot: Component<{ tag: string }> = (dotProps) => {
const colors = () => getTagColorWithSpecial(dotProps.tag, isDark());
const isActive = () => props.activeSearch?.includes(`tags:${dotProps.tag}`) || false;
return (
<div
class="relative"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(dotProps.tag, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => {
hideTooltip();
}}
onClick={(e) => {
e.stopPropagation();
props.onTagClick?.(dotProps.tag);
}}
>
<div
class="w-2 h-2 rounded-full hover:scale-150 transition-transform duration-200 ease-out cursor-pointer"
style={{
'background-color': colors().bg,
'box-shadow': isActive()
? isDark()
? `0 0 0 2.5px rgba(255, 255, 255, 0.9)`
: `0 0 0 2.5px rgba(0, 0, 0, 0.8)`
: 'none',
}}
/>
</div>
);
};
return (
<Show when={props.tags && props.tags.length > 0}>
<div class="inline-flex items-center gap-1 ml-2">
<For each={visibleTags()}>
{(tag) => <TagDot tag={tag} />}
</For>
{/* Show the final dot if only one hidden tag remains */}
<Show when={hiddenTags().length === 1}>
<TagDot tag={hiddenTags()[0]} />
</Show>
{/* Show +X more indicator if there are multiple hidden tags */}
<Show when={hiddenTags().length > 1}>
<div
class="relative"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = hiddenTags().join('\n');
if (content) {
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
maxWidth: 260,
});
}
}}
onMouseLeave={() => {
hideTooltip();
}}
>
<div class="inline-flex items-center text-[10px] text-gray-500 dark:text-gray-400 whitespace-nowrap leading-none cursor-pointer hover:text-gray-700 dark:hover:text-gray-300 hover:scale-125 transition-transform duration-200 ease-out">
+{hiddenTags().length}
</div>
</div>
</Show>
</div>
</Show>
);
};

View File

@@ -0,0 +1,127 @@
import { createSignal, createEffect, onMount } from 'solid-js';
interface ThresholdSliderProps {
value: number;
onChange: (value: number) => void;
type: 'cpu' | 'memory' | 'disk';
min?: number;
max?: number;
}
export function ThresholdSlider(props: ThresholdSliderProps) {
let sliderRef: HTMLInputElement | undefined;
let thumbRef: HTMLDivElement | undefined;
const [thumbPosition, setThumbPosition] = createSignal(0);
const [isDragging, setIsDragging] = createSignal(false);
// Color mapping
const colorMap = {
cpu: 'text-blue-500',
memory: 'text-green-500',
disk: 'text-amber-500',
};
// Calculate visual position - allow full range 0-100%
const calculateVisualPosition = (value: number) => {
const min = props.min || 0;
const max = props.max || 100;
const percent = ((value - min) / (max - min)) * 100;
// Use full range, handle edge cases with CSS
return Math.max(0, Math.min(100, percent));
};
// Update thumb position when value changes
createEffect(() => {
if (sliderRef) {
setThumbPosition(calculateVisualPosition(props.value));
}
});
onMount(() => {
// Initialize thumb position
setThumbPosition(calculateVisualPosition(props.value));
});
// Prevent scrolling while dragging
const handleMouseDown = () => {
setIsDragging(true);
// Store the current scroll position
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const handleScroll = () => {
window.scrollTo(scrollX, scrollY);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('scroll', handleScroll, { capture: true });
document.removeEventListener('mouseup', handleMouseUp);
};
// Lock scroll position while dragging
window.addEventListener('scroll', handleScroll, { capture: true });
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
class="relative w-full h-3.5 overflow-visible"
onWheel={(e) => isDragging() && e.preventDefault()}
style={{ 'touch-action': isDragging() ? 'none' : 'auto' }}
>
{/* Track background */}
<div class="absolute inset-0 h-3.5 rounded bg-gray-200 dark:bg-gray-600"></div>
{/* Colored fill */}
<div
class={`absolute left-0 h-3.5 rounded ${
props.type === 'cpu'
? 'bg-blue-500/30'
: props.type === 'memory'
? 'bg-green-500/30'
: 'bg-amber-500/30'
}`}
style={{ width: `${calculateVisualPosition(props.value)}%` }}
></div>
{/* Native range input (invisible but functional) */}
<input
ref={sliderRef}
type="range"
min={props.min || 0}
max={props.max || 100}
value={props.value}
onInput={(e) => props.onChange(parseInt(e.currentTarget.value))}
onMouseDown={handleMouseDown}
onWheel={(e) => e.preventDefault()}
class="absolute inset-0 w-full h-3.5 opacity-0 cursor-pointer z-20"
style={{ 'touch-action': 'none' }}
title={`${props.type.toUpperCase()}: ${props.value}%`}
/>
{/* Custom thumb with value */}
<div
ref={thumbRef}
class={`absolute top-1/2 pointer-events-none z-10 ${colorMap[props.type]}`}
style={{
left: `${thumbPosition()}%`,
transform: `translateY(-50%) translateX(${
thumbPosition() <= 1
? '0%' // At 0-1%, keep at left edge
: thumbPosition() >= 99
? '-100%' // At 99-100%, keep at right edge
: '-50%' // Otherwise center
})`,
}}
>
<div class="relative">
<div class="w-9 h-4 bg-white dark:bg-gray-800 rounded-full shadow-md border-2 border-current flex items-center justify-center">
<span class="text-[9px] font-semibold">{props.value}%</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { createSignal, onMount, Show } from 'solid-js';
export function DemoBanner() {
const [isDemoMode, setIsDemoMode] = createSignal(false);
const [dismissed, setDismissed] = createSignal(false);
onMount(async () => {
// Check if we're in demo mode by trying a test request
try {
const response = await fetch('/api/health');
const demoHeader = response.headers.get('X-Demo-Mode');
if (demoHeader === 'true') {
setIsDemoMode(true);
}
} catch (_err) {
// Ignore errors
}
});
const handleDismiss = () => {
setDismissed(true);
// Remember dismissal for this session only
sessionStorage.setItem('demoBannerDismissed', 'true');
};
// Check if already dismissed this session
onMount(() => {
if (sessionStorage.getItem('demoBannerDismissed') === 'true') {
setDismissed(true);
}
});
return (
<Show when={isDemoMode() && !dismissed()}>
<div class="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
<div class="container mx-auto flex items-center justify-between text-sm">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span>
Demo instance with mock data (read-only)
</span>
</div>
<button
onClick={handleDismiss}
class="p-1 hover:bg-blue-100 dark:hover:bg-blue-800/50 rounded text-blue-600 dark:text-blue-400 transition-colors"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
);
}

View File

@@ -0,0 +1,364 @@
import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
interface DockerFilterProps {
search: () => string;
setSearch: (value: string) => void;
groupingMode?: () => 'grouped' | 'flat';
setGroupingMode?: (mode: 'grouped' | 'flat') => void;
searchInputRef?: (el: HTMLInputElement) => void;
onReset?: () => void;
activeHostName?: string;
onClearHost?: () => void;
}
export const DockerFilter: Component<DockerFilterProps> = (props) => {
const historyManager = createSearchHistoryManager(STORAGE_KEYS.DOCKER_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
let searchInputEl: HTMLInputElement | undefined;
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
onMount(() => {
setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
const trimmed = term.trim();
if (!trimmed) return;
const updated = historyManager.add(trimmed);
setSearchHistory(updated);
};
const deleteHistoryEntry = (term: string) => {
setSearchHistory(historyManager.remove(term));
};
const clearHistory = () => {
setSearchHistory(historyManager.clear());
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const focusSearchInput = () => {
queueMicrotask(() => searchInputEl?.focus());
};
let suppressBlurCommit = false;
const markSuppressCommit = () => {
suppressBlurCommit = true;
queueMicrotask(() => {
suppressBlurCommit = false;
});
};
const hasActiveFilters = createMemo(
() =>
props.search().trim() !== '' ||
(!!props.groupingMode && props.groupingMode() === 'flat') ||
Boolean(props.activeHostName),
);
const handleReset = () => {
props.setSearch('');
props.setGroupingMode?.('grouped');
props.onClearHost?.();
props.onReset?.();
closeHistory();
focusSearchInput();
};
return (
<Card class="docker-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search containers or host:hostname"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'docker-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search containers by name, image, ID, or host"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="docker-search-help"
intro="Filter Docker containers quickly"
tips={[
{ code: 'name:api', description: 'Match containers with "api" in the name' },
{ code: 'image:postgres', description: 'Find containers running a specific image' },
{ code: 'host:prod', description: 'Show containers on a host' },
{ code: 'state:running', description: 'Running containers only' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.groupingMode && props.setGroupingMode}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setGroupingMode?.('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode?.() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Grouped
</button>
<button
type="button"
onClick={() => props.setGroupingMode?.('flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode?.() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
List
</button>
</div>
</Show>
<Show when={props.activeHostName}>
<div class="flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
<span>Host: {props.activeHostName}</span>
<button
type="button"
class="text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-100"
onClick={() => props.onClearHost?.()}
title="Clear host filter"
>
×
</button>
</div>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,317 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { renderDockerStatusBadge } from './DockerStatusBadge';
import { formatUptime } from '@/utils/format';
export interface DockerHostSummary {
host: DockerHost;
cpuPercent: number;
memoryPercent: number;
memoryLabel?: string;
runningPercent: number;
runningCount: number;
totalCount: number;
uptimeSeconds: number;
lastSeenRelative: string;
lastSeenAbsolute: string;
}
interface DockerHostSummaryTableProps {
summaries: () => DockerHostSummary[];
selectedHostId: () => string | null;
onSelect: (hostId: string) => void;
}
type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'running' | 'lastSeen' | 'agent';
type SortDirection = 'asc' | 'desc';
const isHostOnline = (host: DockerHost) => {
const status = host.status?.toLowerCase() ?? '';
return status === 'online' || status === 'running' || status === 'healthy';
};
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
const [sortKey, setSortKey] = createSignal<SortKey>('name');
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection(key === 'name' ? 'asc' : 'desc');
}
};
const formatLastSeenValue = (lastSeen?: number) => {
if (!lastSeen) return 0;
return lastSeen;
};
const sortedSummaries = createMemo(() => {
const list = [...props.summaries()];
const key = sortKey();
const dir = sortDirection();
list.sort((a, b) => {
const hostA = a.host;
const hostB = b.host;
let value = 0;
switch (key) {
case 'name':
value = hostA.displayName.localeCompare(hostB.displayName);
break;
case 'uptime':
value = (a.uptimeSeconds || 0) - (b.uptimeSeconds || 0);
break;
case 'cpu':
value = a.cpuPercent - b.cpuPercent;
break;
case 'memory':
value = a.memoryPercent - b.memoryPercent;
break;
case 'running':
value = a.runningPercent - b.runningPercent;
break;
case 'lastSeen':
value = formatLastSeenValue(hostA.lastSeen) - formatLastSeenValue(hostB.lastSeen);
break;
case 'agent':
// Sort by version, putting outdated versions first (for easy identification)
const aOutdated = isAgentOutdated(hostA.agentVersion);
const bOutdated = isAgentOutdated(hostB.agentVersion);
if (aOutdated !== bOutdated) {
value = aOutdated ? -1 : 1;
} else {
value = (hostA.agentVersion || '').localeCompare(hostB.agentVersion || '');
}
break;
}
if (value === 0) {
value = hostA.displayName.localeCompare(hostB.displayName);
}
return dir === 'asc' ? value : -value;
});
return list;
});
const renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼';
};
const isAgentOutdated = (version?: string) => {
if (!version) return false;
return version.includes('dev') || version.startsWith('0.1');
};
return (
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[720px] border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Host {renderSortIndicator('name')}
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Status
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {renderSortIndicator('cpu')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {renderSortIndicator('memory')}
</th>
<th
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('running')}
>
Containers {renderSortIndicator('running')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {renderSortIndicator('uptime')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('lastSeen')}
>
Last Update {renderSortIndicator('lastSeen')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('agent')}
>
Agent {renderSortIndicator('agent')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedSummaries()}>
{(summary) => {
const selected = props.selectedHostId() === summary.host.id;
const online = isHostOnline(summary.host);
const rowStyle = () => {
const styles: Record<string, string> = {};
const shadows: string[] = [];
if (selected) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
}
if (shadows.length > 0) {
styles['box-shadow'] = shadows.join(', ');
}
return styles;
};
const rowClass = () => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
if (selected) {
return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';
}
let className = baseHover;
if (!online) {
className += ' opacity-60';
}
return className;
};
return (
<tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onSelect(summary.host.id)}
>
<td class="pr-2 py-0.5 pl-3 whitespace-nowrap">
<div class="flex items-center gap-1">
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
{summary.host.displayName}
</span>
<Show when={summary.host.displayName !== summary.host.hostname}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
({summary.host.hostname})
</span>
</Show>
<Show when={summary.host.dockerVersion}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Docker {summary.host.dockerVersion}
</span>
</Show>
</div>
</td>
<td class="px-2 py-0.5">
{renderDockerStatusBadge(summary.host.status)}
</td>
<td class="px-2 py-0.5">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.cpuPercent}
label={`${summary.cpuPercent.toFixed(1)}%`}
type="cpu"
/>
</Show>
</td>
<td class="px-2 py-0.5">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.memoryPercent}
label={`${summary.memoryPercent.toFixed(1)}%`}
sublabel={summary.memoryLabel}
type="memory"
/>
</Show>
</td>
<td class="px-2 py-0.5">
<div class="flex justify-center">
<MetricBar
value={summary.runningPercent}
label={`${summary.runningCount}/${summary.totalCount}`}
type="generic"
/>
</div>
</td>
<td class="px-2 py-0.5 whitespace-nowrap">
<span class="text-xs text-gray-600 dark:text-gray-400">
{summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'}
</span>
</td>
<td class="px-2 py-0.5">
<div class="text-sm text-gray-900 dark:text-gray-100">
{summary.lastSeenRelative}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{summary.lastSeenAbsolute}
</div>
</td>
<td class="px-2 py-0.5">
<div class="flex flex-col gap-0.5">
<Show when={summary.host.agentVersion}>
<div class="flex flex-col gap-0.5">
<span
class={
isAgentOutdated(summary.host.agentVersion)
? 'text-[10px] px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium inline-block w-fit'
: 'text-[10px] px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium inline-block w-fit'
}
title={
isAgentOutdated(summary.host.agentVersion)
? 'Outdated - Update recommended'
: 'Up to date - Auto-update enabled'
}
>
v{summary.host.agentVersion}
</span>
<Show when={isAgentOutdated(summary.host.agentVersion)}>
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
Update available
</span>
</Show>
</div>
</Show>
<Show when={summary.host.intervalSeconds}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{summary.host.intervalSeconds}s
</span>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Card>
);
};

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More