diff --git a/.dockerignore b/.dockerignore index 78c0ef4..d0faeee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,8 +10,17 @@ dev-docs/ # Binaries and build artifacts pulse backend +/pulse-host-agent +/pulse-host-agent-* +/pulse-docker-agent +/pulse-server bin/ dist/ +frontend/ +frontend-modern/public/download/ +frontend-modern/public/pulse-host-agent* +frontend-modern/dist/ +scripts/macos/dist/ *.exe *.dll *.so @@ -45,4 +54,4 @@ testing-tools/ # Temporary files *.tmp *.temp -*~ \ No newline at end of file +*~ diff --git a/.gitignore b/.gitignore index abf9781..43ff3ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # Binaries /bin/ /pulse +/pulse-docker-agent +/pulse-sensor-proxy +/pulse-server +/pulse-test +/pulse-host-agent +/pulse-host-agent-* # Logs *.log @@ -28,7 +34,6 @@ Thumbs.db *.test *.out vendor/ -pulse-test test-pulse # Node.js (for frontend-modern) @@ -36,6 +41,9 @@ node_modules/ .npm/ .yarn/ frontend-modern/.vite/ +frontend/ +frontend-modern/public/download/ +data/ # Environment .env @@ -47,6 +55,8 @@ dist/ build/ *.tar.gz pulse-fixes*.tar.gz +release/pulse-host-agent-*.tar.gz +scripts/macos/dist/ # Frontend copy for embedding (generated during build) # Frontend build artifact for Go embedding diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0a31a93 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing to Pulse + +Thanks for investing time in Pulse! This document collects the essentials you +need to be productive across the Go backend, React/TypeScript frontend, and the +installer tooling. + +--- + +## Project Overview + +- **Backend (`cmd/`, `internal/`, `pkg/`)** – Go 1.23+ web server that embeds + the built frontend and exposes REST + WebSocket APIs. +- **Frontend (`frontend-modern/`)** – Vite + React app built with TypeScript. +- **Agents (`cmd/pulse-*-agent`)** – Go binaries distributed alongside Pulse for + host and Docker telemetry. +- **Documentation (`docs/`)** – Markdown-based guides published to users and + referenced from the README. +- **Scripts (`scripts/`)** – Bash installers and helpers bundled for + curl-based distribution. + +--- + +## Getting Started + +```bash +git clone https://github.com/rcourtman/Pulse.git +cd Pulse + +# Install dependencies +brew install go node npm # or use your distro equivalents + +# Install JS deps +cd frontend-modern +npm install +cd .. +``` + +### Hot Reload Dev Loop + +```bash +./scripts/hot-dev.sh # Backend on :7656, frontend on :7655 +npm run mock:on # Optional: enable mock data +``` + +See `docs/development/MOCK_MODE.md` for tips on synthetic fixtures. + +--- + +## Backend Workflow + +- Build: `go build ./cmd/pulse` +- Tests: `go test ./...` +- Lint: `golangci-lint run ./...` (install via `go install` if missing) +- Formatting: `gofmt -w ./cmd ./internal ./pkg` + +Key entry points: +- HTTP router lives in `internal/api`. +- Monitoring engines live under `internal/monitor`. +- Configuration parsing resides in `internal/config`. + +When adding new API endpoints, document them in `docs/API.md` and provide +examples where possible. + +--- + +## Frontend Workflow + +- Dev server: `cd frontend-modern && npm run dev` +- Tests: `npm run test` +- Lint: `npm run lint` +- Format: `npm run format` +- Production build: `npm run build` (copied into `internal/api/frontend-modern` + via the Makefile). + +Use modern React patterns (hooks, function components) and prefer TanStack Query +for data fetching. Add Storybook stories or screenshots when introducing new +UI-heavy features. + +--- + +## Installers & Scripts + +- Centralised guidance: `docs/script-library-guide.md` +- Bundling: `make bundle-scripts` +- Tests: `scripts/tests/run.sh` plus integration suites under + `scripts/tests/integration/` + +Document rollout plans and kill switches in `docs/installer-v2-rollout.md` and +`MIGRATION_SCAFFOLDING.md`. + +--- + +## Documentation Standards + +- Author or update guides in `docs/` when behaviour changes. +- Organise new topics through `docs/README.md` so they appear in the docs index. +- Avoid marketing copy in technical docs—save that for `README.md` or + external sites. +- Keep instructions evergreen; put release-specific notes in + `docs/RELEASE_NOTES.md`. + +Run a quick link check (`npm run lint-docs` if available, or `markdownlint`) +before submitting large doc updates. + +--- + +## Testing Expectations + +- Every PR should note the tests run (`go test`, `npm test`, `scripts/tests/run.sh`). +- Add regression coverage when fixing bugs. +- Mention manual verification steps (e.g., “Proxmox LXC installer tested on + PVE 8.1”) if automated coverage is not feasible. + +--- + +## Coding Guidelines + +- Adhere to existing formatting tools (`gofmt`, `prettier`, `eslint`). +- Name Go packages with short, meaningful identifiers (avoid `util`). +- Keep functions focused; prefer small helpers over large monoliths. +- Prefer context-aware logging (`logger.Named("component")`) in new Go code. +- Ensure secrets never reach logs and redact sensitive fields in API responses. + +--- + +## Submitting Changes + +1. Fork + branch (`git checkout -b feature/my-change`). +2. Make your edits and run relevant tests. +3. Update docs and changelog entries as needed. +4. Open a PR describing: + - What changed + - Why it changed + - Testing performed + - Rollout / migration concerns + +Reviewers will focus on correctness, security, and upgrade paths, so call out +anything unusual up front. Thanks again for contributing! + diff --git a/DEV-QUICK-START.md b/DEV-QUICK-START.md index 4c5218f..fb980f7 100644 --- a/DEV-QUICK-START.md +++ b/DEV-QUICK-START.md @@ -1,5 +1,11 @@ # Development Quick Start +## Prerequisites + +- Go **1.24.9** or newer +- Node.js 20+ +- pnpm 9+ (for frontend work) + ## Hot-Reload Development Mode Start the development environment with hot-reload: diff --git a/Dockerfile b/Dockerfile index 8ec5133..68e0c5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BUILD_AGENT=1 # Build stage for frontend (must be built first for embedding) -FROM node:20-alpine AS frontend-builder +FROM node:20.16.0-alpine3.19 AS frontend-builder WORKDIR /app/frontend-modern @@ -19,7 +19,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \ npm run build # Build stage for Go backend -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.24.9-alpine3.19 AS backend-builder ARG BUILD_AGENT WORKDIR /app @@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ -o pulse-sensor-proxy ./cmd/pulse-sensor-proxy # Runtime image for the Docker agent (offered via --target agent_runtime) -FROM alpine:latest AS agent_runtime +FROM alpine:3.19.1 AS agent_runtime # Use TARGETARCH to select the correct binary for the build platform ARG TARGETARCH @@ -118,7 +118,7 @@ ENV PULSE_NO_AUTO_UPDATE=true ENTRYPOINT ["/usr/local/bin/pulse-docker-agent"] # Final stage (Pulse server runtime) -FROM alpine:latest +FROM alpine:3.19.1 RUN apk --no-cache add ca-certificates tzdata su-exec openssh-client diff --git a/MIGRATION_SCAFFOLDING.md b/MIGRATION_SCAFFOLDING.md index d8844f1..424366f 100644 --- a/MIGRATION_SCAFFOLDING.md +++ b/MIGRATION_SCAFFOLDING.md @@ -8,41 +8,9 @@ This document tracks temporary code added to handle migration paths between vers ## Active Migration Code -### Legacy SSH Detection Banner (Added: v4.23.0) - -**Why it exists**: Users who set up temperature monitoring before v4.23.0 used SSH keys directly in the container. The new secure architecture uses `pulse-sensor-proxy` on the host with Unix socket communication. Users with the old setup need to remove and re-add their nodes to upgrade. - -**Files involved**: -- Backend: `/opt/pulse/internal/api/router.go` - `detectLegacySSH()` function -- Backend: `/opt/pulse/internal/api/types.go` - `HealthResponse.LegacySSHDetected` fields -- Frontend: `/opt/pulse/frontend-modern/src/components/LegacySSHBanner.tsx` -- Frontend: `/opt/pulse/frontend-modern/src/App.tsx` - Banner integration - -**Removal criteria** (ANY of these): -- Version reaches v5.0 or later -- Telemetry shows <1% detection rate for 30+ consecutive days -- 6 months after v4.23.0 release (whichever comes first) - -**How to remove**: -1. Delete `LegacySSHBanner.tsx` component -2. Remove import and usage from `App.tsx` -3. Delete `detectLegacySSH()` function from `router.go` -4. Remove `LegacySSHDetected`, `RecommendProxyUpgrade`, `ProxyInstallScriptAvailable` from `types.go` HealthResponse -5. Remove banner logic from `handleHealth()` in `router.go` -6. Delete this entry from this document - -**Manual override**: Set `PULSE_LEGACY_DETECTION=false` environment variable to disable detection without code changes. - -**Telemetry notes**: Currently no telemetry implemented. Consider adding metrics to track: -- How often the banner is shown -- How many users still have legacy SSH setup -- When detection rate drops below threshold - ---- - ## Removed Migration Code -(None yet - this document was created v4.23.0) +- Legacy SSH detection banner (removed in cleanup) --- diff --git a/Makefile b/Makefile index d8965b2..365d91f 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,24 @@ # Pulse Makefile for development -.PHONY: build run dev frontend backend all clean dev-hot lint lint-backend lint-frontend format format-backend format-frontend +.PHONY: build run dev frontend backend all clean distclean dev-hot lint lint-backend lint-frontend format format-backend format-frontend + +FRONTEND_DIR := frontend-modern +FRONTEND_DIST := $(FRONTEND_DIR)/dist +FRONTEND_EMBED_DIR := internal/api/frontend-modern # Build everything all: frontend backend # Build frontend only frontend: - cd frontend-modern && npm run build + npm --prefix $(FRONTEND_DIR) 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/ + rm -rf $(FRONTEND_EMBED_DIR) + mkdir -p $(FRONTEND_EMBED_DIR) + cp -r $(FRONTEND_DIST) $(FRONTEND_EMBED_DIR)/ @echo "✓ Frontend copied for embedding" # Build backend only (includes embedded frontend) @@ -38,7 +42,10 @@ dev-hot: # Clean build artifacts clean: rm -f pulse - rm -rf frontend-modern/dist + rm -rf $(FRONTEND_DIST) $(FRONTEND_EMBED_DIR) + +distclean: clean + ./scripts/cleanup.sh # Quick rebuild and restart for development restart: frontend backend @@ -51,7 +58,7 @@ lint-backend: golangci-lint run ./... lint-frontend: - cd frontend-modern && npm run lint + npm --prefix $(FRONTEND_DIR) run lint # Apply formatters format: format-backend format-frontend @@ -60,4 +67,4 @@ format-backend: gofmt -w cmd internal pkg format-frontend: - cd frontend-modern && npm run format + npm --prefix $(FRONTEND_DIR) run format diff --git a/README.md b/README.md index ba71b99..739e166 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,13 @@ helm install pulse oci://ghcr.io/rcourtman/pulse-chart \ ### Initial Setup **Option A: Interactive Setup (UI)** -1. Open `http://:7655` -2. **Complete the mandatory security setup** (first-time only) -3. Create your admin username and password -4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation. Assign scopes so each token only has the permissions it needs (e.g. `docker:report`, `host-agent:report`). Legacy tokens default to full access until you edit and save new scopes. +1. On the host, read the bootstrap token that protects the first-time setup screen: + - Standard install: `cat /etc/pulse/.bootstrap_token` + - Docker/Helm: `cat /data/.bootstrap_token` inside the container or mounted volume +2. Open `http://:7655` +3. When prompted, paste the bootstrap token to unlock the wizard, then **complete the mandatory security setup** (first-time only) +4. Create your admin username and password and store the generated API token safely (the token file is removed after setup) +5. Use **Settings → Security → API tokens** to mint dedicated tokens for automation. Assign scopes so each token only has the permissions it needs (e.g. `docker:report`, `host-agent:report`). Legacy tokens default to full access until you edit and save new scopes. **Option B: Automated Setup (No UI)** For automated deployments, configure authentication via environment variables: diff --git a/SECURITY.md b/SECURITY.md index a8cc3fa..f305edf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -396,8 +396,8 @@ docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest # 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" +# or in Authorization header (preferred for shared tooling) +curl -H "Authorization: Bearer your-original-token" http://localhost:7655/api/export ``` ### Auto-Registration Security diff --git a/cmd/pulse-docker-agent/main.go b/cmd/pulse-docker-agent/main.go index 9d31bbf..f78eb1d 100644 --- a/cmd/pulse-docker-agent/main.go +++ b/cmd/pulse-docker-agent/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog" ) @@ -57,20 +58,21 @@ func main() { } 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")) - envNoAutoUpdate := strings.TrimSpace(os.Getenv("PULSE_NO_AUTO_UPDATE")) - envTargets := strings.TrimSpace(os.Getenv("PULSE_TARGETS")) - envContainerStates := strings.TrimSpace(os.Getenv("PULSE_CONTAINER_STATES")) - envSwarmScope := strings.TrimSpace(os.Getenv("PULSE_SWARM_SCOPE")) - envSwarmServices := strings.TrimSpace(os.Getenv("PULSE_SWARM_SERVICES")) - envSwarmTasks := strings.TrimSpace(os.Getenv("PULSE_SWARM_TASKS")) - envIncludeContainers := strings.TrimSpace(os.Getenv("PULSE_INCLUDE_CONTAINERS")) - envCollectDisk := strings.TrimSpace(os.Getenv("PULSE_COLLECT_DISK")) + envURL := utils.GetenvTrim("PULSE_URL") + envToken := utils.GetenvTrim("PULSE_TOKEN") + envInterval := utils.GetenvTrim("PULSE_INTERVAL") + envHostname := utils.GetenvTrim("PULSE_HOSTNAME") + envAgentID := utils.GetenvTrim("PULSE_AGENT_ID") + envInsecure := utils.GetenvTrim("PULSE_INSECURE_SKIP_VERIFY") + envNoAutoUpdate := utils.GetenvTrim("PULSE_NO_AUTO_UPDATE") + envTargets := utils.GetenvTrim("PULSE_TARGETS") + envRuntime := utils.GetenvTrim("PULSE_RUNTIME") + envContainerStates := utils.GetenvTrim("PULSE_CONTAINER_STATES") + envSwarmScope := utils.GetenvTrim("PULSE_SWARM_SCOPE") + envSwarmServices := utils.GetenvTrim("PULSE_SWARM_SERVICES") + envSwarmTasks := utils.GetenvTrim("PULSE_SWARM_TASKS") + envIncludeContainers := utils.GetenvTrim("PULSE_INCLUDE_CONTAINERS") + envCollectDisk := utils.GetenvTrim("PULSE_COLLECT_DISK") defaultInterval := 30 * time.Second if envInterval != "" { @@ -86,22 +88,22 @@ func loadConfig() dockeragent.Config { includeServicesDefault := true if envSwarmServices != "" { - includeServicesDefault = parseBool(envSwarmServices) + includeServicesDefault = utils.ParseBool(envSwarmServices) } includeTasksDefault := true if envSwarmTasks != "" { - includeTasksDefault = parseBool(envSwarmTasks) + includeTasksDefault = utils.ParseBool(envSwarmTasks) } includeContainersDefault := true if envIncludeContainers != "" { - includeContainersDefault = parseBool(envIncludeContainers) + includeContainersDefault = utils.ParseBool(envIncludeContainers) } collectDiskDefault := true if envCollectDisk != "" { - collectDiskDefault = parseBool(envCollectDisk) + collectDiskDefault = utils.ParseBool(envCollectDisk) } urlFlag := flag.String("url", envURL, "Pulse server URL (e.g. http://pulse:7655)") @@ -109,8 +111,9 @@ func loadConfig() dockeragent.Config { 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") - noAutoUpdateFlag := flag.Bool("no-auto-update", parseBool(envNoAutoUpdate), "Disable automatic agent updates") + insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS certificate verification") + noAutoUpdateFlag := flag.Bool("no-auto-update", utils.ParseBool(envNoAutoUpdate), "Disable automatic agent updates") + runtimeFlag := flag.String("runtime", envRuntime, "Container runtime to expect (auto, docker, podman)") var targetFlags stringFlagList flag.Var(&targetFlags, "target", "Pulse target in url|token[|insecure] format. Repeat to send to multiple Pulse instances") var containerStateFlags stringFlagList @@ -182,6 +185,7 @@ func loadConfig() dockeragent.Config { Targets: targets, ContainerStates: containerStates, SwarmScope: strings.ToLower(strings.TrimSpace(*swarmScopeFlag)), + Runtime: strings.ToLower(strings.TrimSpace(*runtimeFlag)), IncludeServices: *includeServicesFlag, IncludeTasks: *includeTasksFlag, IncludeContainers: *includeContainersFlag, @@ -189,15 +193,6 @@ func loadConfig() dockeragent.Config { } } -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 { diff --git a/cmd/pulse-host-agent/main.go b/cmd/pulse-host-agent/main.go index b8ec5af..379761d 100644 --- a/cmd/pulse-host-agent/main.go +++ b/cmd/pulse-host-agent/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog" ) @@ -60,14 +61,14 @@ func main() { } func loadConfig() hostagent.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")) - envTags := strings.TrimSpace(os.Getenv("PULSE_TAGS")) - envRunOnce := strings.TrimSpace(os.Getenv("PULSE_ONCE")) + envURL := utils.GetenvTrim("PULSE_URL") + envToken := utils.GetenvTrim("PULSE_TOKEN") + envInterval := utils.GetenvTrim("PULSE_INTERVAL") + envHostname := utils.GetenvTrim("PULSE_HOSTNAME") + envAgentID := utils.GetenvTrim("PULSE_AGENT_ID") + envInsecure := utils.GetenvTrim("PULSE_INSECURE_SKIP_VERIFY") + envTags := utils.GetenvTrim("PULSE_TAGS") + envRunOnce := utils.GetenvTrim("PULSE_ONCE") defaultInterval := 30 * time.Second if envInterval != "" { @@ -81,8 +82,8 @@ func loadConfig() hostagent.Config { intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval (e.g. 30s, 1m)") 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") - runOnceFlag := flag.Bool("once", parseBool(envRunOnce), "Collect and send a single report, then exit") + insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS certificate verification") + runOnceFlag := flag.Bool("once", utils.ParseBool(envRunOnce), "Collect and send a single report, then exit") showVersion := flag.Bool("version", false, "Print the agent version and exit") var tagFlags multiValue @@ -143,12 +144,3 @@ func gatherTags(env string, flags []string) []string { } return tags } - -func parseBool(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "y", "on": - return true - default: - return false - } -} diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index 082d975..42931d5 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -239,10 +239,9 @@ func runServer() { // Reload system.json persistence := config.NewConfigPersistence(cfg.DataPath) if persistence != nil { - if sysConfig, err := persistence.LoadSystemSettings(); err == nil { + if _, 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") diff --git a/docker-compose.parallel.yml b/docker-compose.parallel.yml deleted file mode 100644 index 6744eed..0000000 --- a/docker-compose.parallel.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "3.8" - -x-codex-mcp-http: &codex_mcp_http - build: . - image: codex-mcp-server:latest - entrypoint: /bin/sh - command: > - -c 'pip install --quiet --disable-pip-version-check uvicorn starlette sse-starlette && - mkdir -p /root/.config && - cp -r /gh-config-source /root/.config/gh && - exec python /app/server.py' - volumes: - - /usr/local/bin/codex:/usr/local/bin/codex:ro - - /usr/local/lib/node_modules/@openai/codex:/usr/local/lib/node_modules/@openai/codex:ro - - /usr/local/lib/node_modules/@openai/codex/vendor:/usr/local/vendor:ro - - /tmp:/tmp - - /home/pulse/.codex:/root/.codex - - /home/pulse/.config/gh:/gh-config-source:ro - - /opt/pulse:/opt/pulse - - /opt/pulse/.mcp-servers/codex/server_http_fixed.py:/app/server.py:ro - environment: - HOME: /root - labels: - com.pulse.project: "codex-mcp" - -services: - codex-parallel-1: - <<: *codex_mcp_http - container_name: codex-parallel-1 - ports: - - "18765:8765" - - codex-parallel-2: - <<: *codex_mcp_http - container_name: codex-parallel-2 - ports: - - "18766:8765" - - codex-parallel-3: - <<: *codex_mcp_http - container_name: codex-parallel-3 - ports: - - "18767:8765" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh old mode 100644 new mode 100755 diff --git a/docs/API.md b/docs/API.md index ce4c720..62676d8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -49,8 +49,10 @@ docker run -e API_TOKENS=token-a,token-b rcourtman/pulse:latest # With API token (header) curl -H "X-API-Token: your-secure-token" http://localhost:7655/api/health -# With API Token (query parameter, for export/import) -curl "http://localhost:7655/api/export?token=your-secure-token" +# With API token (Authorization header) +curl -H "Authorization: Bearer your-secure-token" http://localhost:7655/api/health + +# (Query parameters are rejected to avoid leaking tokens in logs or referrers.) # With session cookie (after login) curl -b cookies.txt http://localhost:7655/api/health @@ -125,8 +127,6 @@ Response: "status": "healthy", "timestamp": 1754995749, "uptime": 166.187561244, - "legacySSHDetected": false, - "recommendProxyUpgrade": false, "proxyInstallScriptAvailable": true, "devModeSSH": false } @@ -218,7 +218,7 @@ Each service entry lists offline daemons in `message` when present (for example, ### Scheduler Health -**New in v4.24.0:** Monitor Pulse's internal adaptive polling scheduler and circuit breaker status. +Monitor Pulse's internal adaptive polling scheduler and circuit breaker status. ```bash GET /api/monitoring/scheduler/health @@ -479,15 +479,22 @@ Quick setup for authentication (first-time setup). POST /api/security/quick-setup ``` +Authentication: +- Provide the bootstrap token in the `X-Setup-Token` header (or in the JSON payload) when no auth is configured yet. +- Once credentials exist, an authenticated admin session or API token with `settings:write` is required. + Request body: ```json { "username": "admin", "password": "secure-password", - "generateApiToken": true + "apiToken": "raw-token-value", + "setupToken": "" } ``` +The bootstrap token can be read from `/.bootstrap_token` in the data directory (for example `/etc/pulse/.bootstrap_token` on bare metal or `/data/.bootstrap_token` in Docker). The token file is removed automatically after a successful setup run. + #### API Token Management Manage API tokens for automation workflows, Docker agents, and tool integrations. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c39fc60..02bfb2f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -286,7 +286,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout - 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`). -- `dockerIgnoredContainerPrefixes` lets you silence state/metric/restart alerts for ephemeral containers whose names or IDs share a common, case-insensitive prefix. The Docker tab in the UI keeps this list in sync. +- `dockerIgnoredContainerPrefixes` lets you silence state/metric/restart alerts for ephemeral containers whose names or IDs share a common, case-insensitive prefix. The Containers tab in the UI keeps this list in sync. - Swarm service alerts track missing replicas: `serviceWarnGapPercent` defines when a warning fires, and `serviceCriticalGapPercent` must be greater than or equal to the warning gap (Pulse automatically clamps the critical value upward if an older client submits something smaller). - Docker container state controls live in `dockerDefaults`: flip `stateDisableConnectivity` to silence exit/offline alerts globally, or change `statePoweredOffSeverity` to `critical` when you want exiting containers to page immediately. Per-container overrides still take precedence. - `dockerDefaults.disk` defines the writable-layer usage threshold (% of the container's upper filesystem compared to its base image). Defaults trigger at 85% and clear at 80%, and can be overridden per container or host when noisy workloads need a different window. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 2886c06..f3f6621 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -13,9 +13,10 @@ docker run -d \ 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! +1. Inspect the bootstrap token generated inside the container: `docker exec -it pulse cat /data/.bootstrap_token` +2. Access `http://your-server:7655` +3. Paste the bootstrap token when prompted, then **complete the mandatory security setup** +4. Save the generated credentials and API token—they are shown only once and the bootstrap file is deleted after success ## First-Time Setup @@ -207,7 +208,7 @@ If a host remains offline, review [Troubleshooting → Docker Agent Shows Hosts ## Network Discovery -**New in v4.5.0+**: Pulse automatically scans common home/office networks when running in Docker! +Pulse automatically scans common home/office networks when running in Docker. ### How It Works 1. Detects Docker environment automatically diff --git a/docs/DOCKER_MONITORING.md b/docs/DOCKER_MONITORING.md index 9b8f0ad..b444400 100644 --- a/docs/DOCKER_MONITORING.md +++ b/docs/DOCKER_MONITORING.md @@ -1,6 +1,6 @@ -# Docker Monitoring Agent +# Docker & Podman 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. The recommended deployment is the bundled, least-privilege systemd service that runs the static `pulse-docker-agent` binary directly on the host. That path lets the installer lock down permissions, manage upgrades automatically, and integrate with the native init system. Containerising the agent is still available for orchestrated environments, but it trades away some of those controls (and still needs the Docker socket) so treat that option as advanced. +Pulse is focused on Proxmox VE and PBS, but many homelabs also run application stacks in container runtimes such as Docker and Podman. The optional Pulse container agent turns runtime health and resource usage into first-class metrics that show up alongside your hypervisor data. The recommended deployment is the bundled, least-privilege systemd service that runs the static `pulse-docker-agent` binary directly on the host. That path lets the installer lock down permissions, manage upgrades automatically, and integrate with the native init system. Containerising the agent is still available for orchestrated environments, but it trades away some of those controls (and still needs the runtime socket) so treat that option as advanced. ## What the agent reports @@ -11,7 +11,7 @@ Every check interval (30s by default) the agent collects: - Restart counters and exit codes - CPU usage, memory consumption and limits - Images, port mappings, network addresses, and start times -- Writable layer size, root filesystem size, block I/O totals, and mount metadata (shown in the Docker table drawer) +- Writable layer size, root filesystem size, block I/O totals, and mount metadata (shown in the Containers table drawer) - Read/write throughput derived from Docker block I/O counters so you can spot noisy workloads at a glance - Health-check failures, restart-loop windows, and recent exit codes (displayed in the UI under each container drawer) @@ -21,8 +21,8 @@ Data is pushed to Pulse over HTTPS using your existing API token – no inbound - Pulse v4.22.0 or newer with an API token enabled (`Settings → Security`) - API token with the `docker:report` scope (add `docker:manage` if you use remote lifecycle commands) -- 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` +- Docker 20.10+ **or** Podman 4.7+ on Linux (the agent talks to the runtime API socket) +- Access to the runtime socket (`/var/run/docker.sock`, `/run/podman/podman.sock`, or a `unix://` URI) - Go 1.24+ if you plan to build the binary from source ## Installation @@ -44,8 +44,9 @@ Copy the binary to your Docker host (e.g. `/usr/local/bin/pulse-docker-agent`) a Use the bundled installation script (ships with Pulse v4.22.0+) to deploy and manage the agent. Replace the token placeholder with an API token generated in **Settings → Security**. Create a dedicated token for each Docker host so you can revoke individual credentials without touching others—sharing one token across many hosts makes incident response much harder. Tokens used here should include the `docker:report` scope so the agent can submit telemetry (add `docker:manage` only if you plan to issue lifecycle commands remotely). ```bash -curl -fsSL http://pulse.example.com/install-docker-agent.sh \ - | sudo bash -s -- --url http://pulse.example.com --token +curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ + sudo bash /tmp/pulse-install-docker-agent.sh --url http://pulse.example.com --token && \ + rm -f /tmp/pulse-install-docker-agent.sh ``` > **Why sudo?** The installer needs to drop binaries under `/usr/local/bin`, create a systemd service, and start it—actions that require root privileges. Piping to `sudo bash …` saves you from retrying if you run the command as an unprivileged user. @@ -59,12 +60,45 @@ Running the one-liner again from another Pulse server (with its own URL/token) w To report to more than one Pulse instance from the same Docker host, repeat the `--target` flag (format: `https://pulse.example.com|`) or export `PULSE_TARGETS` before running the script: ```bash -curl -fsSL http://pulse.example.com/install-docker-agent.sh \ - | sudo bash -s -- \ +curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ + sudo bash /tmp/pulse-install-docker-agent.sh -- \ --target https://pulse.example.com| \ - --target https://pulse-dr.example.com| + --target https://pulse-dr.example.com| && \ + rm -f /tmp/pulse-install-docker-agent.sh ``` +### Quick install for Podman (system service) + +Use the multi-runtime installer when you want the agent to run against Podman as a systemd service. The script takes care of enabling `podman.socket`, creating a dedicated service account, and wiring the correct runtime socket automatically: + +```bash +curl -fSL http://pulse.example.com/install-container-agent.sh -o /tmp/pulse-install-container-agent.sh && \ + sudo bash /tmp/pulse-install-container-agent.sh --runtime podman --url http://pulse.example.com --token && \ + rm -f /tmp/pulse-install-container-agent.sh +``` + +The environment file lives at `/etc/pulse/pulse-docker-agent.env` and the unit is still named `pulse-docker-agent.service` for backwards compatibility. The agent exports `PULSE_RUNTIME=podman` and points both `CONTAINER_HOST` and `DOCKER_HOST` at the Podman socket (`/run/podman/podman.sock` by default). Restart the service after editing the env file with `sudo systemctl restart pulse-docker-agent`. + +> **What's new for Podman?** The agent now sends pod- and compose-aware metadata for Podman hosts. Pulse surfaces pod names, infra-container markers, compose project/service identifiers, auto-update policies, and user namespace hints so you can see how containers relate without leaving the UI. + +### Quick install for Podman (rootless user service) + +Podman’s rootless mode works too. Run the installer as the target user and add the `--rootless` flag — no sudo required: + +```bash +curl -fSL http://pulse.example.com/install-container-agent.sh -o /tmp/pulse-install-container-agent.sh && \ + bash /tmp/pulse-install-container-agent.sh --runtime podman --rootless --url http://pulse.example.com --token && \ + rm -f /tmp/pulse-install-container-agent.sh +``` + +The agent binary is dropped into `~/.local/bin`, configuration lives under `~/.config/pulse`, and a user-level service (`~/.config/systemd/user/pulse-docker-agent.service`) is created. Enable lingering so the agent keeps running after you log out: + +```bash +sudo loginctl enable-linger "$USER" +``` + +If `systemctl --user` is unavailable, the installer will print the exact command you can place in a cron job or another init system. + ## Running the agent The agent needs to know where Pulse lives and which API token to use. @@ -106,7 +140,7 @@ Use the new flags to tune the payload: If a manager cannot reach the Swarm API the agent automatically falls back to node scope so updates keep flowing. -Adjust warning and critical replica gaps (or disable service alerts entirely) under **Alerts → Thresholds → Docker** in the Pulse UI. +Adjust warning and critical replica gaps (or disable service alerts entirely) under **Alerts → Thresholds → Containers** in the Pulse UI. ### Multiple Pulse instances @@ -186,6 +220,9 @@ docker run -d \ | `--token`, `PULSE_TOKEN`| Pulse API token with `docker:report` scope (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` | +| `--runtime`, `PULSE_RUNTIME` | Container runtime to target (`docker`, `podman`, `auto`). | `docker` | +| `--container-socket`, `PULSE_CONTAINER_SOCKET` / `CONTAINER_HOST` | Explicit runtime socket path or `unix://` URI. | Runtime default | +| `--rootless`, `PULSE_RUNTIME_ROOTLESS` | Install/manage the agent as a user service (Podman). | Auto (rootful) | | `--container-state`, `PULSE_CONTAINER_STATES` | Limit reports to specific Docker statuses (`created`, `running`, `restarting`, `removing`, `paused`, `exited`, `dead`). Separate multiple values with commas/semicolons or repeat the flag. | — | | `--swarm-scope`, `PULSE_SWARM_SCOPE` | Swarm data scope: `node`, `cluster`, or `auto` (auto picks cluster on managers, node on workers). | `node` | | `--swarm-services`, `PULSE_SWARM_SERVICES` | Include Swarm service summaries in reports. | `true` | @@ -200,20 +237,20 @@ The agent automatically discovers the Docker socket via the usual environment va ### Disk usage monitoring & alerts -When `--collect-disk` is enabled (the default), Pulse records each container’s writable layer and root filesystem sizes. The Alerts engine treats the proportion of writable data to total filesystem as the disk usage percentage for that container. A fleet-wide threshold lives under **Alerts → Thresholds → Docker Containers** and defaults to 85% trigger / 80% clear; adjust or disable it per host/container when your workload makes heavy use of copy-on-write layers. Containers that stop reporting disk metrics (for example when size queries are disabled) automatically skip the disk alert evaluation. +When `--collect-disk` is enabled (the default), Pulse records each container’s writable layer and root filesystem sizes. The Alerts engine treats the proportion of writable data to total filesystem as the disk usage percentage for that container. A fleet-wide threshold lives under **Alerts → Thresholds → Containers** and defaults to 85% trigger / 80% clear; adjust or disable it per host/container when your workload makes heavy use of copy-on-write layers. Containers that stop reporting disk metrics (for example when size queries are disabled) automatically skip the disk alert evaluation. ### Suppressing ephemeral containers -CI runners and short-lived build containers can generate noisy state alerts when they exit on schedule. In Pulse v4.24.0 and later you can provide a list of prefixes to ignore under **Alerts → Thresholds → Docker → Ignored container prefixes**. Any container whose name *or* ID begins with a configured prefix is skipped for state, health, metric, restart-loop, and OOM alerts. Matching is case-insensitive and the list is saved as `dockerIgnoredContainerPrefixes` inside `alerts.json`. Use one entry per family of ephemeral containers (for example, `runner-` or `gitlab-job-`). +CI runners and short-lived build containers can generate noisy state alerts when they exit on schedule. In Pulse v4.24.0 and later you can provide a list of prefixes to ignore under **Alerts → Thresholds → Containers → Ignored container prefixes**. Any container whose name *or* ID begins with a configured prefix is skipped for state, health, metric, restart-loop, and OOM alerts. Matching is case-insensitive and the list is saved as `dockerIgnoredContainerPrefixes` inside `alerts.json`. Use one entry per family of ephemeral containers (for example, `runner-` or `gitlab-job-`). -Need the alerts but at a different tone? The same Docker tab exposes global controls for the container state detector. Flip **Disable container state alerts** (`stateDisableConnectivity`) to mute powered-off/offline warnings across the fleet, or change **Default severity** (`statePoweredOffSeverity`) to `critical` so unexpected exits page immediately. Individual host/container overrides still win when you need exceptions. +Need the alerts but at a different tone? The same Containers tab exposes global controls for the container state detector. Flip **Disable container state alerts** (`stateDisableConnectivity`) to mute powered-off/offline warnings across the fleet, or change **Default severity** (`statePoweredOffSeverity`) to `critical` so unexpected exits page immediately. Individual host/container overrides still win when you need exceptions. ## 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. +- Check Pulse (`/containers` 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 diff --git a/docs/FAQ.md b/docs/FAQ.md index 83e34c7..a43c166 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -93,9 +93,7 @@ Yes! When you add one cluster node, Pulse automatically discovers and monitors a Reduce `metricsRetentionDays` in settings and restart ### How do I monitor adaptive polling? -**New in v4.25.0:** The adaptive scheduler now exposes staleness scores, circuit breaker state, and per-resource poll metrics so you can trace why work was delayed. - -**New in v4.24.0:** Pulse includes adaptive polling that automatically adjusts polling intervals based on system load. +The adaptive scheduler exposes staleness scores, circuit breaker state, and per-resource poll metrics so you can trace why work was delayed. Adaptive polling automatically adjusts polling intervals based on system load. **Monitor adaptive polling:** - **Dashboard**: Settings → System → Monitoring shows scheduler health status @@ -114,8 +112,7 @@ Reduce `metricsRetentionDays` in settings and restart See [Adaptive Polling Documentation](monitoring/ADAPTIVE_POLLING.md) for complete details. ### What's new about rate limiting in v4.25.0? -**New in v4.25.0:** Adaptive polling metrics and circuit breaker states are now exposed alongside rate-limit headers, making throttling decisions easier to interpret. -Pulse now returns standard rate limit headers with all API responses: +Adaptive polling metrics and circuit breaker states are exposed alongside rate-limit headers, making throttling decisions easier to interpret. Pulse returns standard rate limit headers with all API responses: **Response Headers:** - `X-RateLimit-Limit`: Maximum requests allowed per window (e.g., 500) @@ -219,8 +216,8 @@ Open **Alerts → History** and click an entry. The right-hand panel now shows a 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 host’s 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. +### Why does a container host show as offline in the Containers 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 host’s last heartbeat under **Details**. Still stuck? Walk through [Troubleshooting → Container Agent Shows Hosts Offline](TROUBLESHOOTING.md#container-agent-shows-hosts-offline) for a step-by-step checklist. ## Updates @@ -229,7 +226,7 @@ First, confirm the agent is still running (`systemctl status pulse-docker-agent` - **Manual/systemd**: Run the install script again: `curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash` ### Can I roll back if an update misbehaves? -**New in v4.24.0:** Yes! Pulse now retains previous versions and provides easy rollback. +Pulse retains previous versions and provides easy rollback. **Via UI (Recommended):** 1. Navigate to **Settings → System → Updates** @@ -265,7 +262,7 @@ Check rollback logs: `journalctl -u pulse | grep rollback` - **Docker**: launch with a versioned tag instead of `latest`, e.g. `docker run -d --name pulse -p 7655:7655 rcourtman/pulse:v4.24.0` ### How do I adjust logging without restarting? -**New in v4.24.0:** Pulse supports runtime logging configuration—no restart required! +Pulse supports runtime logging configuration—no restart required. **Via UI:** 1. Navigate to **Settings → System → Logging** diff --git a/docs/HOST_AGENT.md b/docs/HOST_AGENT.md index e73150e..5f95921 100644 --- a/docs/HOST_AGENT.md +++ b/docs/HOST_AGENT.md @@ -3,14 +3,14 @@ The Pulse host agent extends monitoring to standalone servers that do not expose Proxmox or Docker APIs. With it you can surface uptime, OS metadata, CPU load, memory/disk utilisation, and connection health for any Linux, macOS, or Windows -machine alongside the rest of your infrastructure. Beginning with the upcoming -release the installer now handshakes with Pulse in real time so you can confirm -registration directly from the UI and receive host-agent alerts alongside your -existing Docker/Proxmox notifications. +machine alongside the rest of your infrastructure. Starting in v4.26.0 the +installer handshakes with Pulse in real time so you can confirm registration +from the UI and receive host-agent alerts alongside your existing +Docker/Proxmox notifications. ## Prerequisites -- Pulse `main` (or a release that includes `/api/agents/host/report`) +- Pulse v4.26.0 or newer (host agent reporting shipped with `/api/agents/host/report`) - An API token with the `host-agent:report` scope (create under **Settings → Security**) - Outbound HTTP/HTTPS connectivity from the host back to Pulse diff --git a/docs/INSTALL.md b/docs/INSTALL.md index cac0aca..3fc0e68 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -13,6 +13,23 @@ The installer will prompt you for the port (default: 7655). To skip the prompt, FRONTEND_PORT=8080 curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash ``` +### First-Time Authentication Bootstrap + +Pulse protects the initial Quick Security Setup screen with a one-time bootstrap token. After the service starts, read the token from the data directory before opening the UI: + +| Deployment | Token Path | +|------------|------------| +| Standard install / Proxmox LXC | `/etc/pulse/.bootstrap_token` | +| Docker container | `/data/.bootstrap_token` inside the container or the mounted host volume | +| Helm / Kubernetes | The persistent volume mounted at `/data` | + +1. SSH to the host (or `docker exec` into the container). +2. Display the token: `cat /etc/pulse/.bootstrap_token` (adjust the path per the table). +3. When the UI prompts for setup, paste the token into the dialog or send it as the `X-Setup-Token` header for API calls. +4. The token is deleted automatically after setup succeeds; remove the file manually if you abort the wizard and need a new token. + +If you preconfigure `PULSE_AUTH_USER`/`PULSE_AUTH_PASS`, OIDC, or proxy auth, the bootstrap token is ignored because authentication is already in place. + ## Installation Methods ### Proxmox VE Hosts @@ -107,8 +124,8 @@ systemctl status pulse-update.timer # Check status - Creates backup before updating - Automatically rolls back if update fails - Logs all activity to systemd journal -- **New in v4.25.0**: Adaptive monitoring now ships with circuit breakers, staleness tracking, and richer poll metrics while the Helm chart streamlines Kubernetes installs bundled with the binary. -- **New in v4.24.0**: Rollback history is retained in Settings → System → Updates; use the new 'Restore previous version' button if the latest build regresses +- Adaptive monitoring ships with circuit breakers, staleness tracking, and richer poll metrics, and the bundled Helm chart mirrors these defaults for Kubernetes clusters. +- Rollback history is retained in Settings → System → Updates; use the **Restore previous version** button if the latest build regresses. #### View Update Logs ```bash @@ -139,7 +156,7 @@ docker run -d --name pulse -p 7655:7655 -v pulse_data:/data rcourtman/pulse:late ### Rollback to Previous Version -**New in v4.25.0:** Pulse retains previous versions and allows easy rollback if an update causes issues, now backed by detailed scheduler metrics so you can see why a rollback triggered. +Pulse retains previous versions and allows easy rollback if an update causes issues, backed by detailed scheduler metrics so you can see why a rollback triggered. #### Via UI (Recommended) 1. Navigate to **Settings → System → Updates** @@ -188,7 +205,7 @@ curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | b ### Runtime Logging Configuration -**New in v4.25.0:** Adjust logging settings without restarting Pulse; the structured logging subsystem now centralizes format, destinations, and rotation controls. +Adjust logging settings without restarting Pulse; the structured logging subsystem centralizes format, destinations, and rotation controls. #### Via UI Navigate to **Settings → System → Logging** to configure: @@ -210,7 +227,7 @@ docker run -e LOG_LEVEL=debug -e LOG_FORMAT=json rcourtman/pulse:latest ### Adaptive Polling -**New in v4.25.0:** Adaptive polling now publishes staleness scores, circuit breaker states, and poll timings in `/api/monitoring/scheduler/health`, giving operators context when the scheduler slows down. +Adaptive polling publishes staleness scores, circuit breaker states, and poll timings in `/api/monitoring/scheduler/health`, giving operators context when the scheduler slows down. ## Troubleshooting diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md index 95b1839..83e3da2 100644 --- a/docs/KUBERNETES.md +++ b/docs/KUBERNETES.md @@ -2,7 +2,7 @@ Deploy Pulse to Kubernetes with the bundled Helm chart under `deploy/helm/pulse`. The chart provisions the Pulse hub (web UI + API) and can optionally run the Docker monitoring agent alongside it. Stable builds are published automatically to the GitHub Container Registry (GHCR) whenever a Pulse release goes out. -> **New in v4.25.0:** The Helm chart is shipped with the release archives and pairs with the upgraded monitoring engine (staleness tracking, circuit breakers, detailed poll metrics) so Kubernetes clusters benefit from the same adaptive scheduling improvements as bare-metal installs. +> The Helm chart ships with the release archives and pairs with the upgraded monitoring engine (staleness tracking, circuit breakers, detailed poll metrics) so Kubernetes clusters benefit from the same adaptive scheduling improvements as bare-metal installs. ## Prerequisites diff --git a/docs/PORT_CONFIGURATION.md b/docs/PORT_CONFIGURATION.md index 7c95024..745dc4e 100644 --- a/docs/PORT_CONFIGURATION.md +++ b/docs/PORT_CONFIGURATION.md @@ -2,7 +2,7 @@ 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. +> **Development tip:** The hot-reload workflow (`scripts/hot-dev.sh` or `make dev-hot`) loads `.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 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f29a8fb --- /dev/null +++ b/docs/README.md @@ -0,0 +1,59 @@ +# Pulse Documentation Index + +Use this index to navigate the documentation bundled with the repository. Each +section groups related guides so you can jump straight to the material you need. + +--- + +## Getting Started + +- [INSTALL.md](INSTALL.md) – Installation guide covering script, Docker, and Helm paths. +- [FAQ.md](FAQ.md) – Common questions and troubleshooting quick answers. +- [MIGRATION.md](MIGRATION.md) – Export/import process for moving between hosts. +- [DEV-QUICK-START.md](../DEV-QUICK-START.md) – Hot reload workflow for local development. + +## Deployment Guides + +- [DOCKER.md](DOCKER.md) – Container deployment walkthroughs and compose samples. +- [KUBERNETES.md](KUBERNETES.md) – Helm chart usage, ingress, persistence. +- [REVERSE_PROXY.md](REVERSE_PROXY.md) – nginx, Caddy, Apache, Traefik, HAProxy recipes. +- [DOCKER_MONITORING.md](DOCKER_MONITORING.md) – Docker/Podman agent installation. +- [HOST_AGENT.md](HOST_AGENT.md) – Host agent installers for Linux, macOS, Windows. +- [PORT_CONFIGURATION.md](PORT_CONFIGURATION.md) – Changing default ports and listeners. + +## Operations & Monitoring + +- [CONFIGURATION.md](CONFIGURATION.md) – Detailed breakdown of config files and env vars. +- [TEMPERATURE_MONITORING.md](TEMPERATURE_MONITORING.md) – Sensor proxy setup and hardening. +- [VM_DISK_MONITORING.md](VM_DISK_MONITORING.md) – Enabling guest-agent disk telemetry. +- [monitoring/](monitoring/) – Adaptive polling and Prometheus metric references. +- [WEBHOOKS.md](WEBHOOKS.md) – Notification providers and payload templates. +- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) – Deep dive into common operational issues. + +## Security + +- [SECURITY.md](../SECURITY.md) – Canonical security policy (root-level document). +- [docs/security/](security/) – Sensor proxy network and hardening guidance. +- [PROXY_AUTH.md](PROXY_AUTH.md) – Authenticating via Authentik, Authelia, etc. +- [TEMPERATURE_MONITORING_SECURITY.md](TEMPERATURE_MONITORING_SECURITY.md) – Legacy SSH considerations. + +## Reference + +- [API.md](API.md) – REST API overview with examples. +- [api/SCHEDULER_HEALTH.md](api/SCHEDULER_HEALTH.md) – Adaptive scheduler API schema. +- [RELEASE_NOTES.md](RELEASE_NOTES.md) – Latest feature highlights and changes. +- [SCREENSHOTS.md](SCREENSHOTS.md) – UI tour with annotated screenshots. +- [DOCKER_HUB_README.md](DOCKER_HUB_README.md) – Summarised feature list for registries. + +## Development & Contribution + +- [CONTRIBUTING.md](../CONTRIBUTING.md) – Repository-wide contribution guide. +- [CONTRIBUTING-SCRIPTS.md](CONTRIBUTING-SCRIPTS.md) – Expectations for installer contributors. +- [script-library-guide.md](script-library-guide.md) – Working with shared Bash modules. +- [installer-v2-rollout.md](installer-v2-rollout.md) – Process for shipping major installer updates. +- [development/MOCK_MODE.md](development/MOCK_MODE.md) – Using mock data while developing. +- [MIGRATION_SCAFFOLDING.md](../MIGRATION_SCAFFOLDING.md) – Tracking temporary migration code. + +Have an idea for a new guide? Update this index when you add documentation so +discoverability stays high. + diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e0d397d..9cd9a86 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -300,9 +300,9 @@ Or if Pulse is installed: **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 +### Container Agent Shows Hosts Offline -**Symptoms**: `/docker` tab marks hosts as offline or missing container metrics. +**Symptoms**: `/containers` tab marks hosts as offline or missing container metrics. **Checklist:** 1. Run the agent manually with verbose logs: @@ -318,7 +318,7 @@ Or if Pulse is installed: 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 ` and adjust restart policy if needed. +**Restart loops**: The Containers workspace Issues column lists the last exit codes. Investigate recurring non-zero codes in `docker logs ` and adjust restart policy if needed. **Step 3: Check Pulse logs** diff --git a/docs/api/SCHEDULER_HEALTH.md b/docs/api/SCHEDULER_HEALTH.md index 6d95aa0..2b2332f 100644 --- a/docs/api/SCHEDULER_HEALTH.md +++ b/docs/api/SCHEDULER_HEALTH.md @@ -1,6 +1,6 @@ # Scheduler Health API -**New in v4.24.0** +Adaptive scheduler health endpoint Endpoint: `GET /api/monitoring/scheduler/health` @@ -86,7 +86,7 @@ Each element gives a complete view of one instance. | `lastSuccess` | timestamp nullable | RFC 3339 timestamp of most recent successful poll | | `lastError` | object nullable | `{ at, message, category }` where `at` is RFC 3339, `message` describes the error, and `category` is `transient` (network issues, timeouts) or `permanent` (auth failures, invalid config) | | `consecutiveFailures` | integer | Current failure streak length (resets on successful poll) | -| `firstFailureAt` | timestamp nullable | **New in v4.24.0**: RFC 3339 timestamp when the current failure streak began. Useful for calculating failure duration | +| `firstFailureAt` | timestamp nullable | RFC 3339 timestamp when the current failure streak began. Useful for calculating failure duration | **Timing Metadata (v4.24.0+):** - `firstFailureAt`: Tracks when a failure streak started, enabling "failing for X minutes" calculations @@ -98,10 +98,10 @@ Each element gives a complete view of one instance. | Field | Type | Description | |-------|------|-------------| | `state` | string | `closed` (healthy), `open` (failing), `half_open` (testing recovery), or `unknown` (not initialized) | -| `since` | timestamp nullable | **New in v4.24.0**: RFC 3339 timestamp when the current state began. Use to calculate how long a breaker has been open | -| `lastTransition` | timestamp nullable | **New in v4.24.0**: RFC 3339 timestamp of the most recent state change (e.g., closed → open) | -| `retryAt` | timestamp nullable | **New in v4.24.0**: RFC 3339 timestamp of next scheduled retry attempt when breaker is open or half-open | -| `failureCount` | integer | **New in v4.24.0**: Number of failures in the current breaker cycle. Resets when breaker closes | +| `since` | timestamp nullable | RFC 3339 timestamp when the current state began. Use to calculate how long a breaker has been open | +| `lastTransition` | timestamp nullable | RFC 3339 timestamp of the most recent state change (e.g., closed → open) | +| `retryAt` | timestamp nullable | RFC 3339 timestamp of next scheduled retry attempt when breaker is open or half-open | +| `failureCount` | integer | Number of failures in the current breaker cycle. Resets when breaker closes | **Circuit Breaker Timing (v4.24.0+):** - `since`: When did the current state start? (e.g., "breaker has been open for 5 minutes") diff --git a/docs/development/MOCK_MODE.md b/docs/development/MOCK_MODE.md new file mode 100644 index 0000000..0516821 --- /dev/null +++ b/docs/development/MOCK_MODE.md @@ -0,0 +1,106 @@ +# Mock Mode Development Guide + +Pulse ships with a mock data pipeline so you can iterate on UI and backend +changes without touching real infrastructure. This guide collects everything you +need to know about running in mock mode during development. + +--- + +## Why Mock Mode? + +- Exercise dashboards, alert timelines, and charts with predictable sample data. +- Reproduce edge cases (offline nodes, noisy containers, backup failures) by + tweaking configuration values rather than waiting for production incidents. +- Swap between synthetic and live data without rebuilding services. + +--- + +## Starting the Dev Stack + +```bash +# Launch backend + frontend with hot reload +./scripts/hot-dev.sh +``` + +The script exposes: +- Frontend: `http://localhost:7655` (Vite hot module reload) +- Backend API: `http://localhost:7656` + +--- + +## Toggling Mock Data + +The npm helpers and `toggle-mock.sh` wrapper point the backend at different +`.env` files and restart the relevant services automatically. + +```bash +npm run mock:on # Enable mock mode +npm run mock:off # Return to real data +npm run mock:status # Display current state +npm run mock:edit # Open mock.env in $EDITOR +``` + +Equivalent shell invocations: + +```bash +./scripts/toggle-mock.sh on +./scripts/toggle-mock.sh off +./scripts/toggle-mock.sh status +``` + +When switching: +- `mock.env` (or `mock.env.local`) feeds configuration values to the backend. +- `PULSE_DATA_DIR` swaps between `/opt/pulse/tmp/mock-data` (synthetic) and + `/etc/pulse` (real data) so test credentials never mix with production ones. +- The backend process restarts; the frontend stays hot-reloading. + +--- + +## Customising Mock Fixtures + +`mock.env` exposes the knobs most developers care about: + +```bash +PULSE_MOCK_MODE=false # Enable/disable mock mode +PULSE_MOCK_NODES=7 # Number of synthetic nodes +PULSE_MOCK_VMS_PER_NODE=5 # Average VM count per node +PULSE_MOCK_LXCS_PER_NODE=8 # Average container count per node +PULSE_MOCK_RANDOM_METRICS=true # Toggle metric jitter +PULSE_MOCK_STOPPED_PERCENT=20 # Percentage of guests stopped/offline +``` + +Create `mock.env.local` for personal tweaks that should not be committed: + +```bash +cp mock.env mock.env.local +$EDITOR mock.env.local +``` + +The toggle script prioritises `.local` files, falling back to the shared +defaults when none are present. + +--- + +## Troubleshooting + +- **Backend did not restart:** flip mock mode off/on again (`npm run mock:off`, + then `npm run mock:on`) to force a reload. +- **Ports already in use:** confirm nothing else is listening on `7655`/`7656` + (`lsof -i :7655` / `lsof -i :7656`) and kill stray processes. +- **Data feels stale:** delete `/opt/pulse/tmp/mock-data` and toggle mock mode + back on to regenerate fixtures. + +--- + +## Limitations + +- Mock data focuses on happy-path flows; use real Proxmox/PBS environments + before shipping changes that touch API integrations. +- Webhook payloads are synthetically generated and omit provider-specific + quirks—test with real channels for production rollouts. +- Encrypt/decrypt flows still use the local crypto stack; do not treat mock mode + as a sandbox for experimenting with credential formats. + +For more advanced scenarios, inspect `scripts/hot-dev.sh` and the mock seeders +under `internal/mock` for additional entry points. + diff --git a/docs/images/01-dashboard.png b/docs/images/01-dashboard.png index 90cf606..dd2c9f9 100644 Binary files a/docs/images/01-dashboard.png and b/docs/images/01-dashboard.png differ diff --git a/docs/images/02-storage.png b/docs/images/02-storage.png index d0c2cb9..2e40392 100644 Binary files a/docs/images/02-storage.png and b/docs/images/02-storage.png differ diff --git a/docs/images/03-backups.png b/docs/images/03-backups.png index 2374c57..daf6f58 100644 Binary files a/docs/images/03-backups.png and b/docs/images/03-backups.png differ diff --git a/docs/images/04-alerts.png b/docs/images/04-alerts.png index d9a1c8d..c929ab6 100644 Binary files a/docs/images/04-alerts.png and b/docs/images/04-alerts.png differ diff --git a/docs/images/05-alert-history.png b/docs/images/05-alert-history.png index 4078355..8749659 100644 Binary files a/docs/images/05-alert-history.png and b/docs/images/05-alert-history.png differ diff --git a/docs/images/06-settings.png b/docs/images/06-settings.png index 6e43f36..54f00fb 100644 Binary files a/docs/images/06-settings.png and b/docs/images/06-settings.png differ diff --git a/docs/images/08-mobile.png b/docs/images/08-mobile.png index 05c9334..e855038 100644 Binary files a/docs/images/08-mobile.png and b/docs/images/08-mobile.png differ diff --git a/docs/installer-v2-rollout.md b/docs/installer-v2-rollout.md new file mode 100644 index 0000000..e44e350 --- /dev/null +++ b/docs/installer-v2-rollout.md @@ -0,0 +1,97 @@ +# Installer v2 Rollout Playbook + +This playbook captures the agreed rollout process for major installer updates +(`install.sh`, host/agent installers, and supporting bundles). Use it when +cutting a new installer generation or deprecating legacy flows. + +--- + +## 1. Define Objectives + +- Document the functional changes (new flags, platforms, security updates). +- Outline compatibility expectations (supported OS versions, container + runtimes, Proxmox releases). +- Decide which behaviours remain behind feature flags or environment toggles. + +Create a tracking issue with: +- Summary of the change +- Owners for implementation, docs, and QA +- Planned release milestone + +--- + +## 2. Build in Parallel + +While the existing installer continues shipping: + +1. Implement changes under `scripts/install-*-v2.sh` (keep the original script + untouched). +2. Gate sensitive logic behind `PULSE_INSTALLER_V2` or similar flags so you can + exercise new code paths without breaking production users. +3. Add migration scaffolding to handle legacy configs—record details in + `MIGRATION_SCAFFOLDING.md`. + +--- + +## 3. Testing Matrix + +Cover these scenarios before merging: + +| Scenario | Notes | +|----------|-------| +| Fresh install on Debian/Ubuntu | Include root + non-root invocation, interactive + `--force`. | +| Proxmox LXC creation | Validate resource sizing, storage defaults, and rollback on failure. | +| Docker / Compose flow | Ensure bind mounts, auto-hash, and setup wizard paths remain intact. | +| Upgrade in place | Re-run installer on an existing deployment; preserve data and services. | +| Air-gapped mode | Exercise `--offline` or pre-downloaded assets if supported. | +| Uninstall / cleanup | Confirm services and temp files are removed where promised. | + +Automate what you can with `scripts/tests/integration/`. Record any manual +checks in the tracking issue. + +--- + +## 4. Documentation Updates + +- Update end-user docs (`docs/INSTALL.md`, Docker/Kubernetes guides) with new + flags and workflows. +- Refresh quick-start snippets in `README.md`. +- Note behavioural changes in `docs/RELEASE_NOTES.md` under the relevant + version once the rollout ships. +- For script contributors, update `docs/CONTRIBUTING-SCRIPTS.md` and + `docs/script-library-guide.md` with new patterns. + +--- + +## 5. Staged Rollout + +1. Merge v2 into `main` behind a feature flag or version guard. +2. Ask beta testers to opt-in via `--use-installer-v2` (or similar). +3. Monitor GitHub issues / Discord for feedback. +4. Iterate quickly on regressions—keep the old installer intact until v2 is + stable. + +When metrics show low regression risk, switch the default path to v2 but keep +the v1 flag available for at least one stable release. + +--- + +## 6. Sunset Legacy Paths + +- Announce deprecation in release notes at least one version before removal. +- Remove the legacy flag after the deprecation window. +- Delete scaffolding and update `MIGRATION_SCAFFOLDING.md` to reflect the + cleanup. + +--- + +## 7. Post-Rollout Checklist + +- Audit bundled artifacts (`dist/*.sh`) for unexpected diffs. +- Regenerate checksums and publish updated hashes if distributing binaries. +- Close the tracking issue with a summary of lessons learned and follow-up + tasks (e.g., telemetry improvements). + +Keeping this playbook up to date ensures future installer iterations follow the +same predictable rollout process and reduces duplicate tribal knowledge. + diff --git a/docs/monitoring/ADAPTIVE_POLLING.md b/docs/monitoring/ADAPTIVE_POLLING.md index 826f5ca..fe8a786 100644 --- a/docs/monitoring/ADAPTIVE_POLLING.md +++ b/docs/monitoring/ADAPTIVE_POLLING.md @@ -63,8 +63,8 @@ Exposed via Prometheus (`:9091/metrics`): | `pulse_monitor_poll_staleness_seconds` | gauge | `instance_type`, `instance` | Age since last success (0 on success) | | `pulse_monitor_poll_queue_depth` | gauge | — | Size of priority queue | | `pulse_monitor_poll_inflight` | gauge | `instance_type` | Concurrent tasks per type | -| `pulse_monitor_poll_errors_total` | counter | `instance_type`, `instance`, `category` | **New in v4.24.0**: Error counts by category (transient/permanent) | -| `pulse_monitor_poll_last_success_timestamp` | gauge | `instance_type`, `instance` | **New in v4.24.0**: Unix timestamp of last successful poll | +| `pulse_monitor_poll_errors_total` | counter | `instance_type`, `instance`, `category` | Error counts by category (transient/permanent) | +| `pulse_monitor_poll_last_success_timestamp` | gauge | `instance_type`, `instance` | Unix timestamp of last successful poll | **Alerting Recommendations:** - Alert when `pulse_monitor_poll_staleness_seconds` > 120 for critical instances @@ -185,4 +185,3 @@ Returns comprehensive scheduler health data (authentication required). - Task 8: expose scheduler health & dead-letter statistics via API and UI panels. - Task 9: add dedicated unit/integration harness for the scheduler & workers. - diff --git a/docs/script-library-guide.md b/docs/script-library-guide.md new file mode 100644 index 0000000..4874dd1 --- /dev/null +++ b/docs/script-library-guide.md @@ -0,0 +1,144 @@ +# Pulse Script Library Guide + +This guide expands on `scripts/lib/README.md` and explains how the shared Bash +modules fit together when you are building or refactoring installer scripts. + +--- + +## Library Structure + +``` +scripts/ + lib/ + common.sh # Logging, error handling, retry helpers, temp dirs + http.sh # Curl/wget wrappers, GitHub release helpers + systemd.sh # Systemd unit management helpers + README.md # API-level reference +``` + +Key conventions: + +- **Namespaces:** Exported functions are declared as `module::function` (for + example `common::run`, `systemd::create_service`). Avoid referencing private + helpers (`module::__helper`) from other modules. +- **Development vs Bundled mode:** During local development scripts source + modules from `scripts/lib`. Bundled artifacts produced by + `make bundle-scripts` contain the modules inline, so the source guards remain + but resolve to no-ops. +- **Compatibility:** The library targets Bash 5 but must run on Debian 11+ + (Pulse LXC), Ubuntu LTS, and minimal container images. Stick to POSIX shell + built-ins or guarded GNU extensions. + +--- + +## Recommended Script Skeleton + +```bash +#!/usr/bin/env bash +set -euo pipefail + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" && pwd)" +# shellcheck source=../../scripts/lib/common.sh +source "${LIB_DIR}/common.sh" +# shellcheck source=../../scripts/lib/systemd.sh +source "${LIB_DIR}/systemd.sh" + +common::init "$@" +common::require_command curl tar + +main() { + common::log_info "Starting installer..." + common::temp_dir WORKDIR --prefix pulse- + download_payload + install_service +} + +download_payload() { + http::download --url "${PULSE_DOWNLOAD_URL}" --output "${WORKDIR}/pulse.tar.gz" +} + +install_service() { + systemd::create_service /etc/systemd/system/pulse.service <<'UNIT' +[Unit] +Description=Pulse Monitoring +After=network-online.target +UNIT + + systemd::enable_and_start pulse.service +} + +main "$@" +``` + +**Why this layout works** + +- `common::init` centralises logging/traps and stores the original CLI args so + you can re-exec under sudo if required (`common::ensure_root`). +- `common::temp_dir` registers a cleanup handler automatically to keep `/tmp` + tidy. +- `systemd::create_service` respects `--dry-run` flags and prevents partial + writes on failure. + +--- + +## Logging and Dry-Run Practices + +- Respect `PULSE_LOG_LEVEL` and `PULSE_DEBUG`—they are already wired into + `common::log_*`. +- Wrap mutating commands in `common::run` or `common::run_capture` to inherit + retry/backoff logic and `--dry-run` behaviour. +- Provide meaningful `--label` values on long-running steps to improve CI log + readability. + +Example: + +```bash +common::run --label "Extract Pulse binary" \ + -- tar -xzf "${ARCHIVE}" -C "${TARGET_DIR}" --strip-components=1 +``` + +When invoked with `--dry-run`, the command prints the operation instead of +executing and exits successfully—keep this in mind when writing tests. + +--- + +## Testing Strategy + +- **Smoke tests:** `scripts/tests/run.sh` lints scripts, validates manifests, and + exercises bundle generation. +- **Integration tests:** Place scenario-specific scripts under + `scripts/tests/integration/`. They should run quickly (<30s) and clean up + after themselves. +- **Manual verification:** For destructive operations (e.g., provisioning an LXC + container), run the script with `--dry-run` to confirm the steps before + executing against real infrastructure. + +When adding new library helpers, accompany them with unit coverage using +`bats` (found under `testing-tools/bats`) or an integration script that covers +the happy path and a failure case. + +--- + +## Bundling Checklist + +1. Update `scripts/bundle.manifest` with any newly created scripts. +2. Run `make bundle-scripts` (or `./scripts/bundle.sh`) to regenerate `dist/*`. +3. Inspect the diff to ensure only intentional changes appear. +4. Re-run `scripts/tests/run.sh` to catch lint and shellcheck regressions. + +Bundled files embed provenance metadata (timestamp + manifest path). Do not edit +bundled artifacts by hand—always rebuild from sources. + +--- + +## When to Extend the Library + +- You need to reuse logic across two or more scripts. +- A helper hides platform-specific differences (e.g., `systemctl` vs `service` + on legacy systems). +- The code is complex enough that centralised unit tests provide value. + +Document new functions in `scripts/lib/README.md` and update this guide if usage +patterns change. Keeping these references in sync helps future contributors +avoid copy/paste or undocumented conventions. + diff --git a/frontend-modern/alert_settings_tabs.png b/frontend-modern/alert_settings_tabs.png deleted file mode 100644 index c4a1b7d..0000000 Binary files a/frontend-modern/alert_settings_tabs.png and /dev/null differ diff --git a/frontend-modern/package-lock.json b/frontend-modern/package-lock.json index 6d2dbca..536434d 100644 Binary files a/frontend-modern/package-lock.json and b/frontend-modern/package-lock.json differ diff --git a/frontend-modern/package.json b/frontend-modern/package.json index 4ce5856..666c8a9 100644 --- a/frontend-modern/package.json +++ b/frontend-modern/package.json @@ -28,11 +28,11 @@ "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" + "solid-js": "^1.8.0" }, "devDependencies": { + "@solidjs/testing-library": "^0.8.5", + "@testing-library/jest-dom": "^6.5.0", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", @@ -40,12 +40,10 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-solid": "^0.14.0", - "@solidjs/testing-library": "^0.8.5", - "@testing-library/jest-dom": "^6.5.0", "jsdom": "^24.1.0", "postcss": "^8.4.0", "prettier": "^3.3.0", - "tailwindcss": "^3.4.0", + "tailwindcss": "^3.4.18", "typescript": "^5.3.0", "vite": "^6.4.1", "vite-plugin-solid": "^2.8.0", diff --git a/frontend-modern/pnpm-lock.yaml b/frontend-modern/pnpm-lock.yaml deleted file mode 100644 index 847fae7..0000000 Binary files a/frontend-modern/pnpm-lock.yaml and /dev/null differ diff --git a/frontend-modern/public/download/pulse-host-agent-windows-amd64 b/frontend-modern/public/download/pulse-host-agent-windows-amd64 deleted file mode 100755 index f95d628..0000000 Binary files a/frontend-modern/public/download/pulse-host-agent-windows-amd64 and /dev/null differ diff --git a/frontend-modern/scripts/build-release.sh b/frontend-modern/scripts/build-release.sh deleted file mode 100755 index 3d3481a..0000000 --- a/frontend-modern/scripts/build-release.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index a9329a9..459d8aa 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -17,12 +17,12 @@ import type { JSX } from 'solid-js'; import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; import { getGlobalWebSocketStore } from './stores/websocket-global'; import { ToastContainer } from './components/Toast/Toast'; -import NotificationContainer from './components/NotificationContainer'; import { ErrorBoundary } from './components/ErrorBoundary'; import { SecurityWarning } from './components/SecurityWarning'; import { Login } from './components/Login'; import { logger } from './utils/logger'; -import { POLLING_INTERVALS, STORAGE_KEYS } from './constants'; +import { POLLING_INTERVALS } from './constants'; +import { STORAGE_KEYS } from '@/utils/localStorage'; import { UpdatesAPI } from './api/updates'; import type { VersionInfo } from './api/updates'; import { apiFetch } from './utils/apiClient'; @@ -30,15 +30,14 @@ import { SettingsAPI } from './api/settings'; import { eventBus } from './stores/events'; import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; -import { LegacySSHBanner } from './components/LegacySSHBanner'; import { DemoBanner } from './components/DemoBanner'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State } from '@/types/api'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; -import { DockerIcon } from '@/components/icons/DockerIcon'; -import { HostsIcon } from '@/components/icons/HostsIcon'; -import { AlertsIcon } from '@/components/icons/AlertsIcon'; -import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon'; +import BoxesIcon from 'lucide-solid/icons/boxes'; +import MonitorIcon from 'lucide-solid/icons/monitor'; +import BellIcon from 'lucide-solid/icons/bell'; +import SettingsIcon from 'lucide-solid/icons/settings'; import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; @@ -138,7 +137,7 @@ function App() { loaders.forEach((load) => { void load().catch((error) => { - console.warn('[App] Failed to preload route module', error); + logger.warn('Preloading route module failed', error); }); }); }; @@ -264,7 +263,7 @@ function App() { updateStore.checkForUpdates(); }) .catch((error) => { - console.error('Failed to load version:', error); + logger.error('Failed to load version', error); }); }); @@ -362,13 +361,13 @@ function App() { // Check auth on mount onMount(async () => { - console.log('[App] Starting auth check...'); + logger.debug('[App] Starting auth check...'); // Check if we just logged out - if so, always show login page const justLoggedOut = localStorage.getItem('just_logged_out'); if (justLoggedOut) { localStorage.removeItem('just_logged_out'); - console.log('[App] Just logged out, showing login page'); + logger.debug('[App] User logged out, showing login page'); setHasAuth(true); // Force showing login instead of setup setNeedsAuth(true); setIsLoading(false); @@ -380,14 +379,14 @@ function App() { const securityRes = await apiFetch('/api/security/status'); if (securityRes.status === 401) { - console.warn( + logger.warn( '[App] Security status request returned 401. Clearing stored credentials and showing login.', ); try { const { clearAuth } = await import('./utils/apiClient'); clearAuth(); } catch (clearError) { - console.warn('[App] Failed to clear stored auth after 401:', clearError); + logger.warn('[App] Failed to clear stored auth after 401', clearError); } setHasAuth(false); setNeedsAuth(true); @@ -399,13 +398,13 @@ function App() { } const securityData = await securityRes.json(); - console.log('[App] Security status:', securityData); + logger.debug('[App] Security status fetched', securityData); // Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning if (securityData.deprecatedDisableAuth === true) { - console.warn( - '[App] Legacy DISABLE_AUTH flag detected; authentication remains enabled. Remove the flag and restart Pulse to silence this warning.', - ); + logger.warn( + '[App] Legacy DISABLE_AUTH flag detected; authentication remains enabled. Remove the flag and restart Pulse to silence this warning.', + ); } const authConfigured = securityData.hasAuthentication || false; @@ -413,7 +412,7 @@ function App() { // Check for proxy auth if (securityData.hasProxyAuth && securityData.proxyAuthUsername) { - console.log('[App] Proxy auth detected, user:', securityData.proxyAuthUsername); + logger.info('[App] Proxy auth detected', { user: securityData.proxyAuthUsername }); setProxyAuthInfo({ username: securityData.proxyAuthUsername, logoutURL: securityData.proxyAuthLogoutURL, @@ -439,7 +438,7 @@ function App() { } setHasLoadedServerTheme(true); } catch (error) { - console.error('Failed to load theme from server:', error); + logger.error('Failed to load theme from server', error); } } @@ -450,7 +449,7 @@ function App() { // Check for updates after loading version info (non-blocking) updateStore.checkForUpdates(); }) - .catch((error) => console.error('Failed to load version:', error)); + .catch((error) => logger.error('Failed to load version', error)); setIsLoading(false); return; @@ -458,7 +457,7 @@ function App() { // Check for OIDC session if (securityData.oidcEnabled && securityData.oidcUsername) { - console.log('[App] OIDC session detected, user:', securityData.oidcUsername); + logger.info('[App] OIDC session detected', { user: securityData.oidcUsername }); setHasAuth(true); // OIDC is enabled, so auth is configured setProxyAuthInfo({ username: securityData.oidcUsername, @@ -485,7 +484,7 @@ function App() { } setHasLoadedServerTheme(true); } catch (error) { - console.error('Failed to load theme from server:', error); + logger.error('Failed to load theme from server', error); } } @@ -496,7 +495,7 @@ function App() { // Check for updates after loading version info (non-blocking) updateStore.checkForUpdates(); }) - .catch((error) => console.error('Failed to load version:', error)); + .catch((error) => logger.error('Failed to load version', error)); setIsLoading(false); return; @@ -504,7 +503,7 @@ function App() { // If no auth is configured, show FirstRunSetup if (!authConfigured) { - console.log('[App] No auth configured, showing Login/FirstRunSetup'); + logger.info('[App] No auth configured, showing Login/FirstRunSetup'); setNeedsAuth(true); // This will show the Login component which shows FirstRunSetup setIsLoading(false); return; @@ -542,7 +541,7 @@ function App() { } setHasLoadedServerTheme(true); } catch (error) { - console.error('Failed to load theme from server:', error); + logger.error('Failed to load theme from server', error); } } else { // We have a local preference, just mark that we've checked the server @@ -550,12 +549,12 @@ function App() { } } } catch (error) { - console.error('Auth check error:', error); + logger.error('Auth check error', error); try { const { clearAuth } = await import('./utils/apiClient'); clearAuth(); } catch (clearError) { - console.warn('[App] Failed to clear stored auth after auth check error:', clearError); + logger.warn('[App] Failed to clear stored auth after auth check error', clearError); } setHasAuth(false); setNeedsAuth(true); @@ -587,13 +586,13 @@ function App() { }); if (!response.ok) { - console.error('Logout failed:', response.status); + logger.error('Logout failed', { status: response.status }); } // Clear auth from apiClient clearAuth(); } catch (error) { - console.error('Logout error:', error); + logger.error('Logout error', error); } // Clear all local storage EXCEPT theme preference and logout flag @@ -645,7 +644,6 @@ function App() { -
- @@ -766,12 +763,11 @@ function AppLayout(props: { }) { const navigate = useNavigate(); const location = useLocation(); - const PLATFORM_SEEN_STORAGE_KEY = 'pulse-platforms-seen'; const readSeenPlatforms = (): Record => { if (typeof window === 'undefined') return {}; try { - const stored = window.localStorage.getItem(PLATFORM_SEEN_STORAGE_KEY); + const stored = window.localStorage.getItem(STORAGE_KEYS.PLATFORMS_SEEN); if (stored) { const parsed = JSON.parse(stored) as Record; if (parsed && typeof parsed === 'object') { @@ -779,7 +775,7 @@ function AppLayout(props: { } } } catch (error) { - console.warn('Failed to parse stored platform visibility preferences', error); + logger.warn('Failed to parse stored platform visibility preferences', error); } return {}; }; @@ -789,9 +785,9 @@ function AppLayout(props: { const persistSeenPlatforms = (map: Record) => { if (typeof window === 'undefined') return; try { - window.localStorage.setItem(PLATFORM_SEEN_STORAGE_KEY, JSON.stringify(map)); + window.localStorage.setItem(STORAGE_KEYS.PLATFORMS_SEEN, JSON.stringify(map)); } catch (error) { - console.warn('Failed to persist platform visibility preferences', error); + logger.warn('Failed to persist platform visibility preferences', error); } }; @@ -867,7 +863,7 @@ function AppLayout(props: { enabled: hasDockerHosts() || !!seenPlatforms()['docker'], live: hasDockerHosts(), icon: ( - + ), }, { @@ -879,7 +875,7 @@ function AppLayout(props: { enabled: hasHosts() || !!seenPlatforms()['hosts'], live: hasHosts(), icon: ( - + ), }, ]; @@ -910,7 +906,7 @@ function AppLayout(props: { badge: null as 'update' | null, count: activeAlertCount, breakdown, - icon: , + icon: , }, { id: 'settings' as const, @@ -920,7 +916,7 @@ function AppLayout(props: { badge: updateStore.isUpdateVisible() ? ('update' as const) : null, count: undefined, breakdown: undefined, - icon: , + icon: , }, ]; }); diff --git a/frontend-modern/src/SimpleApp.tsx b/frontend-modern/src/SimpleApp.tsx deleted file mode 100644 index 4f07372..0000000 --- a/frontend-modern/src/SimpleApp.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { createSignal, onMount } from 'solid-js'; - -export default function SimpleApp() { - const [status, setStatus] = createSignal('Initializing...'); - const [data, setData] = createSignal(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 ( -
-

Pulse System Test

-
-

- Status: {status()} -

-

- WebSocket: {wsStatus()} -

-
-

API Data:

-
{JSON.stringify(data(), null, 2)}
-
- ); -} diff --git a/frontend-modern/src/Test.tsx b/frontend-modern/src/Test.tsx deleted file mode 100644 index 93fac58..0000000 --- a/frontend-modern/src/Test.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function Test() { - return ( -
-

TEST - APP IS WORKING!

-

If you see this, the basic app infrastructure works.

-
- ); -} diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index 6df3c9a..0e92d39 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -1,8 +1,6 @@ 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'; @@ -31,8 +29,6 @@ export class AlertsAPI { 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', @@ -64,12 +60,6 @@ export class AlertsAPI { }); } - 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', @@ -86,12 +76,4 @@ export class AlertsAPI { }); } - 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 }), - }); - } } diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 1f4dd12..87d9a7b 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -1,47 +1,23 @@ -import type { SettingsResponse, SettingsUpdateRequest } from '@/types/settings'; import type { SystemConfig } from '@/types/config'; import { apiFetchJSON } from '@/utils/apiClient'; -// Response types -export interface ApiResponse { - success?: boolean; - status?: string; - message?: string; - data?: T; +export interface SystemSettingsResponse extends SystemConfig { + envOverrides?: Record; } export class SettingsAPI { private static baseUrl = '/api'; - static async getSettings(): Promise { - return apiFetchJSON(`${this.baseUrl}/settings`) as Promise; - } - - // Full settings update (legacy - avoid using) - static async updateSettings(settings: SettingsUpdateRequest): Promise { - return apiFetchJSON(`${this.baseUrl}/settings/update`, { - method: 'POST', - body: JSON.stringify(settings), - }) as Promise; - } - // System settings update (preferred) - uses SystemConfig type from config.ts - static async updateSystemSettings(settings: Partial): Promise { - return apiFetchJSON(`${this.baseUrl}/system/settings/update`, { + static async updateSystemSettings(settings: Partial): Promise { + await apiFetchJSON(`${this.baseUrl}/system/settings/update`, { method: 'POST', body: JSON.stringify(settings), - }) as Promise; + }); } // Get system settings - returns SystemConfig - static async getSystemSettings(): Promise { - return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise; - } - - static async validateSettings(settings: SettingsUpdateRequest): Promise { - return apiFetchJSON(`${this.baseUrl}/settings/validate`, { - method: 'POST', - body: JSON.stringify(settings), - }) as Promise; + static async getSystemSettings(): Promise { + return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise; } } diff --git a/frontend-modern/src/api/system.ts b/frontend-modern/src/api/system.ts deleted file mode 100644 index a5205b9..0000000 --- a/frontend-modern/src/api/system.ts +++ /dev/null @@ -1,27 +0,0 @@ -// 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; - backupPollingInterval?: number; - backupPollingEnabled?: boolean; - // apiToken removed - now handled via security API -} - -export class SystemAPI { - // System Settings - static async getSystemSettings(): Promise { - return apiFetchJSON('/api/system/settings'); - } - - static async updateSystemSettings(settings: Partial): Promise { - await apiFetchJSON('/api/system/settings/update', { - method: 'POST', - body: JSON.stringify(settings), - }); - } -} diff --git a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx index 7e4c004..6a04432 100644 --- a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx +++ b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx @@ -1,5 +1,6 @@ import { createSignal, createEffect, Show, For } from 'solid-js'; import { NotificationsAPI } from '@/api/notifications'; +import { logger } from '@/utils/logger'; import { formField, labelClass, @@ -53,7 +54,7 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) { const data = await NotificationsAPI.getEmailProviders(); setProviders(data); } catch (err) { - console.error('Failed to load email providers:', err); + logger.error('Failed to load email providers', err); } }); diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index ea5064f..9673eb6 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -5,6 +5,7 @@ import type { Alert } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider'; +import { logger } from '@/utils/logger'; const COLUMN_TOOLTIP_LOOKUP: Record = { 'cpu %': 'Percent CPU utilization allowed before an alert fires.', @@ -173,7 +174,7 @@ export function ResourceTable(props: ResourceTableProps) { // Track changes to global defaults and factory defaults for debugging createEffect(() => { - console.log('[ResourceTable] createEffect triggered - props changed:', { + logger.debug('[ResourceTable] props changed', { title: props.title, globalDefaults: props.globalDefaults, factoryDefaults: props.factoryDefaults, @@ -183,13 +184,13 @@ export function ResourceTable(props: ResourceTableProps) { // Check if global defaults have been customized from factory defaults const hasCustomGlobalDefaults = () => { - console.log('[ResourceTable] hasCustomGlobalDefaults check:', { + logger.debug('[ResourceTable] hasCustomGlobalDefaults check', { globalDefaults: props.globalDefaults, factoryDefaults: props.factoryDefaults, title: props.title, }); if (!props.globalDefaults || !props.factoryDefaults) { - console.log('[ResourceTable] Missing props, returning false'); + logger.debug('[ResourceTable] Missing props, returning false'); return false; } const result = Object.keys(props.factoryDefaults).some((key) => { @@ -197,13 +198,15 @@ export function ResourceTable(props: ResourceTableProps) { const factory = props.factoryDefaults?.[key]; const differs = current !== undefined && current !== factory; if (differs) { - console.log( - `[ResourceTable] Difference found: ${key} current=${current} factory=${factory}`, - ); + logger.debug('[ResourceTable] Difference found', { + key, + current, + factory, + }); } return differs; }); - console.log(`[ResourceTable] hasCustomGlobalDefaults result: ${result}`); + logger.debug('[ResourceTable] hasCustomGlobalDefaults result', { result }); return result; }; diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index 545b9c6..4f273a6 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -29,6 +29,7 @@ import type { } from '@/types/alerts'; import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import { logger } from '@/utils/logger'; type OverrideType = | 'guest' | 'node' @@ -336,7 +337,8 @@ const [editingThresholds, setEditingThresholds] = createSignal< // Determine active tab from URL const getActiveTabFromRoute = (): 'proxmox' | 'pmg' | 'hosts' | 'docker' => { const path = location.pathname; - if (path.includes('/thresholds/docker')) return 'docker'; + if (path.includes('/thresholds/containers')) return 'docker'; + if (path.includes('/thresholds/docker')) return 'docker'; // Legacy support if (path.includes('/thresholds/hosts')) return 'hosts'; if (path.includes('/thresholds/mail-gateway')) return 'pmg'; return 'proxmox'; // default @@ -357,12 +359,21 @@ const [editingThresholds, setEditingThresholds] = createSignal< } }); + createEffect(() => { + if (location.pathname.startsWith('/alerts/thresholds/docker')) { + navigate( + location.pathname.replace('/alerts/thresholds/docker', '/alerts/thresholds/containers'), + { replace: true, scroll: false }, + ); + } + }); + const handleTabClick = (tab: 'proxmox' | 'pmg' | 'hosts' | 'docker') => { const tabRoutes = { proxmox: '/alerts/thresholds/proxmox', pmg: '/alerts/thresholds/mail-gateway', hosts: '/alerts/thresholds/hosts', - docker: '/alerts/thresholds/docker', + docker: '/alerts/thresholds/containers', }; navigate(tabRoutes[tab]); }; @@ -731,7 +742,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< displayName: friendlyName, rawName: originalName, type: 'dockerHost' as const, - resourceType: 'Docker Host', + resourceType: 'Container Host', node: host.hostname, instance: host.displayName, status, @@ -754,7 +765,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< displayName: friendlyName, rawName: originalName, type: 'dockerHost', - resourceType: 'Docker Host', + resourceType: 'Container Host', node: override.node || '', instance: override.instance || '', status: 'unknown', @@ -844,7 +855,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< id: resourceId, name: containerName, type: 'dockerContainer', - resourceType: 'Docker Container', + resourceType: 'Container', node: groupKey, instance: host.hostname, status, @@ -871,7 +882,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< .filter((override) => override.type === 'dockerContainer' && !seen.has(override.id)) .forEach((override) => { const fallbackName = override.name || override.id.split('/').pop() || override.id; - const group = 'Unassigned Docker Containers'; + const group = 'Unassigned Containers'; if (!groups[group]) { groups[group] = []; } @@ -879,7 +890,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< id: override.id, name: fallbackName, type: 'dockerContainer', - resourceType: 'Docker Container', + resourceType: 'Container', status: 'unknown', hasOverride: true, disabled: override.disabled || false, @@ -934,8 +945,8 @@ const [editingThresholds, setEditingThresholds] = createSignal< }); }); - meta['Unassigned Docker Containers'] = { - displayName: 'Unassigned Docker Containers', + meta['Unassigned Containers'] = { + displayName: 'Unassigned Containers', status: 'unknown', }; @@ -1448,7 +1459,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< }, { key: 'dockerHosts' as const, - label: 'Docker Hosts', + label: 'Container Hosts', total: props.dockerHosts?.length ?? 0, overrides: countOverrides(dockerHostsWithOverrides()), tab: 'docker' as const, @@ -1497,7 +1508,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< }, { key: 'dockerContainers' as const, - label: 'Docker Containers', + label: 'Containers', total: totalDockerContainers() ?? 0, overrides: countOverrides(dockerContainersFlat()), tab: 'docker' as const, @@ -1514,7 +1525,7 @@ const [editingThresholds, setEditingThresholds] = createSignal< const filtered = items.filter((item) => item.total > 0 || item.overrides > 0); return filtered.filter((item) => item.tab === activeTab()); } catch (err) { - console.error('Error in summaryItems memo:', err); + logger.error('Error in summaryItems memo:', err); return []; } }); @@ -2245,7 +2256,7 @@ const cancelEdit = () => { : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' }`} > - Docker + Containers @@ -2270,6 +2281,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={props.nodeDefaults} @@ -2309,6 +2322,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }} @@ -2382,6 +2397,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={props.guestDefaults} @@ -2444,6 +2461,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={backupDefaultsRecord()} @@ -2516,6 +2535,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={snapshotDefaultsRecord()} @@ -2591,6 +2612,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={{ usage: props.storageDefault() }} @@ -2664,6 +2687,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={pmgGlobalDefaults()} @@ -2699,6 +2724,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={props.hostDefaults} @@ -2725,7 +2752,7 @@ const cancelEdit = () => { Ignored container prefixes

- Containers whose name or ID starts with any prefix below are skipped for Docker + Containers whose name or ID starts with any prefix below are skipped for container alerts. Enter one prefix per line; matching is case-insensitive.

@@ -2836,11 +2863,11 @@ const cancelEdit = () => {
{ editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDisableFlag={props.disableAllDockerHosts} @@ -2868,7 +2897,7 @@ const cancelEdit = () => {
{ 'Memory Critical %', ]} activeAlerts={props.activeAlerts} - emptyMessage="No Docker containers match the current filters." + emptyMessage="No containers match the current filters." onEdit={startEditing} onSaveEdit={saveEdit} onCancelEdit={cancelEdit} @@ -2891,6 +2920,8 @@ const cancelEdit = () => { editingId={editingId} editingThresholds={editingThresholds} setEditingThresholds={setEditingThresholds} + editingNote={editingNote} + setEditingNote={setEditingNote} formatMetricValue={formatMetricValue} hasActiveAlert={hasActiveAlert} globalDefaults={{ diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index 9886aeb..a4a353a 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -1,5 +1,6 @@ import { createSignal, createEffect, Show, For, Index } from 'solid-js'; import { NotificationsAPI, Webhook } from '@/api/notifications'; +import { logger } from '@/utils/logger'; import { formField, labelClass, @@ -164,7 +165,7 @@ export function WebhookConfig(props: WebhookConfigProps) { const data = await NotificationsAPI.getWebhookTemplates(); setTemplates(data); } catch (err) { - console.error('Failed to load webhook templates:', err); + logger.error('Failed to load webhook templates:', err); } }); diff --git a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx index 75fea38..35a852a 100644 --- a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx @@ -6,7 +6,7 @@ import { ThresholdsTable, normalizeDockerIgnoredInput } from '../ThresholdsTable import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import type { Host } from '@/types/api'; -let mockPathname = '/alerts/thresholds/docker'; +let mockPathname = '/alerts/thresholds/containers'; vi.mock('@solidjs/router', () => ({ useNavigate: () => vi.fn(), @@ -26,7 +26,7 @@ afterEach(() => { }); beforeEach(() => { - mockPathname = '/alerts/thresholds/docker'; + mockPathname = '/alerts/thresholds/containers'; }); const DEFAULT_PMG_THRESHOLDS: PMGThresholdDefaults = { diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 1d702da..e9dd169 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -13,6 +13,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import type { BackupType, GuestType, UnifiedBackup } from '@/types/backups'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; +import { logger } from '@/utils/logger'; type BackupSortKey = keyof Pick< UnifiedBackup, @@ -42,10 +43,6 @@ const BACKUP_SORT_KEY_VALUES: readonly BackupSortKey[] = [ type FilterableGuestType = 'VM' | 'LXC' | 'Host'; -// Types for PBS backups - temporarily disabled to avoid unused warnings -// type PBSBackup = any; -// type PBSSnapshot = any; - interface DateGroup { label: string; items: UnifiedBackup[]; @@ -243,7 +240,7 @@ const UnifiedBackups: Component = () => { seenBackups.add(backupKey); if (debugMode) { - console.log( + logger.debug( `PBS backup: vmid=${backup.vmid}, time=${backupTimeSeconds}, key=${backupKey}, verified=${backup.verified}`, ); } @@ -273,7 +270,7 @@ const UnifiedBackups: Component = () => { const isVmidZero = vmidAsNumber === 0; if (debugMode && isVmidZero) { - console.log('[PMG Debug] PBS backup with VMID=0:', { + logger.debug('[PMG Debug] PBS backup with VMID=0:', { vmid: backup.vmid, vmidType: typeof backup.vmid, backupType: backup.backupType, @@ -363,7 +360,7 @@ const UnifiedBackups: Component = () => { // Skip if we've already seen this backup (PBS or local duplicate) if (seenBackups.has(backupKey)) { if (debugMode) { - console.log( + logger.debug( `PVE storage backup duplicate skipped: vmid=${backup.vmid}, ctime=${backup.ctime}, key=${backupKey}, storage=${backup.storage}`, ); } @@ -381,7 +378,7 @@ const UnifiedBackups: Component = () => { const isVmidZero = vmidAsNumber === 0; if (debugMode && isVmidZero) { - console.log('[PMG Debug] Storage backup with VMID=0:', { + logger.debug('[PMG Debug] Storage backup with VMID=0:', { vmid: backup.vmid, vmidType: typeof backup.vmid, type: backup.type, @@ -426,86 +423,6 @@ const UnifiedBackups: Component = () => { }); }); - // Normalize PBS backups - // NOTE: Legacy code - PBS backups are now handled differently in the Go backend - // The 'backups' field doesn't exist on PBSInstance anymore, and 'snapshots' field - // doesn't exist on PBSDatastore. This code is kept for reference but commented out. - - /* - state.pbs?.forEach((pbsInstance) => { - // Check if backups are at the instance level - if (pbsInstance.backups && Array.isArray(pbsInstance.backups)) { - pbsInstance.backups.forEach((backup: PBSBackup) => { - // Determine display type - VMID 0 indicates host backup (e.g., PMG) - let displayType: GuestType; - if (backup.vmid === 0) { - displayType = 'Host'; - } else if (backup.type === 'vm' || backup.type === 'qemu') { - displayType = 'VM'; - } else { - displayType = 'LXC'; - } - - unified.push({ - backupType: 'remote', - vmid: backup.vmid || 0, - name: backup.guestName || '', - type: displayType, - node: pbsInstance.name || 'PBS', - instance: pbsInstance.id || 'PBS', - backupTime: backup.ctime || backup.backupTime || 0, - backupName: `${backup.vmid}/${new Date((backup.ctime || backup.backupTime || 0) * 1000).toISOString().split('T')[0]}`, - description: backup.notes || backup.comment || '', - status: backup.verified ? 'verified' : 'unverified', - size: backup.size || null, - storage: null, - datastore: backup.datastore || null, - namespace: backup.namespace || 'root', - verified: backup.verified || false, - protected: backup.protected || false - }); - }); - } - - // Also check datastores for snapshots (original JS structure) - if (pbsInstance.datastores && Array.isArray(pbsInstance.datastores)) { - pbsInstance.datastores?.forEach((datastore) => { - if (datastore.snapshots && Array.isArray(datastore.snapshots)) { - datastore.snapshots.forEach((backup: PBSSnapshot) => { - let totalSize = 0; - if (backup.files && Array.isArray(backup.files)) { - totalSize = backup.files.reduce((sum: number, file) => sum + (file.size || 0), 0); - } - - unified.push({ - backupType: 'remote', - vmid: backup['backup-id'] || 0, - name: backup.comment || '', - type: backup['backup-type'] === 'vm' || backup['backup-type'] === 'qemu' - ? 'VM' - : backup['backup-type'] === 'host' - ? 'Host' - : 'LXC', - node: pbsInstance.name || 'PBS', - instance: pbsInstance.id || 'PBS', - backupTime: backup['backup-time'] || 0, - backupName: `${backup['backup-id']}/${new Date((backup['backup-time'] || 0) * 1000).toISOString().split('T')[0]}`, - description: backup.comment || '', - status: backup.verified ? 'verified' : 'unverified', - size: totalSize || null, - storage: null, - datastore: datastore.name || null, - namespace: backup.namespace || 'root', - verified: backup.verified || false, - protected: backup.protected || false - }); - }); - } - }); - } - }); - */ - return unified; }); diff --git a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx b/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx deleted file mode 100644 index 2921e80..0000000 --- a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx +++ /dev/null @@ -1,237 +0,0 @@ -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'; -import { useAlertsActivation } from '@/stores/alertsActivation'; - -interface CompactNodeCardProps { - node: Node; - variant: 'compact' | 'ultra-compact'; - onClick?: () => void; - isSelected?: boolean; -} - -const CompactNodeCard: Component = (props) => { - const { activeAlerts } = useWebSocket(); - const alertsActivation = useAlertsActivation(); - const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); - - 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 = createMemo(() => - getAlertStyles(props.node.id || props.node.name, activeAlerts, alertsEnabled()), - ); - const nodeAlerts = createMemo(() => - getResourceAlerts(props.node.id || props.node.name, activeAlerts, alertsEnabled()), - ); - 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' }) => ( -
-
= 90 ? 'bg-red-500' : props.value >= 75 ? 'bg-yellow-500' : 'bg-green-500' - }`} - style={{ width: `${props.value}%` }} - /> -
- ); - - if (props.variant === 'ultra-compact') { - // Single line format for 10+ nodes - return ( - - {/* Status dot */} - - - {/* Node name */} - - {displayName()} - - - {/* Cluster/Standalone indicator */} - - - {props.node.isClusterMember ? props.node.clusterName?.slice(0, 3).toUpperCase() : 'SA'} - - - - {/* Alert indicator */} - - - - - {/* Metrics */} -
- - C:{cpuPercent().toString().padStart(3)}% - - - M:{memPercent().toString().padStart(3)}% - - - D:{diskPercent().toString().padStart(3)}% - -
- - {/* Uptime */} - - ↑{formatUptime(props.node.uptime)} - -
- ); - } - - // Compact bar format for 5-9 nodes - return ( - -
-
- - - {displayName()} - - - ({props.node.name}) - - {/* Cluster/Standalone indicator */} - - - {props.node.isClusterMember ? props.node.clusterName : 'Standalone'} - - - - - -
- - {formatUptime(props.node.uptime)} - -
- - {/* Metric bars */} -
-
- CPU - - {cpuPercent()}% -
-
- Mem - - {memPercent()}% -
-
- Disk - - {diskPercent()}% -
-
-
- ); -}; - -export default CompactNodeCard; diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index e4da198..c416b13 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -18,9 +18,9 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { isNodeOnline } from '@/utils/status'; import { getNodeDisplayName } from '@/utils/nodes'; +import { logger } from '@/utils/logger'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; - -const GUEST_METADATA_STORAGE_KEY = 'pulseGuestMetadata'; +import { STORAGE_KEYS } from '@/utils/localStorage'; type GuestMetadataRecord = Record; type IdleCallbackHandle = number; @@ -49,7 +49,7 @@ const readGuestMetadataCache = (): GuestMetadataRecord => { } try { - const raw = window.localStorage.getItem(GUEST_METADATA_STORAGE_KEY); + const raw = window.localStorage.getItem(STORAGE_KEYS.GUEST_METADATA); if (!raw) { cachedGuestMetadata = {}; lastPersistedGuestMetadataJSON = null; @@ -62,7 +62,7 @@ const readGuestMetadataCache = (): GuestMetadataRecord => { return cachedGuestMetadata; } } catch (err) { - console.warn('Failed to parse cached guest metadata:', err); + logger.warn('Failed to parse cached guest metadata', err); } cachedGuestMetadata = {}; @@ -110,7 +110,7 @@ const runGuestMetadataPersist = () => { performance.clearMarks(`${markBase}:end`); performance.clearMeasures(markBase); } - console.warn('Failed to serialize guest metadata cache:', err); + logger.warn('Failed to serialize guest metadata cache', err); return; } @@ -121,9 +121,9 @@ const runGuestMetadataPersist = () => { const entries = performance.getEntriesByName(markBase); const entry = entries[entries.length - 1]; if (entry) { - console.debug( - `[guestMetadataCache] skipped persist (unchanged) in ${entry.duration.toFixed(2)}ms`, - ); + logger.debug('[guestMetadataCache] skipped persist (unchanged)', { + durationMs: entry.duration, + }); } performance.clearMarks(`${markBase}:start`); performance.clearMarks(`${markBase}:end`); @@ -133,7 +133,7 @@ const runGuestMetadataPersist = () => { } try { - window.localStorage.setItem(GUEST_METADATA_STORAGE_KEY, serialized); + window.localStorage.setItem(STORAGE_KEYS.GUEST_METADATA, serialized); lastPersistedGuestMetadataJSON = serialized; if (markBase) { performance.mark(`${markBase}:end`); @@ -141,9 +141,10 @@ const runGuestMetadataPersist = () => { const entries = performance.getEntriesByName(markBase); const entry = entries[entries.length - 1]; if (entry) { - console.debug( - `[guestMetadataCache] persisted ${Object.keys(metadata).length} entries in ${entry.duration.toFixed(2)}ms`, - ); + logger.debug('[guestMetadataCache] persisted entries', { + count: Object.keys(metadata).length, + durationMs: entry.duration, + }); } performance.clearMarks(`${markBase}:start`); performance.clearMarks(`${markBase}:end`); @@ -157,7 +158,7 @@ const runGuestMetadataPersist = () => { performance.clearMarks(`${markBase}:end`); performance.clearMeasures(markBase); } - console.warn('Failed to persist guest metadata cache:', err); + logger.warn('Failed to persist guest metadata cache', err); } }; @@ -264,7 +265,7 @@ export function Dashboard(props: DashboardProps) { updateGuestMetadataState(() => metadata || {}); } catch (err) { // Silently fail - metadata is optional for display - console.debug('Failed to load guest metadata:', err); + logger.debug('Failed to load guest metadata', err); } }); @@ -694,11 +695,11 @@ export function Dashboard(props: DashboardProps) { }); const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => { - console.log('handleNodeSelect called:', nodeId, nodeType); + logger.debug('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); + logger.debug('Set selected node', { nodeId }); // Show filters if a node is selected if (nodeId && !showFilters()) { setShowFilters(true); diff --git a/frontend-modern/src/components/Dashboard/DiskHealthSummary.tsx b/frontend-modern/src/components/Dashboard/DiskHealthSummary.tsx deleted file mode 100644 index af1de1f..0000000 --- a/frontend-modern/src/components/Dashboard/DiskHealthSummary.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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 = (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 = {}; - 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 ( - 0}> - -
- - - {diskStats().healthy}/{diskStats().total} - -
- -
- {/* Health Status */} -
- Status -
- 0}> - - {diskStats().healthy} healthy - - - 0}> - - {diskStats().failing} failed - - - 0}> - - {diskStats().lowLife} low - - - 0}> - - {diskStats().unknown} unknown - - -
-
- - {/* Average SSD Life */} - 0}> -
- Avg SSD Life -
-
-
= 50 - ? 'bg-green-500' - : diskStats().avgWearout >= 20 - ? 'bg-yellow-500' - : diskStats().avgWearout >= 10 - ? 'bg-orange-500' - : 'bg-red-500' - }`} - style={`width: ${diskStats().avgWearout}%`} - /> -
- - {Math.round(diskStats().avgWearout)}% - -
-
- - - {/* Disk Distribution */} -
-
Distribution
-
- {Object.entries(diskStats().byNode).map(([node, count]) => ( - - {node}: {count} - - ))} -
-
-
- -
- ); -}; diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 24515d4..fbef9e6 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -5,9 +5,10 @@ import { MetricBar } from './MetricBar'; import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; import { DiskList } from './DiskList'; -import { isGuestRunning, shouldDisplayGuestMetrics } from '@/utils/status'; +import { isGuestRunning } from '@/utils/status'; import { GuestMetadataAPI } from '@/api/guestMetadata'; import { showSuccess, showError } from '@/utils/toast'; +import { logger } from '@/utils/logger'; type Guest = VM | Container; @@ -286,7 +287,7 @@ export function GuestRow(props: GuestRowProps) { showSuccess('Guest URL cleared'); } } catch (err: any) { - console.error('Failed to save guest URL:', err); + logger.error('Failed to save guest URL:', err); showError(err.message || 'Failed to save guest URL'); } }; @@ -313,7 +314,7 @@ export function GuestRow(props: GuestRowProps) { showSuccess('Guest URL removed'); } catch (err: any) { - console.error('Failed to remove guest URL:', err); + logger.error('Failed to remove guest URL:', err); showError(err.message || 'Failed to remove guest URL'); } } @@ -342,7 +343,6 @@ export function GuestRow(props: GuestRowProps) { 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 @@ -581,7 +581,7 @@ export function GuestRow(props: GuestRowProps) { {/* CPU */} - -}> + -}>
- -}> + -}> = (props) => { ? historyMenuRef?.contains(next) || historyToggleRef?.contains(next) : false; const interactingWithTips = - next?.getAttribute('aria-controls') === 'docker-search-help'; + next?.getAttribute('aria-controls') === 'container-search-help'; if (!interactingWithHistory && !interactingWithTips) { commitSearchToHistory(e.currentTarget.value); } @@ -212,8 +212,8 @@ export const DockerFilter: Component = (props) => { Show search history = (p }; const agentOutdated = isAgentOutdated(summary.host.agentVersion); + const runtimeInfo = resolveHostRuntime(summary.host); + const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; return ( = (p ({summary.host.hostname}) - - Docker + + {runtimeInfo.label} - + - v{summary.host.dockerVersion} + v{runtimeVersion}
diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 1701cdf..35db880 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -11,6 +11,8 @@ import { useWebSocket } from '@/App'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { formatBytes, formatRelativeTime } from '@/utils/format'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; +import { logger } from '@/utils/logger'; +import { STORAGE_KEYS } from '@/utils/localStorage'; const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); const DEGRADED_HOST_STATUSES = new Set([ @@ -22,8 +24,6 @@ const DEGRADED_HOST_STATUSES = new Set([ 'unknown', ]); -const DOCKER_METADATA_STORAGE_KEY = 'pulseDockerMetadata'; - type DockerMetadataRecord = Record; interface DockerHostsProps { @@ -36,17 +36,21 @@ export const DockerHosts: Component = (props) => { const { initialDataReceived, reconnecting, connected } = useWebSocket(); // Load docker metadata from localStorage or API - const [dockerMetadata, setDockerMetadata] = createSignal(() => { + const loadInitialDockerMetadata = (): DockerMetadataRecord => { try { - const cached = localStorage.getItem(DOCKER_METADATA_STORAGE_KEY); + const cached = localStorage.getItem(STORAGE_KEYS.DOCKER_METADATA); if (cached) { return JSON.parse(cached); } } catch (err) { - console.warn('Failed to parse cached docker metadata:', err); + logger.warn('Failed to parse cached docker metadata', err); } return {}; - }); + }; + + const [dockerMetadata, setDockerMetadata] = createSignal( + loadInitialDockerMetadata(), + ); const sortedHosts = createMemo(() => { const hosts = props.hosts || []; @@ -167,13 +171,13 @@ export const DockerHosts: Component = (props) => { .then((metadata) => { setDockerMetadata(metadata || {}); try { - localStorage.setItem(DOCKER_METADATA_STORAGE_KEY, JSON.stringify(metadata || {})); + localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(metadata || {})); } catch (err) { - console.warn('Failed to cache docker metadata:', err); + logger.warn('Failed to cache docker metadata', err); } }) .catch((err) => { - console.debug('Failed to load docker metadata:', err); + logger.debug('Failed to load docker metadata', err); }); }); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); @@ -196,9 +200,9 @@ export const DockerHosts: Component = (props) => { // Cache to localStorage try { - localStorage.setItem(DOCKER_METADATA_STORAGE_KEY, JSON.stringify(updated)); + localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(updated)); } catch (err) { - console.warn('Failed to cache docker metadata:', err); + logger.warn('Failed to cache docker metadata', err); } return updated; @@ -283,12 +287,12 @@ export const DockerHosts: Component = (props) => { /> } - title={reconnecting() ? 'Reconnecting to Docker agents...' : 'Loading Docker data...'} + title={reconnecting() ? 'Reconnecting to container agents...' : 'Loading container data...'} description={ reconnecting() ? 'Re-establishing metrics from the monitoring service.' : connected() - ? 'Waiting for the first Docker update.' + ? 'Waiting for the first container update.' : 'Connecting to the monitoring service.' } /> @@ -313,15 +317,15 @@ export const DockerHosts: Component = (props) => { /> } - title="No Docker hosts configured" - description="Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically." + title="No container runtimes reporting" + description="Deploy the Pulse container agent (Docker or Podman) on at least one host to light up this tab. As soon as an agent reports in, runtime metrics appear automatically." actions={ - - -
- - -
- 0}> -
-
- Services -
-
- - {(serviceEntry) => { - const serviceState = props.getServiceState(serviceEntry.key); - - const selectService = () => { - hostState.setExpanded(true); - props.onSelect?.({ - type: 'service', - hostId: entry.hostId, - id: serviceEntry.key, - }); - }; - - const selectTask = (taskNodeId: string) => { - hostState.setExpanded(true); - serviceState.setExpanded(true); - props.onSelect?.({ - type: 'task', - hostId: entry.hostId, - serviceKey: serviceEntry.key, - id: taskNodeId, - }); - }; - - return ( -
-
- 0}> - - - -
- - 0 && serviceState.isExpanded()}> -
- - {(taskEntry) => { - const task = taskEntry.task; - return ( - - ); - }} - -
-
-
- ); - }} -
-
-
-
- - 0}> -
-
- Standalone Containers -
-
- - {(containerEntry) => { - const container = containerEntry.container; - const selectContainer = () => { - hostState.setExpanded(true); - props.onSelect?.({ - type: 'container', - hostId: entry.hostId, - id: containerEntry.nodeId, - }); - }; - - return ( - - ); - }} - -
-
-
-
-
-
- ); - }} - - - - ); -}; diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 296225f..ced68f5 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -4,10 +4,12 @@ import { Card } from '@/components/shared/Card'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; -import { formatBytes, formatPercent, formatUptime, formatRelativeTime } from '@/utils/format'; +import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; import type { DockerMetadata } from '@/api/dockerMetadata'; import { DockerMetadataAPI } from '@/api/dockerMetadata'; +import { resolveHostRuntime } from './runtimeDisplay'; import { showSuccess, showError } from '@/utils/toast'; +import { logger } from '@/utils/logger'; const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); const DEGRADED_HOST_STATUSES = new Set([ @@ -108,6 +110,214 @@ const parseSearchTerm = (term?: string): SearchToken[] => { }); }; +interface PodmanMetadataItem { + label: string; + value?: string; +} + +interface PodmanMetadataSection { + title: string; + items: PodmanMetadataItem[]; +} + +const PODMAN_METADATA_GROUPS: Array<{ + title: string; + prefixes?: string[]; + keys?: string[]; +}> = [ + { + title: 'Pod', + prefixes: ['io.podman.annotations.pod.', 'io.podman.pod.', 'net.containers.podman.pod.'], + }, + { + title: 'Compose', + prefixes: ['io.podman.compose.'], + }, + { + title: 'Auto Update', + prefixes: ['io.containers.autoupdate.'], + keys: ['io.containers.autoupdate'], + }, + { + title: 'User Namespace', + keys: ['io.podman.annotations.userns', 'io.containers.userns'], + }, + { + title: 'Capabilities', + keys: ['io.containers.capabilities', 'io.containers.selinux', 'io.containers.seccomp'], + }, + { + title: 'Podman Annotations', + prefixes: ['io.podman.annotations.'], + }, + { + title: 'Container Settings', + prefixes: ['io.containers.'], + }, +]; + +const humanizePodmanKey = (raw: string): string => { + if (!raw) return 'Value'; + const cleaned = raw.replace(/[_\-.]+/g, ' ').trim(); + if (!cleaned) return 'Value'; + return cleaned + .split(' ') + .map((segment) => { + if (!segment) return segment; + if (segment.toUpperCase() === segment) return segment; + return segment.charAt(0).toUpperCase() + segment.slice(1); + }) + .join(' ') + .replace(/\bId\b/g, 'ID') + .replace(/\bUrl\b/g, 'URL'); +}; + +const stripPrefix = (key: string, prefixes: string[] = []): string => { + for (const prefix of prefixes) { + if (prefix && key.startsWith(prefix)) { + const stripped = key.slice(prefix.length); + if (stripped) { + return stripped; + } + } + } + const lastDot = key.lastIndexOf('.'); + if (lastDot >= 0 && lastDot < key.length - 1) { + return key.slice(lastDot + 1); + } + return key; +}; + +const buildPodmanMetadataSections = ( + metadata?: DockerContainer['podman'], + labels?: Record, +): PodmanMetadataSection[] => { + const sections: PodmanMetadataSection[] = []; + const consumed = new Set(); + const markConsumed = (...keys: (string | undefined)[]) => { + keys.forEach((key) => { + if (key) consumed.add(key); + }); + }; + + const pushSection = (title: string, items: PodmanMetadataItem[]) => { + if (items.length > 0) { + sections.push({ title, items }); + } + }; + + if (metadata) { + const podItems: PodmanMetadataItem[] = []; + if (metadata.podName) { + podItems.push({ label: 'Pod Name', value: metadata.podName }); + markConsumed('io.podman.annotations.pod.name'); + } + if (metadata.podId) { + podItems.push({ label: 'Pod ID', value: metadata.podId }); + markConsumed('io.podman.annotations.pod.id'); + } + if (metadata.infra !== undefined) { + podItems.push({ label: 'Infra Container', value: metadata.infra ? 'true' : 'false' }); + markConsumed('io.podman.annotations.pod.infra'); + } + pushSection('Pod', podItems); + + const composeItems: PodmanMetadataItem[] = []; + if (metadata.composeProject) { + composeItems.push({ label: 'Project', value: metadata.composeProject }); + markConsumed('io.podman.compose.project'); + } + if (metadata.composeService) { + composeItems.push({ label: 'Service', value: metadata.composeService }); + markConsumed('io.podman.compose.service'); + } + if (metadata.composeWorkdir) { + composeItems.push({ label: 'Working Dir', value: metadata.composeWorkdir }); + markConsumed('io.podman.compose.working_dir'); + } + if (metadata.composeConfigHash) { + composeItems.push({ label: 'Config Hash', value: metadata.composeConfigHash }); + markConsumed('io.podman.compose.config-hash'); + } + pushSection('Compose', composeItems); + + const autoUpdateItems: PodmanMetadataItem[] = []; + if (metadata.autoUpdatePolicy) { + autoUpdateItems.push({ label: 'Policy', value: metadata.autoUpdatePolicy }); + markConsumed('io.containers.autoupdate'); + } + if (metadata.autoUpdateRestart) { + autoUpdateItems.push({ label: 'Restart', value: metadata.autoUpdateRestart }); + markConsumed('io.containers.autoupdate.restart'); + } + pushSection('Auto Update', autoUpdateItems); + + const namespaceItems: PodmanMetadataItem[] = []; + if (metadata.userNamespace) { + namespaceItems.push({ label: 'User Namespace', value: metadata.userNamespace }); + markConsumed('io.podman.annotations.userns', 'io.containers.userns'); + } + pushSection('Security', namespaceItems); + } + + if (!labels || Object.keys(labels).length === 0) { + return sections; + } + + const entries = Object.entries(labels); + const remaining = entries.filter( + ([key]) => + !consumed.has(key) && (key.includes('podman') || key.startsWith('io.containers.')), + ); + if (remaining.length === 0) { + return sections; + } + + const used = new Set(); + const addSection = (title: string, prefixes: string[] = [], keys: string[] = []) => { + const items: Array<[string, string]> = []; + + for (const [key, value] of remaining) { + if (used.has(key)) continue; + + const matchesPrefix = prefixes.some((prefix) => prefix && key.startsWith(prefix)); + const matchesKey = keys.includes(key); + + if (!matchesPrefix && !matchesKey) continue; + + items.push([key, value]); + used.add(key); + } + + if (items.length === 0) return; + + sections.push({ + title, + items: items.map(([key, value]) => ({ + label: humanizePodmanKey(stripPrefix(key, prefixes)), + value: value || undefined, + })), + }); + }; + + for (const group of PODMAN_METADATA_GROUPS) { + addSection(group.title, group.prefixes ?? [], group.keys ?? []); + } + + const leftovers = remaining.filter(([key]) => !used.has(key)); + if (leftovers.length > 0) { + sections.push({ + title: 'Additional Podman Labels', + items: leftovers.map(([key, value]) => ({ + label: humanizePodmanKey(stripPrefix(key)), + value: value || undefined, + })), + }); + } + + return sections; +}; + const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => { if (!containers.length) return undefined; @@ -194,6 +404,17 @@ const containerMatchesToken = ( return hostName.includes(token.value); } + if (token.key === 'pod') { + const pod = container.podman?.podName?.toLowerCase() ?? ''; + return pod.includes(token.value); + } + + if (token.key === 'compose') { + const project = container.podman?.composeProject?.toLowerCase() ?? ''; + const service = container.podman?.composeService?.toLowerCase() ?? ''; + return project.includes(token.value) || service.includes(token.value); + } + if (token.key === 'state') { return state.includes(token.value) || health.includes(token.value); } @@ -212,6 +433,19 @@ const containerMatchesToken = ( .filter(Boolean) .map((value) => value!.toLowerCase()); + if (container.podman) { + [ + container.podman.podName, + container.podman.podId, + container.podman.composeProject, + container.podman.composeService, + container.podman.autoUpdatePolicy, + container.podman.userNamespace, + ] + .filter(Boolean) + .forEach((value) => fields.push(value!.toLowerCase())); + } + if (container.labels) { Object.entries(container.labels).forEach(([key, value]) => { fields.push(key.toLowerCase()); @@ -312,7 +546,6 @@ const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => { const displayName = props.host.displayName || props.host.hostname || props.host.id; - return ( @@ -336,6 +569,8 @@ const DockerContainerRow: Component<{ onCustomUrlUpdate?: (resourceId: string, url: string) => void; }> = (props) => { const { host, container } = props.row; + const runtimeInfo = resolveHostRuntime(host); + const runtimeVersion = () => host.runtimeVersion || host.dockerVersion || null; const rowId = buildRowId(host, props.row); const resourceId = () => `${host.id}:container:${container.id || container.name}`; const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId()); @@ -369,6 +604,14 @@ const DockerContainerRow: Component<{ if (!total || total <= 0) return undefined; return `${diskUsageLabel()} / ${formatBytes(total, 0)}`; }); + const createdRelative = createMemo(() => (container.createdAt ? formatRelativeTime(container.createdAt) : null)); + const createdAbsolute = createMemo(() => (container.createdAt ? formatAbsoluteTime(container.createdAt) : null)); + const startedRelative = createMemo(() => + container.startedAt ? formatRelativeTime(container.startedAt) : null, + ); + const startedAbsolute = createMemo(() => + container.startedAt ? formatAbsoluteTime(container.startedAt) : null, + ); const mounts = createMemo(() => container.mounts || []); const hasMounts = createMemo(() => mounts().length > 0); const blockIo = createMemo(() => container.blockIo); @@ -384,6 +627,15 @@ const DockerContainerRow: Component<{ }; const blockIoReadRateLabel = createMemo(() => formatIoRate(blockIoReadRate())); const blockIoWriteRateLabel = createMemo(() => formatIoRate(blockIoWriteRate())); + const podmanMetadata = createMemo(() => container.podman); + const podName = createMemo(() => podmanMetadata()?.podName?.trim() || undefined); + const isPodInfra = createMemo(() => podmanMetadata()?.infra ?? false); + const podmanMetadataSections = createMemo(() => + buildPodmanMetadataSections(podmanMetadata(), container.labels), + ); + const hasPodmanMetadata = createMemo( + () => !!podmanMetadata() || podmanMetadataSections().length > 0, + ); const hasBlockIo = createMemo(() => { const stats = blockIo(); if (!stats) return false; @@ -393,15 +645,14 @@ const DockerContainerRow: Component<{ const writeRate = stats.writeRateBytesPerSecond ?? 0; return read > 0 || write > 0 || readRate > 0 || writeRate > 0; }); - const hasBlockIoRates = createMemo(() => !!blockIoReadRateLabel() || !!blockIoWriteRateLabel()); - const hasDrawerContent = createMemo(() => { return ( (container.ports && container.ports.length > 0) || (container.labels && Object.keys(container.labels).length > 0) || (container.networks && container.networks.length > 0) || hasMounts() || - hasBlockIo() + hasBlockIo() || + hasPodmanMetadata() ); }); @@ -531,7 +782,7 @@ const DockerContainerRow: Component<{ showSuccess('Container URL cleared'); } } catch (err: any) { - console.error('Failed to save container URL:', err); + logger.error('Failed to save container URL:', err); showError(err.message || 'Failed to save container URL'); // Revert on error setCustomUrl(hadUrl ? customUrl() : undefined); @@ -562,7 +813,7 @@ const DockerContainerRow: Component<{ showSuccess('Container URL removed'); } catch (err: any) { - console.error('Failed to remove container URL:', err); + logger.error('Failed to remove container URL:', err); showError(err.message || 'Failed to remove container URL'); } } @@ -646,6 +897,18 @@ const DockerContainerRow: Component<{ > {container.name || container.id} + + {(name) => ( + + Pod: {name()} + + + infra + + + + )} + - Container + {runtimeInfo.label} @@ -764,7 +1030,7 @@ const DockerContainerRow: Component<{ /> - + —}> - -
- - R {blockIoReadRateLabel()} - - - - - - W {blockIoWriteRateLabel()} - -
-
—}> @@ -809,6 +1062,134 @@ const DockerContainerRow: Component<{
+
+
+ Summary +
+
+
+ Runtime + + {runtimeInfo.label} + + {(version) => ( + {version()} + )} + + +
+
+ Image + + {container.image || '—'} + +
+ + {(name) => ( +
+ Pod + + {name()} + + + infra + + + +
+ )} +
+ + {(project) => ( +
+ Compose Project + {project()} +
+ )} +
+ + {(service) => ( +
+ Compose Service + {service()} +
+ )} +
+ + {(policy) => ( +
+ Auto Update + + {policy()} + + {(restart) => ( + restart: {restart()} + )} + + +
+ )} +
+ + {(userns) => ( +
+ User Namespace + {userns()} +
+ )} +
+
+ State + {statusLabel()} +
+
+ Restarts + {restarts()} +
+ + {(created) => ( +
+ Created +
+ {created()} + + {(abs) => ( +
{abs()}
+ )} +
+
+
+ )} +
+ + {(started) => ( +
+ Started +
+ {started()} + + {(abs) => ( +
{abs()}
+ )} +
+
+
+ )} +
+
+ Uptime + {uptime()} +
+
+ +
+ Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present. +
+
+
0}>
@@ -856,6 +1237,40 @@ const DockerContainerRow: Component<{
+ +
+
+ Podman Metadata +
+
+ + {(section) => ( +
+
+ {section.title} +
+
+ + {(item) => ( +
+ {item.label} + + {item.value || '—'} + +
+ )} +
+
+
+ )} +
+
+
+
+
@@ -1129,7 +1544,7 @@ const DockerServiceRow: Component<{ showSuccess('Service URL cleared'); } } catch (err: any) { - console.error('Failed to save service URL:', err); + logger.error('Failed to save service URL:', err); showError(err.message || 'Failed to save service URL'); // Revert on error setCustomUrl(hadUrl ? customUrl() : undefined); @@ -1160,7 +1575,7 @@ const DockerServiceRow: Component<{ showSuccess('Service URL removed'); } catch (err: any) { - console.error('Failed to remove service URL:', err); + logger.error('Failed to remove service URL:', err); showError(err.message || 'Failed to remove service URL'); } } @@ -1318,7 +1733,7 @@ const DockerServiceRow: Component<{ — — - — + — {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} @@ -1628,48 +2043,48 @@ const DockerUnifiedTable: Component = (props) => { fallback={ } > - - + +
- - - - - - - - diff --git a/frontend-modern/src/components/Docker/runtimeDisplay.ts b/frontend-modern/src/components/Docker/runtimeDisplay.ts new file mode 100644 index 0000000..551f6ea --- /dev/null +++ b/frontend-modern/src/components/Docker/runtimeDisplay.ts @@ -0,0 +1,196 @@ +import type { DockerContainer, DockerHost } from '@/types/api'; + +export type RuntimeKind = 'docker' | 'podman' | 'containerd' | 'cri-o' | 'nerdctl' | 'unknown'; + +export interface RuntimeDisplayInfo { + id: RuntimeKind; + label: string; + badgeClass: string; + raw: string; +} + +export interface RuntimeDisplayOptions { + hint?: string | null; + defaultId?: Exclude | 'unknown'; +} + +const BADGE_CLASSES: Record, string> = { + docker: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + podman: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', + containerd: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + 'cri-o': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + nerdctl: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200', +}; + +const UNKNOWN_BADGE_CLASS = 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + +const LABEL_BY_KIND: Record = { + docker: 'Docker', + podman: 'Podman', + containerd: 'containerd', + 'cri-o': 'CRI-O', + nerdctl: 'nerdctl', + unknown: 'Container runtime', +}; + +const normalize = (value?: string | null) => value?.trim() ?? ''; + +const MATCHERS: Array<{ id: Exclude; keywords: string[] }> = [ + { id: 'podman', keywords: ['podman', 'libpod'] }, + { id: 'nerdctl', keywords: ['nerdctl'] }, + { id: 'containerd', keywords: ['containerd'] }, + { id: 'cri-o', keywords: ['cri-o', 'crio'] }, + { id: 'docker', keywords: ['docker', 'moby engine', 'moby'] }, +]; + +const detectRuntime = (value?: string | null) => { + const raw = normalize(value); + if (!raw) { + return null; + } + + const normalized = raw.toLowerCase(); + for (const matcher of MATCHERS) { + if (matcher.keywords.some((keyword) => normalized.includes(keyword))) { + return { id: matcher.id, raw }; + } + } + + return null; +}; + +const formatRuntimeLabel = (value: string): string => { + if (!value) return LABEL_BY_KIND.unknown; + + const cleaned = value.replace(/\s+/g, ' ').trim(); + + if (/^cri-?o$/i.test(cleaned)) { + return LABEL_BY_KIND['cri-o']; + } + + return cleaned + .split(/[-_\s]+/) + .filter(Boolean) + .map((segment) => { + if (segment.toLowerCase() === 'cri' || segment.toLowerCase() === 'crio') { + return 'CRI'; + } + if (segment.toLowerCase() === 'o') { + return 'O'; + } + if (segment === segment.toUpperCase()) { + return segment; + } + return segment.charAt(0).toUpperCase() + segment.slice(1); + }) + .join(' '); +}; + +export const getRuntimeDisplay = ( + runtime?: string | null, + options: RuntimeDisplayOptions = {}, +): RuntimeDisplayInfo => { + const candidates = [ + { value: runtime, raw: normalize(runtime) }, + { value: options.hint, raw: normalize(options.hint) }, + ]; + + for (const candidate of candidates) { + const detected = detectRuntime(candidate.value); + if (detected) { + const label = LABEL_BY_KIND[detected.id]; + const badgeClass = BADGE_CLASSES[detected.id]; + return { + id: detected.id, + label, + badgeClass, + raw: detected.raw, + }; + } + } + + const firstRaw = candidates.find((candidate) => candidate.raw !== '')?.raw ?? ''; + const defaultKind: RuntimeKind = options.defaultId ?? 'unknown'; + + if (defaultKind !== 'unknown') { + return { + id: defaultKind, + label: LABEL_BY_KIND[defaultKind], + badgeClass: BADGE_CLASSES[defaultKind], + raw: firstRaw, + }; + } + + const label = firstRaw ? formatRuntimeLabel(firstRaw) : LABEL_BY_KIND.unknown; + + return { + id: 'unknown', + label, + badgeClass: UNKNOWN_BADGE_CLASS, + raw: firstRaw, + }; +}; + +const PODMAN_LABEL_PREFIXES = ['io.podman.', 'io.containers.', 'net.containers.podman']; + +const hasPodmanSignature = (container?: DockerContainer | null) => { + if (!container?.labels) return false; + const labels = container.labels; + return Object.keys(labels).some((key) => + PODMAN_LABEL_PREFIXES.some((prefix) => key.startsWith(prefix)), + ) || Object.values(labels).some((value) => value?.toLowerCase().includes('podman')); +}; + +const hostHasPodmanSignature = (host?: Pick | null) => { + if (!host?.containers || host.containers.length === 0) return false; + return host.containers.some((container) => hasPodmanSignature(container)); +}; + +export interface ResolveRuntimeOptions extends RuntimeDisplayOptions { + requireSignatureForGuess?: boolean; +} + +const stripVersion = (value?: string | null) => { + if (!value) return ''; + const match = value.match(/\d+(?:\.\d+){0,2}/); + return match ? match[0] : value.trim(); +}; + +const isLikelyPodmanVersion = (value?: string | null) => { + const clean = stripVersion(value); + if (!clean) return false; + const parts = clean.split('.'); + const major = Number.parseInt(parts[0] || '', 10); + if (!Number.isFinite(major)) return false; + return major >= 2 && major <= 6; +}; + +export const resolveHostRuntime = ( + host: Pick, + options: ResolveRuntimeOptions = {}, +): RuntimeDisplayInfo => { + const hint = options.hint ?? host.runtimeVersion ?? host.dockerVersion ?? null; + const base = getRuntimeDisplay(host.runtime, { + ...options, + hint, + defaultId: options.defaultId ?? 'docker', + }); + if (base.id !== 'docker' && base.id !== 'unknown') { + return base; + } + + const hasSignature = hostHasPodmanSignature(host); + if (hasSignature) { + return getRuntimeDisplay('podman', { hint }); + } + + if (!options.requireSignatureForGuess && isLikelyPodmanVersion(hint)) { + return getRuntimeDisplay('podman', { hint }); + } + + if (base.id === 'unknown') { + return getRuntimeDisplay('docker', { hint }); + } + + return base; +}; diff --git a/frontend-modern/src/components/ErrorBoundary.tsx b/frontend-modern/src/components/ErrorBoundary.tsx index 8ffaa34..efe7880 100644 --- a/frontend-modern/src/components/ErrorBoundary.tsx +++ b/frontend-modern/src/components/ErrorBoundary.tsx @@ -40,7 +40,9 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
-

{props.error.message}

+

+ Please try again or reload the page. If the problem persists, contact your administrator. +

@@ -60,21 +62,9 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
- - - {details() && ( -
-
-              {props.error.stack}
-            
-
- )} +
+ Technical details are suppressed in this view. Check server logs for full context. +
); diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 239e435..c07f5e1 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -1,12 +1,12 @@ import { Component, createSignal, Show, onMount } from 'solid-js'; import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; -import { clearStoredAPIToken } from '@/utils/tokenStorage'; -import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient'; -import { STORAGE_KEYS } from '@/constants'; +import { clearAuth as clearApiClientAuth, setApiToken as setApiClientToken } from '@/utils/apiClient'; +import { getPulseBaseUrl } from '@/utils/url'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { showTokenReveal } from '@/stores/tokenReveal'; import type { APITokenRecord } from '@/api/security'; +import { STORAGE_KEYS } from '@/utils/localStorage'; export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: boolean }> = ( props, @@ -135,19 +135,7 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool setSavedToken(token); // Clear any cached credentials from prior sessions so a reload doesn't auto-submit again - try { - sessionStorage.removeItem('pulse_auth'); - sessionStorage.removeItem('pulse_auth_user'); - } catch (storageError) { - console.warn('Unable to clear cached auth session storage', storageError); - } - - try { - clearStoredAPIToken(); - clearApiClientToken(); - } catch (storageError) { - console.warn('Unable to clear cached API token', storageError); - } + clearApiClientAuth(); const bootstrapRecord: APITokenRecord = { id: 'bootstrap-token', @@ -183,13 +171,14 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool }; const downloadCredentials = () => { + const baseUrl = getPulseBaseUrl(); const credentials = `Pulse Security Credentials ======================== Generated: ${new Date().toISOString()} Web Interface Login: ------------------- -URL: ${window.location.origin} +URL: ${baseUrl} Username: ${savedUsername()} Password: ${savedPassword()} @@ -198,7 +187,7 @@ API Access: API Token: ${savedToken()} Example API Usage: -curl -H "X-API-Token: ${savedToken()}" ${window.location.origin}/api/state +curl -H "X-API-Token: ${savedToken()}" ${baseUrl}/api/state IMPORTANT: Keep these credentials secure! `; diff --git a/frontend-modern/src/components/LegacySSHBanner.tsx b/frontend-modern/src/components/LegacySSHBanner.tsx deleted file mode 100644 index f013d21..0000000 --- a/frontend-modern/src/components/LegacySSHBanner.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Show, createSignal, createEffect, onMount } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; - -/** - * ⚠️ MIGRATION SCAFFOLDING - TEMPORARY COMPONENT - * - * This banner exists only to handle migration from legacy SSH-in-container - * to the secure pulse-sensor-proxy architecture introduced in v4.23.0. - * - * REMOVAL CRITERIA: Remove after v5.0 or when backend telemetry shows <1% detection rate - * for 30+ days. This component serves no functional purpose beyond migration assistance. - * - * Can be disabled by setting PULSE_LEGACY_DETECTION=false on backend. - */ -export function LegacySSHBanner() { - const navigate = useNavigate(); - const [isVisible, setIsVisible] = createSignal(false); - const [isDismissed, setIsDismissed] = createSignal(false); - - // Check if previously dismissed - onMount(() => { - const dismissed = localStorage.getItem('legacySSHBannerDismissed'); - if (dismissed === 'true') { - setIsDismissed(true); - setIsVisible(false); - } - }); - - // Check health endpoint for legacy SSH detection - createEffect(async () => { - if (isDismissed()) return; - - try { - const response = await fetch('/api/health'); - const health = await response.json(); - - if (health.legacySSHDetected && health.recommendProxyUpgrade) { - setIsVisible(true); - // Log banner impression for telemetry (removal criteria tracking) - console.info('[Migration] Legacy SSH banner shown to user'); - } - } catch (_error) { - // Silently fail - health check failures shouldn't break the UI - } - }); - - const handleDismiss = () => { - setIsDismissed(true); - setIsVisible(false); - // Store dismissal in localStorage so it persists - localStorage.setItem('legacySSHBannerDismissed', 'true'); - // Log dismissal for telemetry - console.info('[Migration] Legacy SSH banner dismissed by user'); - }; - - return ( - -
-
-
-
- {/* Warning icon */} - - - - - - -
-
- Legacy temperature monitoring detected. - Remove each node and re-add it using the installer script in Settings → Nodes (advanced: rerun the host installer script directly). - - View upgrade guide ↗ - -
- -
-
- - {/* Dismiss button */} - -
-
-
-
- ); -} diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index b351cda..20f994b 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -1,8 +1,7 @@ import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js'; -import { setBasicAuth } from '@/utils/apiClient'; -import { STORAGE_KEYS } from '@/constants'; +import { logger } from '@/utils/logger'; +import { STORAGE_KEYS } from '@/utils/localStorage'; -// Force include FirstRunSetup with lazy loading const FirstRunSetup = lazy(() => import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })), ); @@ -95,29 +94,29 @@ export const Login: Component = (props) => { window.history.replaceState({}, document.title, newUrl); } - console.log('[Login] Starting auth check...'); + logger.debug('[Login] Starting auth check...'); try { const response = await fetch('/api/security/status'); - console.log('[Login] Auth check response:', response.status); + logger.debug('[Login] Auth check response', { status: response.status }); if (response.ok) { const data = await response.json(); - console.log('[Login] Auth status data:', data); + logger.debug('[Login] Auth status data', data); setAuthStatus(data); } else if (response.status === 429) { // Rate limited - wait a bit and assume auth is configured - console.log('[Login] Rate limited, assuming auth is configured'); + logger.debug('[Login] Rate limited, assuming auth is configured'); setAuthStatus({ hasAuthentication: true }); } else { - console.log('[Login] Auth check failed, assuming no auth'); + logger.debug('[Login] Auth check failed, assuming no auth'); // On error, assume no auth configured setAuthStatus({ hasAuthentication: false }); } } catch (err) { - console.error('[Login] Failed to check auth status:', err); + logger.error('[Login] Failed to check auth status:', err); // On error, assume no auth configured setAuthStatus({ hasAuthentication: false }); } finally { - console.log('[Login] Auth check complete, setting loading to false'); + logger.debug('[Login] Auth check complete, setting loading to false'); setLoadingAuth(false); } }); @@ -156,7 +155,7 @@ export const Login: Component = (props) => { throw new Error('OIDC response missing authorization URL'); } catch (err) { - console.error('[Login] Failed to start OIDC login:', err); + logger.error('[Login] Failed to start OIDC login:', err); setOidcError('Failed to start single sign-on. Please try again.'); } finally { if (!redirecting) { @@ -165,14 +164,8 @@ export const Login: Component = (props) => { } }; - // Only auto-redirect to OIDC if password auth is disabled - // This prevents redirect loops when both password and OIDC are configured - // createEffect(() => { - // if (!loadingAuth() && supportsOIDC() && !autoOidcTriggered()) { - // setAutoOidcTriggered(true); - // startOidcLogin(); - // } - // }); + // Auto-redirect to OIDC is intentionally disabled to prevent redirect loops + // when both password and OIDC are configured. Users must manually click OIDC button. const handleSubmit = async (e: Event) => { e.preventDefault(); @@ -197,8 +190,12 @@ export const Login: Component = (props) => { const data = await response.json(); if (response.ok && data.success) { - // Credentials are valid, save them and notify parent - setBasicAuth(username(), password()); + // Credentials are valid; persist username for convenience and rely on session cookie + try { + sessionStorage.setItem('pulse_auth_user', username()); + } catch (_err) { + // Ignore storage failures (private browsing, etc.) + } props.onLogin(); } else if (response.status === 403) { // Account is locked @@ -245,7 +242,11 @@ export const Login: Component = (props) => { }); if (response.ok) { - setBasicAuth(username(), password()); + try { + sessionStorage.setItem('pulse_auth_user', username()); + } catch (_storageErr) { + // Ignore storage issues + } props.onLogin(); } else if (response.status === 401) { setError('Invalid username or password'); @@ -263,7 +264,10 @@ export const Login: Component = (props) => { }; // Debug logging - console.log('[Login] Render - loadingAuth:', loadingAuth(), 'authStatus:', authStatus()); + logger.debug('[Login] Render', { + loadingAuth: loadingAuth(), + authStatus: authStatus(), + }); const legacyDisableAuth = () => authStatus()?.deprecatedDisableAuth === true; const showFirstRunSetup = () => diff --git a/frontend-modern/src/components/NotificationContainer.tsx b/frontend-modern/src/components/NotificationContainer.tsx deleted file mode 100644 index f9c93aa..0000000 --- a/frontend-modern/src/components/NotificationContainer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { Component } from 'solid-js'; - -const NotificationContainer: Component = () => null; - -export default NotificationContainer; diff --git a/frontend-modern/src/components/SecurityWarning.tsx b/frontend-modern/src/components/SecurityWarning.tsx index 26f099a..a037852 100644 --- a/frontend-modern/src/components/SecurityWarning.tsx +++ b/frontend-modern/src/components/SecurityWarning.tsx @@ -1,6 +1,8 @@ import { Component, createSignal, Show, onMount } from 'solid-js'; import { Portal } from 'solid-js/web'; import { SectionHeader } from '@/components/shared/SectionHeader'; +import { isPulseHttps } from '@/utils/url'; +import { logger } from '@/utils/logger'; interface SecurityStatus { hasAuthentication: boolean; @@ -42,15 +44,17 @@ export const SecurityWarning: Component = () => { let score = 0; const maxScore = 5; + const runningOverHttps = isPulseHttps(); + if (data.credentialsEncrypted !== false) score++; // Always true currently if (data.exportProtected) score++; if (data.apiTokenConfigured) score++; - if (data.hasHTTPS || window.location.protocol === 'https:') score++; + if (data.hasHTTPS || runningOverHttps) score++; if (data.hasAuthentication) score++; setStatus({ hasAuthentication: data.hasAuthentication || false, - hasHTTPS: window.location.protocol === 'https:', + hasHTTPS: runningOverHttps, hasAPIToken: data.apiTokenConfigured || false, hasAuditLogging: data.hasAuditLogging || false, credentialsEncrypted: true, // Always true in current implementation @@ -63,7 +67,7 @@ export const SecurityWarning: Component = () => { }); } } catch (error) { - console.error('Failed to fetch security status:', error); + logger.error('Failed to fetch security status:', error); } }); diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 1fea366..f75d5e8 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -5,11 +5,11 @@ import { formatRelativeTime } from '@/utils/format'; import { useWebSocket } from '@/App'; import type { DockerHost, Host } from '@/types/api'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; -import { setStoredAPIToken } from '@/utils/tokenStorage'; import { setApiToken as setApiClientToken } from '@/utils/apiClient'; +import { logger } from '@/utils/logger'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; -import { ApiIcon } from '@/components/icons/ApiIcon'; +import BadgeCheck from 'lucide-solid/icons/badge-check'; import { API_SCOPE_LABELS, API_SCOPE_OPTIONS, @@ -125,14 +125,14 @@ export const APITokenManager: Component = (props) => { description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.', }, { - label: 'Docker report', + label: 'Container report', scopes: [DOCKER_REPORT_SCOPE], - description: 'Permits Docker agents to stream host and container telemetry only.', + description: 'Permits container agents (Docker or Podman) to stream host and container telemetry only.', }, { - label: 'Docker manage', + label: 'Container manage', scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE], - description: 'Extends Docker reporting with lifecycle actions (restart, stop, etc.).', + description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).', }, { label: 'Settings read', @@ -189,7 +189,7 @@ export const APITokenManager: Component = (props) => { setTokens(list); setTokensLoaded(true); } catch (err) { - console.error('Failed to load API tokens', err); + logger.error('Failed to load API tokens', err); showError('Failed to load API tokens'); } finally { setLoading(false); @@ -263,10 +263,9 @@ export const APITokenManager: Component = (props) => { showSuccess('New API token generated. Copy it below while it is still visible.'); props.onTokensChanged?.(); - setStoredAPIToken(token); setApiClientToken(token); } catch (err) { - console.error('Failed to generate API token', err); + logger.error('Failed to generate API token', err); showError('Failed to generate API token'); } finally { setIsGenerating(false); @@ -309,7 +308,7 @@ export const APITokenManager: Component = (props) => { const hostSummary = extraCount > 0 ? `${hostListPreview}, +${extraCount} more` : hostListPreview; const hostCountLabel = - dockerUsage.count === 1 ? 'Docker host' : `${dockerUsage.count} Docker hosts`; + dockerUsage.count === 1 ? 'container host' : `${dockerUsage.count} container hosts`; messageChunks.push(`${hostCountLabel}: ${hostSummary}`); } if (hostUsage) { @@ -346,7 +345,7 @@ export const APITokenManager: Component = (props) => { setNewTokenRecord(null); } } catch (err) { - console.error('Failed to revoke API token', err); + logger.error('Failed to revoke API token', err); showError('Failed to revoke API token'); } }; @@ -379,7 +378,7 @@ export const APITokenManager: Component = (props) => {
- +
= (props) => { if (dockerUsageEntry) { usageSegments.push( dockerUsageEntry.count === 1 - ? dockerUsageEntry.hosts[0]?.label ?? 'Docker host' - : `${dockerUsageEntry.count} Docker hosts`, + ? dockerUsageEntry.hosts[0]?.label ?? 'Container host' + : `${dockerUsageEntry.count} container hosts`, ); usageTitleSegments.push( - `Docker hosts: ${dockerUsageEntry.hosts.map((host) => host.label).join(', ')}`, + `Container hosts: ${dockerUsageEntry.hosts.map((host) => host.label).join(', ')}`, ); } if (hostUsageEntry) { @@ -690,7 +689,7 @@ export const APITokenManager: Component = (props) => { type="text" value={nameInput()} onInput={(e) => setNameInput(e.currentTarget.value)} - placeholder="e.g. Docker pipeline" + placeholder="e.g. Container pipeline" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-500/40" />
diff --git a/frontend-modern/src/components/Settings/AgentStepSection.tsx b/frontend-modern/src/components/Settings/AgentStepSection.tsx deleted file mode 100644 index 0857ae2..0000000 --- a/frontend-modern/src/components/Settings/AgentStepSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Component, JSX } from 'solid-js'; -import { Show } from 'solid-js'; - -interface AgentStepSectionProps { - step: string; - title: string; - description?: string; - children: JSX.Element; -} - -export const AgentStepSection: Component = (props) => { - return ( -
-
-
- - {props.step.replace('Step ', '')} - -

- {props.title} -

-
- -

- {props.description} -

-
-
-
{props.children}
-
- ); -}; - -export default AgentStepSection; diff --git a/frontend-modern/src/components/Settings/BatchCredentialModal.tsx b/frontend-modern/src/components/Settings/BatchCredentialModal.tsx deleted file mode 100644 index 12cdfb2..0000000 --- a/frontend-modern/src/components/Settings/BatchCredentialModal.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { Component, Show, createSignal, For } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { showSuccess, showError } from '@/utils/toast'; -import { NodesAPI } from '@/api/nodes'; -import type { NodeConfig } from '@/types/nodes'; -import { SectionHeader } from '@/components/shared/SectionHeader'; -import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; - -interface DiscoveredServer { - ip: string; - port: number; - type: 'pve' | 'pbs'; - version: string; - hostname?: string; - release?: string; -} - -interface BatchCredentialModalProps { - isOpen: boolean; - onClose: () => void; - servers: DiscoveredServer[]; - onComplete: () => void; -} - -export const BatchCredentialModal: Component = (props) => { - const [authType, setAuthType] = createSignal<'password' | 'token'>('token'); - const [username, setUsername] = createSignal(''); - const [password, setPassword] = createSignal(''); - const [tokenName, setTokenName] = createSignal(''); - const [tokenValue, setTokenValue] = createSignal(''); - const [isAdding, setIsAdding] = createSignal(false); - const [progress, setProgress] = createSignal<{ - current: number; - total: number; - currentServer?: string; - }>({ current: 0, total: 0 }); - - const handleBatchAdd = async () => { - // Validate inputs - if (authType() === 'password') { - if (!username() || !password()) { - showError('Username and password are required'); - return; - } - } else { - if (!tokenName() || !tokenValue()) { - showError('Token ID and value are required'); - return; - } - } - - setIsAdding(true); - const total = props.servers.length; - setProgress({ current: 0, total }); - - let successCount = 0; - let failedServers: string[] = []; - - for (let i = 0; i < props.servers.length; i++) { - const server = props.servers[i]; - setProgress({ current: i + 1, total, currentServer: server.hostname || server.ip }); - - try { - const nodeData: NodeConfig = { - id: '', // Will be generated by backend - type: server.type, - name: server.hostname || server.ip, - host: `https://${server.ip}:${server.port}`, - verifySSL: false, - ...(authType() === 'password' - ? { user: username(), password: password() } - : { tokenName: tokenName(), tokenValue: tokenValue() }), - // Add default monitoring options based on type - ...(server.type === 'pve' - ? { - monitorVMs: true, - monitorContainers: true, - monitorStorage: true, - monitorBackups: true, - monitorPhysicalDisks: false, - } - : { - monitorDatastores: true, - monitorSyncJobs: true, - monitorVerifyJobs: true, - monitorPruneJobs: true, - monitorGarbageJobs: false, - }), - } as NodeConfig; - - await NodesAPI.addNode(nodeData); - successCount++; - } catch (error) { - console.error(`Failed to add ${server.hostname || server.ip}:`, error); - failedServers.push(server.hostname || server.ip); - } - } - - setIsAdding(false); - - if (successCount === total) { - showSuccess(`Successfully added all ${total} servers`); - props.onComplete(); - props.onClose(); - } else if (successCount > 0) { - showError(`Added ${successCount} of ${total} servers. Failed: ${failedServers.join(', ')}`); - props.onComplete(); - } else { - showError('Failed to add any servers. Check your credentials.'); - } - }; - - return ( - - -
-
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
- - - - -
- - {/* Body */} -
- {/* Server List */} -
-

- Servers to Add -

-
-
- - {(server) => ( - - - {server.hostname || server.ip} - - )} - -
-
-
- - {/* Shared Credentials */} -
-

- Shared Credentials - - (Same credentials will be used for all servers) - -

- - {/* Auth Type Selector */} -
-
- - -
-
- - {/* Token Auth Fields */} - -
-
- - setTokenName(e.currentTarget.value)} - placeholder="user@realm!tokenname" - disabled={isAdding()} - class={controlClass('font-mono')} - /> -

- Example: pulse-monitor@pam!pulse-token or pulse-monitor@pbs!pulse-token -

-
- -
- - setTokenValue(e.currentTarget.value)} - placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - disabled={isAdding()} - class={controlClass('font-mono')} - /> -
-
-
- - {/* Password Auth Fields */} - -
-
- - setUsername(e.currentTarget.value)} - placeholder="root@pam or admin@pbs" - disabled={isAdding()} - class={controlClass()} - /> -
- -
- - setPassword(e.currentTarget.value)} - placeholder="Password" - disabled={isAdding()} - class={controlClass()} - /> -
-
-
-
- - {/* Progress */} - -
-
- - - - -
-

- Adding servers... ({progress().current}/{progress().total}) -

- -

- Currently adding: {progress().currentServer} -

-
-
-
-
-
-
-
-
-
- - {/* Footer */} -
- - -
-
-
-
- - - ); -}; diff --git a/frontend-modern/src/components/Settings/CommandBuilder.tsx b/frontend-modern/src/components/Settings/CommandBuilder.tsx deleted file mode 100644 index 990bfef..0000000 --- a/frontend-modern/src/components/Settings/CommandBuilder.tsx +++ /dev/null @@ -1,623 +0,0 @@ -import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js'; -import { apiFetch } from '@/utils/apiClient'; -import { SecurityAPI, type APITokenRecord } from '@/api/security'; -import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; -import { setStoredAPIToken } from '@/utils/tokenStorage'; -import { setApiToken as setApiClientToken } from '@/utils/apiClient'; - -interface CommandBuilderProps { - command: string; - placeholder: string; - storedToken?: string | null; - currentTokenHint?: string; // Masked token preview (e.g., "abc12***...xyz89") - onTokenChange?: (token: string) => void; - onTokenGenerated?: (token: string, record: APITokenRecord) => void; - requiresToken: boolean; - hasExistingToken?: boolean; - canManageTokens?: boolean; -} - -export const CommandBuilder: Component = (props) => { - const [tokenInput, setTokenInput] = createSignal(''); - const [showToken, setShowToken] = createSignal(false); - const [isValidating, setIsValidating] = createSignal(false); - const [validationResult, setValidationResult] = createSignal<'valid' | 'invalid' | null>(null); - const [copied, setCopied] = createSignal(false); - - // Token generation/revocation state - const [showGenerateModal, setShowGenerateModal] = createSignal(false); - const [isGenerating, setIsGenerating] = createSignal(false); - const [tokenLabel, setTokenLabel] = createSignal('Docker agent token'); - const [latestGeneratedToken, setLatestGeneratedToken] = createSignal(null); - const [latestGeneratedRecord, setLatestGeneratedRecord] = createSignal(null); - const tokenRevealState = useTokenRevealState(); - - const defaultTokenLabel = () => `Docker agent token ${new Date().toISOString().slice(0, 10)}`; - const canManageTokens = () => props.canManageTokens !== false; - const openGenerateModal = () => { - if (!canManageTokens()) return; - setTokenLabel(defaultTokenLabel()); - setShowGenerateModal(true); - }; - - const isRevealActiveForLatestToken = () => { - const token = latestGeneratedToken(); - const active = tokenRevealState(); - if (!token || !active) return false; - return active.token === token; - }; - - const reopenLatestTokenDialog = () => { - const token = latestGeneratedToken(); - const record = latestGeneratedRecord(); - if (!token || !record) return; - showTokenReveal({ - token, - record, - source: 'docker-command', - note: 'Copy this token now. Close the dialog once you have stored it securely.', - }); - }; - - // Initialize with stored token if available - createEffect(() => { - if (props.storedToken && tokenInput() === '') { - setTokenInput(props.storedToken); - // Don't auto-validate stored tokens to avoid unnecessary API calls - } - }); - - // Computed command with token substitution - const finalCommand = createMemo(() => { - const token = tokenInput().trim(); - if (!props.requiresToken) { - return props.command; - } - if (token) { - return props.command.replace(props.placeholder, token); - } - return props.command; - }); - - // Check if placeholder is still present - const hasPlaceholder = createMemo(() => { - return props.requiresToken && finalCommand().includes(props.placeholder); - }); - - // Determine state (warning, success, or neutral) - const commandState = createMemo<'warning' | 'success' | 'neutral'>(() => { - if (!props.requiresToken) return 'success'; // No token needed, always ready - if (hasPlaceholder()) return 'warning'; // Placeholder still present - if (validationResult() === 'valid') return 'success'; // Token validated - if (tokenInput().trim().length > 0) return 'neutral'; // Token entered but not validated - return 'warning'; // No token entered - }); - - // Validate token via API - const validateToken = async () => { - const token = tokenInput().trim(); - if (!token || !props.requiresToken) return; - - setIsValidating(true); - setValidationResult(null); - - try { - const response = await apiFetch('/api/security/validate-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - - // Handle different HTTP status codes appropriately - if (!response.ok) { - if (response.status === 401) { - window.showToast('error', 'Authentication required - please log in'); - return; - } - if (response.status === 403) { - window.showToast('error', 'Permission denied - admin access required'); - return; - } - if (response.status === 429) { - window.showToast('error', 'Rate limit exceeded - please try again later'); - return; - } - // Other server errors - window.showToast('error', `Server error (${response.status}) - please try again`); - return; - } - - const data = await response.json(); - setValidationResult(data.valid ? 'valid' : 'invalid'); - - if (data.valid) { - window.showToast('success', 'Token is valid ✓'); - } else { - window.showToast('error', 'Token is invalid'); - } - } catch (error) { - // Network or parsing error - console.error('Token validation failed:', error); - window.showToast('error', 'Network error - failed to validate token'); - } finally { - setIsValidating(false); - } - }; - - // Copy command to clipboard - const copyCommand = async () => { - // Warn if placeholder is still present - if (hasPlaceholder()) { - window.showToast( - 'warning', - `Please replace ${props.placeholder} with your API token before running the command`, - ); - return; - } - - const command = finalCommand(); - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(command); - } else { - // Fallback - const textarea = document.createElement('textarea'); - textarea.value = command; - textarea.style.position = 'fixed'; - textarea.style.left = '-999999px'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } - - setCopied(true); - setTimeout(() => setCopied(false), 2000); - window.showToast('success', 'Command copied to clipboard'); - } catch (error) { - console.error('Failed to copy:', error); - window.showToast('error', 'Failed to copy to clipboard'); - } - }; - - // Handle token input change - const handleTokenChange = (value: string) => { - setTokenInput(value); - setValidationResult(null); // Clear validation when token changes - if (props.onTokenChange) { - props.onTokenChange(value); - } - }; - - // Generate new token - const generateNewToken = async () => { - if (!canManageTokens()) return; - if (isGenerating()) return; - setIsGenerating(true); - - try { - const desiredName = tokenLabel().trim() || undefined; - const { token: newToken, record } = await SecurityAPI.createToken(desiredName); - - setShowGenerateModal(false); - setLatestGeneratedToken(newToken); - setLatestGeneratedRecord(record); - - reopenLatestTokenDialog(); - // Auto-populate the command builder - setTokenInput(newToken); - if (props.onTokenGenerated) { - props.onTokenGenerated(newToken, record); - } - - setStoredAPIToken(newToken); - setApiClientToken(newToken); - - window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.'); - } catch (error) { - console.error('Token generation failed:', error); - window.showToast('error', error instanceof Error ? error.message : 'Failed to generate token'); - } finally { - setIsGenerating(false); - } - }; - - // Use existing token - const useExistingToken = () => { - if (props.storedToken) { - setTokenInput(props.storedToken); - window.showToast('success', 'Using stored token'); - } - }; - - // Copy existing token to clipboard - const copyExistingToken = async () => { - if (!props.storedToken) return; - - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(props.storedToken); - } else { - const textarea = document.createElement('textarea'); - textarea.value = props.storedToken; - textarea.style.position = 'fixed'; - textarea.style.left = '-999999px'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } - - window.showToast('success', 'Token copied to clipboard'); - } catch (error) { - console.error('Failed to copy token:', error); - window.showToast('error', 'Failed to copy token'); - } - }; - - // Visual styling based on state - const borderColor = createMemo(() => { - switch (commandState()) { - case 'warning': - return 'border-yellow-500 dark:border-yellow-600'; - case 'success': - return 'border-green-500 dark:border-green-600'; - default: - return 'border-gray-300 dark:border-gray-600'; - } - }); - - const bgColor = createMemo(() => { - switch (commandState()) { - case 'warning': - return 'bg-yellow-50 dark:bg-yellow-900/10'; - case 'success': - return 'bg-green-50 dark:bg-green-900/10'; - default: - return 'bg-gray-50 dark:bg-gray-900'; - } - }); - - const indicatorIcon = createMemo(() => { - switch (commandState()) { - case 'warning': - return ( - - - - ); - case 'success': - return ( - - - - ); - default: - return null; - } - }); - - return ( -
- {/* Token Status Section */} - - -
-
-
- - - - No API token configured -
-

Generate a token to secure your Docker agents.

-
- - - -
- -

- Sign in with an administrator account to create tokens from the browser. -

-
-
- } - > - {/* Token exists - show status and actions */} -
-
-
-
- - - - API token configured -
- - - {props.currentTokenHint} - - -
-
- - - - - -
-
-

- Manage or revoke tokens from the table above whenever a credential is no longer needed. -

-
- - - - {/* Security explanation */} - -
- - - -
- Security Note:{' '} - - Tokens are not auto-inserted to prevent accidental exposure. Paste your token below to build the command. - -
-
-
- - {/* Token input field */} - -
- -
-
- handleTokenChange(e.currentTarget.value)} - placeholder="Paste your API token here" - class="w-full px-3 py-2 pr-10 text-sm font-mono border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> - -
- -
- {/* Validation result */} - -
- {validationResult() === 'valid' ? '✓ Token is valid' : '✗ Token is invalid'} -
-
-
-
- - {/* Command preview */} -
-
-
- Command Preview - {indicatorIcon()} -
- -
-
- - {finalCommand()} - -
-
- - {/* Status message */} - -
- - - - Paste your API token above to enable the copy button -
-
- -
- - - - Ready to copy! Your command is complete. -
-
- - {/* Generate Token Confirmation Modal */} - -
-
-

Generate API Token

-
-

- Create a dedicated token for this host or automation workflow. Tokens remain active until you revoke them from the API tokens list. -

-
- - setTokenLabel(event.currentTarget.value)} - class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder={defaultTokenLabel()} - /> -
-
-

- Tip: Issue one token per host so you can revoke compromised credentials without affecting other agents. -

-
-
-
- - -
-
-
-
- - {/* New Token Display Modal */} - -
-
-
- - - -
-
-

- New token ready -

-

- Reopen the secure dialog if you still need to copy the value before you leave this page. The command above already includes it. -

- -
- Label{' '} - - {latestGeneratedRecord()?.name || 'Untitled token'} - - - {' '}· Hint{' '} - - {latestGeneratedRecord()?.prefix}…{latestGeneratedRecord()?.suffix} - - -
-
-
-
-
- - -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Settings/DiscoveryModal.tsx b/frontend-modern/src/components/Settings/DiscoveryModal.tsx deleted file mode 100644 index aa5ca70..0000000 --- a/frontend-modern/src/components/Settings/DiscoveryModal.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { Component, Show, createSignal, For, createEffect, on } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { showSuccess, showError } from '@/utils/toast'; -import { SectionHeader } from '@/components/shared/SectionHeader'; -import Loader from 'lucide-solid/icons/loader'; - -interface DiscoveredServer { - ip: string; - port: number; - type: 'pve' | 'pbs' | 'pmg'; - version: string; - hostname?: string; - release?: string; -} - -interface DiscoveryResult { - servers: DiscoveredServer[]; - errors?: string[]; -} - -interface DiscoveryModalProps { - isOpen: boolean; - onClose: () => void; - onAddServers: (servers: DiscoveredServer[]) => void; -} - -export const DiscoveryModal: Component = (props) => { - const [isScanning, setIsScanning] = createSignal(false); - const [subnet, setSubnet] = createSignal('auto'); - const [discoveryResult, setDiscoveryResult] = createSignal(null); - const [hasScanned, setHasScanned] = createSignal(false); - - // Load cached results when modal opens and reset when it closes - createEffect(on( - () => props.isOpen, - (isOpen) => { - if (isOpen && !hasScanned()) { - setHasScanned(true); - loadCachedResults(); - } else if (!isOpen) { - setHasScanned(false); - // Keep cached results, don't clear them - } - } - )); - - // Listen for real-time WebSocket updates when modal is open and scanning - createEffect(on( - () => props.isOpen, - (isOpen) => { - if (!isOpen) return; - - const handleWsMessage = (event: MessageEvent) => { - try { - const message = JSON.parse(event.data); - - // Handle discovery server found (real-time updates) - if (message.type === 'discovery_server_found' && message.data?.server) { - const server: DiscoveredServer = message.data.server; - - // Add server to results immediately - setDiscoveryResult((prev) => { - if (!prev) { - return { servers: [server], errors: [] }; - } - - // Check if server already exists (by IP and port) - const exists = prev.servers.some( - (s) => s.ip === server.ip && s.port === server.port - ); - - if (!exists) { - return { - ...prev, - servers: [...prev.servers, server], - }; - } - return prev; - }); - } - - // Handle discovery started - if (message.type === 'discovery_started') { - setIsScanning(true); - } - - // Handle discovery complete - if (message.type === 'discovery_complete') { - setIsScanning(false); - } - } catch (error) { - console.error('Error parsing WebSocket message:', error); - } - }; - - // Get WebSocket from global state - const ws = (window as any).__pulseWs; - if (ws && ws.readyState === WebSocket.OPEN) { - ws.addEventListener('message', handleWsMessage); - - return () => { - ws.removeEventListener('message', handleWsMessage); - }; - } - } - )); - - const loadCachedResults = async () => { - try { - const { apiFetch } = await import('@/utils/apiClient'); - // Fetch cached results with GET request - const response = await apiFetch('/api/discover', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.ok) { - const data = await response.json(); - - // If we have cached results, show them immediately - if (data.servers && data.servers.length > 0) { - setDiscoveryResult({ - servers: data.servers, - errors: data.errors || [], - }); - } else { - // No cached results, start a background scan - handleScan(); - } - } else { - // Fallback to scanning - handleScan(); - } - } catch (error) { - console.error('Failed to load cached discovery results:', error); - // Fallback to scanning - handleScan(); - } - }; - - const handleRefresh = async () => { - // First try to get cached results immediately - try { - const { apiFetch } = await import('@/utils/apiClient'); - const response = await apiFetch('/api/discover', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.ok) { - const data = await response.json(); - - // If we have cached results, show them immediately - if (data.servers && data.servers.length > 0) { - setDiscoveryResult({ - servers: data.servers, - errors: data.errors || [], - }); - showSuccess(`Showing ${data.servers.length} cached server(s)`); - return; // Don't start a new scan - } - } - } catch (error) { - console.error('Failed to load cached results:', error); - } - - // If no cached results or error, start a new scan - handleScan(); - }; - - const handleScan = async () => { - setIsScanning(true); - setDiscoveryResult(null); - - // Set a timeout for the scan (30 seconds) - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 30000); - - try { - const { apiFetch } = await import('@/utils/apiClient'); - const response = await apiFetch('/api/discover', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ subnet: subnet() }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(await response.text()); - } - - const result: DiscoveryResult = await response.json(); - setDiscoveryResult(result); - - if (result.servers.length === 0) { - showError('No Proxmox servers found on the network'); - } else { - showSuccess(`Found ${result.servers.length} server(s)`); - } - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - showError('Scan timeout - try a smaller subnet range'); - } else { - showError(`Discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } finally { - setIsScanning(false); - } - }; - - const handleAddServer = (server: DiscoveredServer) => { - props.onAddServers([server]); - }; - - const getServerIcon = (type: string) => { - if (type === 'pve') { - return ( - - - - - - - - - - - - - ); - } - if (type === 'pmg') { - return ( - - - - - ); - } - return ( - - - - - ); - }; - - return ( - - -
-
- {/* Backdrop */} -
- - {/* Modal */} -
- {/* Header */} -
- - -
- - {/* Body */} -
- {/* Quick Actions Bar */} -
-
-

- - Scanning Network... - -

- - - {discoveryResult()!.servers.length} found - - -
- -
- {/* Subnet selector */} - - - {/* Refresh button */} - -
-
- - {/* Show loading message when scanning with no results yet */} - -
- -

Scanning network...

-

Servers will appear here as they're discovered

-
-
- - {/* Discovery Results - show even while scanning */} - -
- {/* Server Cards */} - 0} - fallback={ -
- - - - - -

No Proxmox servers found on this network

-

- Try a different subnet or check if servers are online -

-
- } - > -
- - {(server) => ( -
handleAddServer(server)} - > -
-
- {/* Server Icon */} -
- {getServerIcon(server.type)} -
- - {/* Server Details */} -
-
- {server.hostname || server.ip} -
-
- {server.ip}:{server.port} -
-
- - {server.type === 'pve' - ? 'Proxmox VE' - : server.type === 'pmg' - ? 'PMG' - : 'PBS'} - - - - v{server.version} - - -
-
-
- - {/* Add Arrow */} -
- - - - -
-
-
- )} -
-
-
- - {/* Errors */} - 0}> -
-
- Discovery Warnings -
-
    - - {(error) =>
  • • {error}
  • } -
    -
-
-
-
-
-
- - {/* Footer */} -
- -
-
-
-
- - - ); -}; diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 95cde0c..fb1b06e 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -9,6 +9,11 @@ import type { SecurityStatus } from '@/types/config'; import type { DockerHost } from '@/types/api'; import type { APITokenRecord } from '@/api/security'; import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; +import { resolveHostRuntime } from '@/components/Docker/runtimeDisplay'; +import { copyToClipboard } from '@/utils/clipboard'; +import { getPulseBaseUrl } from '@/utils/url'; +import { logger } from '@/utils/logger'; + export const DockerAgents: Component = () => { const { state } = useWebSocket(); @@ -66,15 +71,7 @@ export const DockerAgents: Component = () => { const [commandQueuedTime, setCommandQueuedTime] = createSignal(null); const [elapsedSeconds, setElapsedSeconds] = createSignal(0); - const pulseUrl = () => { - if (typeof window !== 'undefined') { - const protocol = window.location.protocol; - const hostname = window.location.hostname; - const port = window.location.port; - return `${protocol}//${hostname}${port ? `:${port}` : ''}`; - } - return 'http://localhost:7655'; - }; + const pulseUrl = () => getPulseBaseUrl(); const TOKEN_PLACEHOLDER = ''; @@ -88,6 +85,12 @@ export const DockerAgents: Component = () => { return host.displayName || host.hostname || host.id; }; + const describeRuntime = (host: DockerHost) => { + const runtimeInfo = resolveHostRuntime(host); + const version = host.runtimeVersion || host.dockerVersion || ''; + return version ? `${runtimeInfo.label} ${version}` : runtimeInfo.label; + }; + const modalDisplayName = () => { const host = hostToRemove(); return host ? getDisplayName(host) : ''; @@ -149,7 +152,7 @@ const modalCommandTimedOut = createMemo(() => { const modalLastHeartbeat = createMemo(() => { const host = hostToRemove(); - return host?.lastReportTime ? formatRelativeTime(new Date(host.lastReportTime)) : null; + return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null; }); const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall)); @@ -295,7 +298,7 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS } catch (err) { if (!hasLoggedSecurityStatusError) { hasLoggedSecurityStatusError = true; - console.error('Failed to load security status', err); + logger.error('Failed to load security status', err); } } }; @@ -304,43 +307,11 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS const showInstallCommand = () => !requiresToken() || Boolean(currentToken()); - // Find the token record that matches the currently stored token - const copyToClipboard = async (text: string): Promise => { - try { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - - if (typeof document === 'undefined') { - return false; - } - - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-999999px'; - textarea.style.top = '-999999px'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - try { - return document.execCommand('copy'); - } finally { - document.body.removeChild(textarea); - } - } catch (err) { - console.error('Failed to copy to clipboard', err); - return false; - } - }; - const handleGenerateToken = async () => { if (isGeneratingToken()) return; setIsGeneratingToken(true); try { - const name = tokenName().trim() || `Docker host ${new Date().toISOString().slice(0, 10)}`; + const name = tokenName().trim() || `Container host ${new Date().toISOString().slice(0, 10)}`; const { token, record } = await SecurityAPI.createToken(name, [DOCKER_REPORT_SCOPE]); setCurrentToken(token); @@ -348,8 +319,9 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS setTokenName(''); notificationStore.success('Token generated and inserted into the command below.', 4000); } catch (err) { - console.error('Failed to generate API token', err); - notificationStore.error('Failed to generate API token. Confirm you are signed in as an administrator.', 6000); + logger.error('Failed to generate API token', err); + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + notificationStore.error(`Failed to generate API token: ${errorMsg}`, 8000); } finally { setIsGeneratingToken(false); } @@ -366,15 +338,14 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS // Always return command template with placeholder; the UI replaces it with the selected token. const getInstallCommandTemplate = () => { const url = pulseUrl(); - if (!requiresToken()) { - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --url ${url} --token disabled`; - } - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --url ${url} --token ${TOKEN_PLACEHOLDER}`; + const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; + const tokenSegment = `--token '${tokenValue}'`; + return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --url '${url}' ${tokenSegment} && rm -f /tmp/pulse-install-docker-agent.sh`; }; const getUninstallCommand = () => { const url = pulseUrl(); - return `curl -fsSL ${url}/install-docker-agent.sh | bash -s -- --uninstall`; + return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && rm -f /tmp/pulse-install-docker-agent.sh`; }; const getSystemdService = () => { @@ -405,8 +376,11 @@ WantedBy=multi-user.target`; await MonitoringAPI.allowDockerHostReenroll(hostId); notificationStore.success(`Allowed ${label} to report again`, 4000); } catch (error) { - console.error('Failed to allow Docker host re-enroll', error); - const message = error instanceof Error ? error.message : 'Failed to clear the removal block. Confirm your account has docker:manage access.'; + logger.error('Failed to allow host re-enroll', error); + const message = + error instanceof Error + ? error.message + : 'Failed to clear the removal block. Confirm your account has docker:manage access.'; notificationStore.error(message, 8000); } }; @@ -454,7 +428,7 @@ WantedBy=multi-user.target`; notificationStore.success(`Stop command sent to ${displayName}`, 3500); setRemoveActionLoading('awaitingCommand'); } catch (error) { - console.error('Failed to queue Docker host stop command', error); + logger.error('Failed to queue host stop command', error); const message = error instanceof Error ? error.message : 'Failed to send stop command'; notificationStore.error(message, 8000); setRemoveActionLoading(null); @@ -473,11 +447,11 @@ WantedBy=multi-user.target`; try { await MonitoringAPI.deleteDockerHost(host.id, { hide: true }); - notificationStore.success(`Hidden Docker host ${displayName}`, 3500); + notificationStore.success(`Hidden host ${displayName}`, 3500); closeRemoveModal(); } catch (error) { - console.error('Failed to hide Docker host', error); - const message = error instanceof Error ? error.message : 'Failed to hide Docker host'; + logger.error('Failed to hide host', error); + const message = error instanceof Error ? error.message : 'Failed to hide host'; notificationStore.error(message, 8000); } finally { setRemovingHostId(null); @@ -495,11 +469,11 @@ WantedBy=multi-user.target`; try { await MonitoringAPI.deleteDockerHost(host.id, { force: true }); - notificationStore.success(`Removed Docker host ${displayName}`, 3500); + notificationStore.success(`Removed host ${displayName}`, 3500); closeRemoveModal(); } catch (error) { - console.error('Failed to remove Docker host', error); - const message = error instanceof Error ? error.message : 'Failed to remove Docker host'; + logger.error('Failed to remove host', error); + const message = error instanceof Error ? error.message : 'Failed to remove host'; notificationStore.error(message, 8000); } finally { setRemovingHostId(null); @@ -514,13 +488,13 @@ WantedBy=multi-user.target`; try { await MonitoringAPI.deleteDockerHost(hostId, { force: true }); - notificationStore.success(`Removed Docker host ${displayName}`, 3500); + notificationStore.success(`Removed host ${displayName}`, 3500); if (hostToRemoveId() === hostId) { closeRemoveModal(); } } catch (error) { - console.error('Failed to remove Docker host', error); - const message = error instanceof Error ? error.message : 'Failed to remove Docker host'; + logger.error('Failed to remove host', error); + const message = error instanceof Error ? error.message : 'Failed to remove host'; notificationStore.error(message, 8000); } finally { setRemovingHostId(null); @@ -534,10 +508,10 @@ WantedBy=multi-user.target`; try { await MonitoringAPI.unhideDockerHost(hostId); - notificationStore.success(`Unhidden Docker host ${displayName}`, 3500); + notificationStore.success(`Unhidden host ${displayName}`, 3500); } catch (error) { - console.error('Failed to unhide Docker host', error); - const message = error instanceof Error ? error.message : 'Failed to unhide Docker host'; + logger.error('Failed to unhide host', error); + const message = error instanceof Error ? error.message : 'Failed to unhide host'; notificationStore.error(message, 8000); } finally { setRemovingHostId(null); @@ -552,7 +526,7 @@ WantedBy=multi-user.target`; class="space-y-4 border border-amber-300 bg-amber-50 text-amber-900 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100" >
-

Recently removed Docker hosts

+

Recently removed container hosts

Pulse is currently blocking these hosts because they were explicitly removed. Allow them to re-enroll or copy the command below and run it with a token that includes the docker:manage scope. @@ -603,10 +577,10 @@ WantedBy=multi-user.target`; -

-

Add a Docker host

+
+

Enroll a container runtime

- Run this command as root on your Docker host to start monitoring. + Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime.

@@ -678,7 +652,7 @@ WantedBy=multi-user.target`; {getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}

- Run as root on your Docker host. The installer downloads the agent, creates a systemd service, and starts reporting automatically. + The installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically.

@@ -773,13 +747,13 @@ WantedBy=multi-user.target`; - {/* Remove Docker Host Modal */} + {/* Remove Container Host Modal */}

- Remove Docker host "{modalDisplayName()}" + Remove container host "{modalDisplayName()}"

Pulse guides you through uninstalling the agent and safely cleaning up the host entry. @@ -1161,7 +1135,7 @@ WantedBy=multi-user.target`;

- {/* Active Docker Hosts */} + {/* Active container hosts */}
{/* Pending hosts banner */} @@ -1248,21 +1222,21 @@ WantedBy=multi-user.target`;

- Reporting Docker hosts ({dockerHosts().length}) + Reporting container hosts ({dockerHosts().length})

- Use this list to enroll or retire agents. For live health and troubleshooting, open the Docker monitoring view. + Use this list to enroll or retire agents. For live health and troubleshooting, open the container monitoring view.

- No Docker agents are currently reporting. + No container agents are currently reporting.

Click "Show deployment instructions" above to get started. @@ -1305,7 +1279,7 @@ WantedBy=multi-user.target`;

- + @@ -1317,6 +1291,7 @@ WantedBy=multi-user.target`; const displayName = getDisplayName(host); const commandStatus = host.command?.status ?? null; const removalStatusInfo = getRemovalStatusInfo(host); + const runtimeInfo = resolveHostRuntime(host); const commandInProgress = commandStatus === 'queued' || commandStatus === 'dispatched' || @@ -1339,6 +1314,14 @@ WantedBy=multi-user.target`;
+ Resource + Type + Image / Stack + Status + CPU + Memory + Disk Tasks / Restarts + Updated / Uptime
Host StatusAgent & DockerAgent & runtime Last Seen Actions
{displayName}
{host.hostname}
+
+ + {runtimeInfo.label} + +
{host.os} @@ -1396,16 +1379,19 @@ WantedBy=multi-user.target`;
- {(info) => ( -
- {info.label} -
- )} + {(info) => { + const details = info(); + return ( +
+ {details.label} +
+ ); + }}
- {host.dockerVersion ? `Docker ${host.dockerVersion}` : 'Docker version unavailable'} + {describeRuntime(host)}
Agent {host.agentVersion ? `v${host.agentVersion}` : 'not reporting'} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index 4d8e3e7..dd33f26 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -4,19 +4,18 @@ import { useWebSocket } from '@/App'; import type { Host, HostLookupResponse } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { formatBytes, formatRelativeTime, formatUptime, formatAbsoluteTime } from '@/utils/format'; +import { copyToClipboard } from '@/utils/clipboard'; +import { getPulseBaseUrl } from '@/utils/url'; import { notificationStore } from '@/stores/notifications'; import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import type { SecurityStatus } from '@/types/config'; import type { APITokenRecord } from '@/api/security'; import { SecurityAPI } from '@/api/security'; import { MonitoringAPI } from '@/api/monitoring'; +import { logger } from '@/utils/logger'; const TOKEN_PLACEHOLDER = ''; -const pulseUrl = () => { - if (typeof window === 'undefined') return 'http://localhost:7655'; - const { protocol, hostname, port } = window.location; - return `${protocol}//${hostname}${port ? `:${port}` : ''}`; -}; +const pulseUrl = () => getPulseBaseUrl(); const buildDefaultTokenName = () => { const now = new Date(); @@ -283,7 +282,7 @@ export const HostAgents: Component = () => { notificationStore.success(`Host "${displayName}" removed`, 4000); closeRemoveModal(); } catch (error) { - console.error('Failed to remove host agent', error); + logger.error('Failed to remove host agent', error); const message = error instanceof Error ? error.message : 'Failed to remove host. Please try again.'; notificationStore.error(message, 6000); } finally { @@ -402,7 +401,7 @@ export const HostAgents: Component = () => { } catch (err) { if (!hasLoggedSecurityStatusError) { hasLoggedSecurityStatusError = true; - console.error('Failed to load security status', err); + logger.error('Failed to load security status', err); } } }; @@ -453,41 +452,13 @@ export const HostAgents: Component = () => { setConfirmedNoToken(false); notificationStore.success('Token generated and inserted into the command below.', 4000); } catch (err) { - console.error('Failed to generate host agent token', err); + logger.error('Failed to generate host agent token', err); notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000); } finally { setIsGeneratingToken(false); } }; - const copyToClipboard = async (text: string): Promise => { - try { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - if (typeof document === 'undefined') { - return false; - } - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-999999px'; - textarea.style.top = '-999999px'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - try { - return document.execCommand('copy'); - } finally { - document.body.removeChild(textarea); - } - } catch (err) { - console.error('Failed to copy to clipboard', err); - return false; - } - }; - const resolvedToken = () => { if (requiresToken()) { return currentToken() || TOKEN_PLACEHOLDER; diff --git a/frontend-modern/src/components/Settings/HostsSectionNav.tsx b/frontend-modern/src/components/Settings/HostsSectionNav.tsx deleted file mode 100644 index d14bceb..0000000 --- a/frontend-modern/src/components/Settings/HostsSectionNav.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { Component } from 'solid-js'; -import { For } from 'solid-js'; -import SquareTerminal from 'lucide-solid/icons/square-terminal'; - -type HostsSection = 'linux' | 'macos' | 'windows'; - -interface HostsSectionNavProps { - current: HostsSection; - onSelect: (section: HostsSection) => void; - class?: string; -} - -const AppleIcon = () => ( - - - -); - -const WindowsIcon = () => ( - - - -); - -const allSections: Array<{ - id: HostsSection; - label: string; - icon: Component<{ size?: number; 'stroke-width'?: number }>; -}> = [ - { - id: 'linux', - label: 'Linux', - icon: SquareTerminal, - }, - { - id: 'macos', - label: 'macOS', - icon: AppleIcon, - }, - { - id: 'windows', - label: 'Windows', - icon: WindowsIcon, - }, -]; - -export const HostsSectionNav: Component = (props) => { - const baseClasses = - 'inline-flex items-center gap-2 px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900'; - - return ( -
- - {(section) => { - const isActive = () => section.id === props.current; - const classes = () => isActive() - ? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400` - : `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`; - - const Icon = section.icon; - - return ( - - ); - }} - -
- ); -}; diff --git a/frontend-modern/src/components/Settings/NodeModal.tsx b/frontend-modern/src/components/Settings/NodeModal.tsx index 666fecc..552a33b 100644 --- a/frontend-modern/src/components/Settings/NodeModal.tsx +++ b/frontend-modern/src/components/Settings/NodeModal.tsx @@ -4,6 +4,7 @@ import type { NodeConfig } from '@/types/nodes'; import type { SecurityStatus } from '@/types/config'; import { copyToClipboard } from '@/utils/clipboard'; import { showSuccess, showError } from '@/utils/toast'; +import { getPulseBaseUrl } from '@/utils/url'; import { NodesAPI } from '@/api/nodes'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { @@ -13,6 +14,7 @@ import { labelClass, formCheckbox, } from '@/components/shared/Form'; +import { logger } from '@/utils/logger'; interface NodeModalProps { isOpen: boolean; @@ -298,7 +300,7 @@ export const NodeModal: Component = (props) => { message: result.message || 'Connection successful', }); } catch (error) { - console.error('Test existing node error:', error); + logger.error('Test existing node error:', error); let errorMessage = 'Connection failed'; if (error instanceof Error) { // Remove "API request failed: XXX " prefix if present @@ -360,7 +362,7 @@ export const NodeModal: Component = (props) => { isCluster: result.isCluster, }); } catch (error) { - console.error('Test connection error:', error); + logger.error('Test connection error:', error); let errorMessage = 'Connection failed'; if (error instanceof Error) { // Remove "API request failed: XXX " prefix if present @@ -651,16 +653,18 @@ export const NodeModal: Component = (props) => {
- {/* Docker Tab */} - -
- -
-
- {/* Docker Platform Tab */} @@ -3076,13 +3078,6 @@ const Settings: Component = (props) => { - {/* Host Agents */} - -
- -
-
- {/* System General Tab */}
@@ -4265,7 +4260,7 @@ const Settings: Component = (props) => {
- +
= (props) => {
Server Port: - {window.location.port || - (window.location.protocol === 'https:' ? '443' : '80')} + {getPulsePort()}
@@ -6163,7 +6157,7 @@ const Settings: Component = (props) => { }, websocket: { connected: connected(), - url: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`, + url: getPulseWebSocketUrl(), }, // Include backend diagnostics if available backendDiagnostics: diagnosticsData() || null, @@ -6888,7 +6882,6 @@ const Settings: Component = (props) => { onClick={() => { if (apiTokenInput()) { const tokenValue = apiTokenInput()!; - setStoredAPIToken(tokenValue); setApiClientToken(tokenValue); const source = apiTokenModalSource(); setShowApiTokenModal(false); diff --git a/frontend-modern/src/components/Settings/SettingsSectionNav.tsx b/frontend-modern/src/components/Settings/SettingsSectionNav.tsx index 461b15d..ea1c627 100644 --- a/frontend-modern/src/components/Settings/SettingsSectionNav.tsx +++ b/frontend-modern/src/components/Settings/SettingsSectionNav.tsx @@ -4,7 +4,7 @@ import Server from 'lucide-solid/icons/server'; import HardDrive from 'lucide-solid/icons/hard-drive'; import Mail from 'lucide-solid/icons/mail'; -type SettingsSection = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host'; +type SettingsSection = 'pve' | 'pbs' | 'pmg'; interface SettingsSectionNavProps { current: SettingsSection; diff --git a/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx b/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx deleted file mode 100644 index 101b188..0000000 --- a/frontend-modern/src/components/Settings/UpdateHistoryPanel.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { createSignal, onMount, For, Show, createMemo } from 'solid-js'; -import { UpdatesAPI, type UpdateHistoryEntry } from '@/api/updates'; -import { Card } from '@/components/shared/Card'; -import { SectionHeader } from '@/components/shared/SectionHeader'; - -export function UpdateHistoryPanel() { - const [history, setHistory] = createSignal([]); - const [loading, setLoading] = createSignal(true); - const [error, setError] = createSignal(null); - const [filterStatus, setFilterStatus] = createSignal('all'); - - const filteredHistory = createMemo(() => { - const filter = filterStatus(); - if (filter === 'all') return history(); - return history().filter((entry) => entry.status === filter); - }); - - const loadHistory = async () => { - setLoading(true); - setError(null); - try { - const data = await UpdatesAPI.getUpdateHistory(50); // Get last 50 updates - setHistory(data); - } catch (err) { - console.error('Failed to load update history:', err); - setError('Failed to load update history'); - } finally { - setLoading(false); - } - }; - - onMount(() => { - loadHistory(); - }); - - const getStatusBadge = (status: string) => { - switch (status) { - case 'success': - return ( - - Success - - ); - case 'failed': - return ( - - Failed - - ); - case 'in_progress': - return ( - - In Progress - - ); - case 'rolled_back': - return ( - - Rolled Back - - ); - case 'cancelled': - return ( - - Cancelled - - ); - default: - return {status}; - } - }; - - const formatDate = (timestamp: string) => { - const date = new Date(timestamp); - return date.toLocaleString(); - }; - - const formatDuration = (durationMs: number) => { - if (durationMs === 0) return '-'; - const seconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(seconds / 60); - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; - }; - - return ( -
- - - -
- {/* Filter Controls */} -
- - -
- - {/* Loading State */} - -
- Loading update history... -
-
- - {/* Error State */} - -
-
{error()}
- -
-
- - {/* Empty State */} - -
- No update history available -
-
- - {/* History Table */} - 0}> -
- - - - - - - - - - - - - - {(entry) => ( - - - - - - - - - )} - - -
- Timestamp - - Action - - Version - - Status - - Duration - - Deployment -
- {formatDate(entry.timestamp)} - - {entry.action} - - {entry.version_from} → {entry.version_to} - - {getStatusBadge(entry.status)} - - {formatDuration(entry.duration_ms)} - - {entry.deployment_type} -
-
- - {/* Details Section - Could be expanded with more info */} -
- Showing {filteredHistory().length} {filteredHistory().length === 1 ? 'entry' : 'entries'} -
-
-
-
-
- ); -} diff --git a/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx index 4d8e95f..b1cd5ed 100644 --- a/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx @@ -108,11 +108,11 @@ describe('DockerAgents removed hosts', () => { allowDockerHostReenrollMock.mockResolvedValue(undefined); const clipboardSpy = vi.fn().mockResolvedValue(undefined); - vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as Navigator); + vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); setupComponent([], [createRemovedHost()]); - expect(screen.getByText('Recently removed Docker hosts')).toBeInTheDocument(); + expect(screen.getByText('Recently removed container hosts')).toBeInTheDocument(); const allowButton = screen.getByRole('button', { name: 'Allow re-enroll' }); fireEvent.click(allowButton); @@ -149,7 +149,7 @@ describe('DockerAgents removed hosts', () => { const removeButton = screen.getByRole('button', { name: 'Remove' }); fireEvent.click(removeButton); - await screen.findByText('Remove Docker host "Host One"'); + await screen.findByText('Remove container host "Host One"'); expect(screen.getByText('acknowledged')).toBeInTheDocument(); const progressHeading = screen.getByText('Progress'); @@ -202,7 +202,7 @@ describe('DockerAgents removed hosts', () => { const removeButton = screen.getByRole('button', { name: 'Remove' }); fireEvent.click(removeButton); - await screen.findByText('Remove Docker host "Host One"'); + await screen.findByText('Remove container host "Host One"'); const stopButton = screen.getByRole('button', { name: 'Stop agent now' }); fireEvent.click(stopButton); diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index ca76975..d986dfa 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -5,6 +5,8 @@ import { UpdatesAPI, type UpdatePlan } from '@/api/updates'; import { UpdateConfirmationModal } from './UpdateConfirmationModal'; import { UpdateProgressModal } from './UpdateProgressModal'; import { WebSocketContext } from '@/App'; +import { copyToClipboard } from '@/utils/clipboard'; +import { logger } from '@/utils/logger'; export function UpdateBanner() { const navigate = useNavigate(); @@ -24,7 +26,7 @@ export function UpdateBanner() { const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion); setUpdatePlan(plan); } catch (error) { - console.error('Failed to fetch update plan:', error); + logger.error('Failed to fetch update plan', error); } } }); @@ -44,21 +46,21 @@ export function UpdateBanner() { setShowConfirmModal(false); setShowProgressModal(true); } catch (error) { - console.error('Failed to start update:', error); + logger.error('Failed to start update', error); alert('Failed to start update. Please try again.'); } finally { setIsApplying(false); } }; - const copyToClipboard = async (text: string, index: number) => { - try { - await navigator.clipboard.writeText(text); - setCopiedIndex(index); - setTimeout(() => setCopiedIndex(null), 2000); - } catch (error) { - console.error('Failed to copy:', error); + const handleCopy = async (text: string, index: number) => { + const success = await copyToClipboard(text); + if (!success) { + logger.error('Failed to copy update instruction to clipboard', new Error('clipboard copy failed')); + return; } + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); }; // Get deployment type message @@ -223,7 +225,7 @@ export function UpdateBanner() { {instruction}