Refactor: Code cleanup and localStorage consolidation
This commit includes comprehensive codebase cleanup and refactoring: ## Code Cleanup - Remove dead TypeScript code (types/monitoring.ts - 194 lines duplicate) - Remove unused Go functions (GetClusterNodes, MigratePassword, GetClusterHealthInfo) - Clean up commented-out code blocks across multiple files - Remove unused TypeScript exports (helpTextClass, private tag color helpers) - Delete obsolete test files and components ## localStorage Consolidation - Centralize all storage keys into STORAGE_KEYS constant - Update 5 files to use centralized keys: * utils/apiClient.ts (AUTH, LEGACY_TOKEN) * components/Dashboard/Dashboard.tsx (GUEST_METADATA) * components/Docker/DockerHosts.tsx (DOCKER_METADATA) * App.tsx (PLATFORMS_SEEN) * stores/updates.ts (UPDATES) - Benefits: Single source of truth, prevents typos, better maintainability ## Previous Work Committed - Docker monitoring improvements and disk metrics - Security enhancements and setup fixes - API refactoring and cleanup - Documentation updates - Build system improvements ## Testing - All frontend tests pass (29 tests) - All Go tests pass (15 packages) - Production build successful - Zero breaking changes Total: 186 files changed, 5825 insertions(+), 11602 deletions(-)
|
|
@ -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
|
||||
*~
|
||||
*~
|
||||
|
|
|
|||
12
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
139
CONTRIBUTING.md
Normal file
|
|
@ -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!
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
23
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
|
||||
|
|
|
|||
11
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://<your-server>: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://<your-server>: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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
0
docker-entrypoint.sh
Normal file → Executable file
19
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": "<bootstrap-token>"
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <api-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 <api-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|<api-token>`) 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|<primary-token> \
|
||||
--target https://pulse-dr.example.com|<dr-token>
|
||||
--target https://pulse-dr.example.com|<dr-token> && \
|
||||
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 <api-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 <api-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
|
||||
|
|
|
|||
15
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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
59
docs/README.md
Normal file
|
|
@ -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.
|
||||
|
||||
|
|
@ -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 <container>` 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 <container>` and adjust restart policy if needed.
|
||||
|
||||
**Step 3: Check Pulse logs**
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
106
docs/development/MOCK_MODE.md
Normal file
|
|
@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 116 KiB |
97
docs/installer-v2-rollout.md
Normal file
|
|
@ -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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
144
docs/script-library-guide.md
Normal file
|
|
@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 259 KiB |
BIN
frontend-modern/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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() {
|
|||
<SecurityWarning />
|
||||
<DemoBanner />
|
||||
<UpdateBanner />
|
||||
<LegacySSHBanner />
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans py-4 sm:py-6">
|
||||
<AppLayout
|
||||
connected={connected}
|
||||
|
|
@ -663,7 +661,6 @@ function App() {
|
|||
</AppLayout>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
<NotificationContainer />
|
||||
<TokenRevealDialog />
|
||||
<TooltipRoot />
|
||||
</DarkModeContext.Provider>
|
||||
|
|
@ -766,12 +763,11 @@ function AppLayout(props: {
|
|||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const PLATFORM_SEEN_STORAGE_KEY = 'pulse-platforms-seen';
|
||||
|
||||
const readSeenPlatforms = (): Record<string, boolean> => {
|
||||
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<string, boolean>;
|
||||
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<string, boolean>) => {
|
||||
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: (
|
||||
<DockerIcon class="w-4 h-4 shrink-0" />
|
||||
<BoxesIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -879,7 +875,7 @@ function AppLayout(props: {
|
|||
enabled: hasHosts() || !!seenPlatforms()['hosts'],
|
||||
live: hasHosts(),
|
||||
icon: (
|
||||
<HostsIcon class="w-4 h-4 shrink-0" />
|
||||
<MonitorIcon class="w-4 h-4 shrink-0" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -910,7 +906,7 @@ function AppLayout(props: {
|
|||
badge: null as 'update' | null,
|
||||
count: activeAlertCount,
|
||||
breakdown,
|
||||
icon: <AlertsIcon class="w-4 h-4 shrink-0" />,
|
||||
icon: <BellIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
{
|
||||
id: 'settings' as const,
|
||||
|
|
@ -920,7 +916,7 @@ function AppLayout(props: {
|
|||
badge: updateStore.isUpdateVisible() ? ('update' as const) : null,
|
||||
count: undefined,
|
||||
breakdown: undefined,
|
||||
icon: <SettingsGearIcon class="w-4 h-4 shrink-0" />,
|
||||
icon: <SettingsIcon class="w-4 h-4 shrink-0" />,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import { createSignal, onMount } from 'solid-js';
|
||||
|
||||
export default function SimpleApp() {
|
||||
const [status, setStatus] = createSignal('Initializing...');
|
||||
const [data, setData] = createSignal<any>(null);
|
||||
const [wsStatus, setWsStatus] = createSignal('Not connected');
|
||||
|
||||
onMount(() => {
|
||||
setStatus('Testing API connection...');
|
||||
|
||||
// Test API
|
||||
fetch('/api/health')
|
||||
.then((res) => {
|
||||
setStatus(`API Status: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setStatus('API Connected! Testing WebSocket...');
|
||||
|
||||
// Test WebSocket
|
||||
const ws = new WebSocket(`ws://${window.location.host}/ws`);
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsStatus('WebSocket CONNECTED');
|
||||
setStatus('Everything working!');
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
setWsStatus('WebSocket ERROR');
|
||||
console.error('WS Error:', e);
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
setWsStatus(`WebSocket CLOSED: ${e.code} - ${e.reason}`);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
setWsStatus('WebSocket receiving data!');
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
setData((prev) => ({ ...prev, lastMessage: msg.type }));
|
||||
} catch (err) {
|
||||
console.error('Parse error:', err);
|
||||
}
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus(`API Error: ${err}`);
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', 'font-family': 'monospace' }}>
|
||||
<h1>Pulse System Test</h1>
|
||||
<hr />
|
||||
<p>
|
||||
<strong>Status:</strong> {status()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>WebSocket:</strong> {wsStatus()}
|
||||
</p>
|
||||
<hr />
|
||||
<h3>API Data:</h3>
|
||||
<pre>{JSON.stringify(data(), null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export default function Test() {
|
||||
return (
|
||||
<div style={{ padding: '20px', 'font-size': '24px' }}>
|
||||
<h1>TEST - APP IS WORKING!</h1>
|
||||
<p>If you see this, the basic app infrastructure works.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T = unknown> {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
message?: string;
|
||||
data?: T;
|
||||
export interface SystemSettingsResponse extends SystemConfig {
|
||||
envOverrides?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export class SettingsAPI {
|
||||
private static baseUrl = '/api';
|
||||
|
||||
static async getSettings(): Promise<SettingsResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/settings`) as Promise<SettingsResponse>;
|
||||
}
|
||||
|
||||
// Full settings update (legacy - avoid using)
|
||||
static async updateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/settings/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
}) as Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
// System settings update (preferred) - uses SystemConfig type from config.ts
|
||||
static async updateSystemSettings(settings: Partial<SystemConfig>): Promise<ApiResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/system/settings/update`, {
|
||||
static async updateSystemSettings(settings: Partial<SystemConfig>): Promise<void> {
|
||||
await apiFetchJSON(`${this.baseUrl}/system/settings/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
}) as Promise<ApiResponse>;
|
||||
});
|
||||
}
|
||||
|
||||
// Get system settings - returns SystemConfig
|
||||
static async getSystemSettings(): Promise<SystemConfig> {
|
||||
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemConfig>;
|
||||
}
|
||||
|
||||
static async validateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/settings/validate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
}) as Promise<ApiResponse>;
|
||||
static async getSystemSettings(): Promise<SystemSettingsResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemSettingsResponse>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SystemSettings> {
|
||||
return apiFetchJSON('/api/system/settings');
|
||||
}
|
||||
|
||||
static async updateSystemSettings(settings: Partial<SystemSettings>): Promise<void> {
|
||||
await apiFetchJSON('/api/system/settings/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
@ -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
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -2836,11 +2863,11 @@ const cancelEdit = () => {
|
|||
<Show when={hasSection('dockerHosts')}>
|
||||
<div ref={registerSection('dockerHosts')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title="Docker Hosts"
|
||||
title="Container Hosts"
|
||||
resources={dockerHostsWithOverrides()}
|
||||
columns={[]}
|
||||
activeAlerts={props.activeAlerts}
|
||||
emptyMessage="No Docker hosts match the current filters."
|
||||
emptyMessage="No container hosts match the current filters."
|
||||
onEdit={startEditing}
|
||||
onSaveEdit={saveEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
|
|
@ -2851,6 +2878,8 @@ 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 = () => {
|
|||
<Show when={hasSection('dockerContainers')}>
|
||||
<div ref={registerSection('dockerContainers')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title="Docker Containers"
|
||||
title="Containers"
|
||||
groupedResources={dockerContainersGroupedByHost()}
|
||||
groupHeaderMeta={dockerHostGroupMeta()}
|
||||
columns={[
|
||||
|
|
@ -2881,7 +2910,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={{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CompactNodeCardProps> = (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' }) => (
|
||||
<div class="w-[80px] h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full transition-all ${
|
||||
props.value >= 90 ? 'bg-red-500' : props.value >= 75 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${props.value}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (props.variant === 'ultra-compact') {
|
||||
// Single line format for 10+ nodes
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
border={false}
|
||||
hoverable
|
||||
class={`flex items-center gap-2 px-3 py-1.5 ${
|
||||
props.isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: !isOnline()
|
||||
? 'border-red-500'
|
||||
: alertStyles().hasUnacknowledgedAlert
|
||||
? 'border-orange-500'
|
||||
: alertStyles().hasAcknowledgedOnlyAlert
|
||||
? 'border-gray-400 dark:border-gray-600'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
} border transition-all cursor-pointer hover:scale-[1.01]`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
class={`w-2 h-2 rounded-full ${
|
||||
props.node.connectionHealth === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: isOnline()
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Node name */}
|
||||
<a
|
||||
href={props.node.host || `https://${props.node.name}:8006`}
|
||||
target="_blank"
|
||||
class="font-medium text-sm w-24 truncate hover:text-blue-600 dark:hover:text-blue-400"
|
||||
title={
|
||||
showActualName()
|
||||
? `${displayName()} • ${props.node.name}`
|
||||
: props.node.name
|
||||
}
|
||||
>
|
||||
{displayName()}
|
||||
</a>
|
||||
|
||||
{/* Cluster/Standalone indicator */}
|
||||
<Show when={props.node.isClusterMember !== undefined}>
|
||||
<span
|
||||
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
|
||||
props.node.isClusterMember
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{props.node.isClusterMember ? props.node.clusterName?.slice(0, 3).toUpperCase() : 'SA'}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* Alert indicator */}
|
||||
<Show when={alertStyles().hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
</Show>
|
||||
|
||||
{/* Metrics */}
|
||||
<div class="flex gap-4 text-xs font-mono">
|
||||
<span class={getMetricColor(cpuPercent(), 'cpu')}>
|
||||
C:{cpuPercent().toString().padStart(3)}%
|
||||
</span>
|
||||
<span class={getMetricColor(memPercent(), 'mem')}>
|
||||
M:{memPercent().toString().padStart(3)}%
|
||||
</span>
|
||||
<span class={getMetricColor(diskPercent(), 'disk')}>
|
||||
D:{diskPercent().toString().padStart(3)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<span class="ml-auto text-xs text-gray-500 dark:text-gray-400">
|
||||
↑{formatUptime(props.node.uptime)}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact bar format for 5-9 nodes
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
border={false}
|
||||
hoverable
|
||||
class={`border ${
|
||||
props.isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: !isOnline()
|
||||
? 'border-red-500'
|
||||
: alertStyles().hasUnacknowledgedAlert
|
||||
? 'border-orange-500'
|
||||
: alertStyles().hasAcknowledgedOnlyAlert
|
||||
? 'border-gray-400 dark:border-gray-600'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
} cursor-pointer transition-all hover:scale-[1.02]`}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`w-2 h-2 rounded-full ${
|
||||
props.node.connectionHealth === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: isOnline()
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<a
|
||||
href={props.node.host || `https://${props.node.name}:8006`}
|
||||
target="_blank"
|
||||
class="font-semibold text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{displayName()}
|
||||
</a>
|
||||
<Show when={showActualName()}>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
|
||||
</Show>
|
||||
{/* Cluster/Standalone indicator */}
|
||||
<Show when={props.node.isClusterMember !== undefined}>
|
||||
<span
|
||||
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
props.node.isClusterMember
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={alertStyles().hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatUptime(props.node.uptime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metric bars */}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">CPU</span>
|
||||
<MiniProgressBar value={cpuPercent()} type="cpu" />
|
||||
<span class={`text-xs ${getMetricColor(cpuPercent(), 'cpu')}`}>{cpuPercent()}%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Mem</span>
|
||||
<MiniProgressBar value={memPercent()} type="mem" />
|
||||
<span class={`text-xs ${getMetricColor(memPercent(), 'mem')}`}>{memPercent()}%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Disk</span>
|
||||
<MiniProgressBar value={diskPercent()} type="disk" />
|
||||
<span class={`text-xs ${getMetricColor(diskPercent(), 'disk')}`}>{diskPercent()}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactNodeCard;
|
||||
|
|
@ -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<string, GuestMetadata>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<DiskHealthSummaryProps> = (props) => {
|
||||
const diskStats = createMemo(() => {
|
||||
const disks = props.disks || [];
|
||||
const total = disks.length;
|
||||
const healthy = disks.filter((d) => d.health === 'PASSED').length;
|
||||
const failing = disks.filter((d) => d.health === 'FAILED').length;
|
||||
const unknown = disks.filter((d) => d.health === 'UNKNOWN' || !d.health).length;
|
||||
const lowLife = disks.filter((d) => d.wearout > 0 && d.wearout < 10).length;
|
||||
const avgWearout =
|
||||
disks.filter((d) => d.wearout > 0).reduce((sum, d) => sum + d.wearout, 0) /
|
||||
disks.filter((d) => d.wearout > 0).length || 0;
|
||||
|
||||
// Group by node
|
||||
const byNode: Record<string, number> = {};
|
||||
disks.forEach((d) => {
|
||||
byNode[d.node] = (byNode[d.node] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
healthy,
|
||||
failing,
|
||||
unknown,
|
||||
lowLife,
|
||||
avgWearout,
|
||||
byNode,
|
||||
};
|
||||
});
|
||||
|
||||
const healthColor = createMemo(() => {
|
||||
const stats = diskStats();
|
||||
if (stats.failing > 0) return 'text-red-600 dark:text-red-400';
|
||||
if (stats.lowLife > 0) return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (stats.unknown > 0) return 'text-gray-600 dark:text-gray-400';
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
});
|
||||
|
||||
const healthBg = createMemo(() => {
|
||||
const stats = diskStats();
|
||||
if (stats.failing > 0) return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
|
||||
if (stats.lowLife > 0)
|
||||
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
|
||||
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={diskStats().total > 0}>
|
||||
<Card padding="md" border={false} class={`${healthBg()}`}>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<SectionHeader
|
||||
title="Disk health summary"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
titleClass="text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<span class={`text-2xl font-bold ${healthColor()}`}>
|
||||
{diskStats().healthy}/{diskStats().total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{/* Health Status */}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-400">Status</span>
|
||||
<div class="flex gap-2">
|
||||
<Show when={diskStats().healthy > 0}>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||
{diskStats().healthy} healthy
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={diskStats().failing > 0}>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
|
||||
{diskStats().failing} failed
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={diskStats().lowLife > 0}>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">
|
||||
{diskStats().lowLife} low
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={diskStats().unknown > 0}>
|
||||
<span class="px-1.5 py-0.5 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-400">
|
||||
{diskStats().unknown} unknown
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average SSD Life */}
|
||||
<Show when={diskStats().avgWearout > 0}>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-400">Avg SSD Life</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
class={`h-1.5 rounded-full transition-all ${
|
||||
diskStats().avgWearout >= 50
|
||||
? 'bg-green-500'
|
||||
: diskStats().avgWearout >= 20
|
||||
? 'bg-yellow-500'
|
||||
: diskStats().avgWearout >= 10
|
||||
? 'bg-orange-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={`width: ${diskStats().avgWearout}%`}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-gray-700 dark:text-gray-300 font-medium">
|
||||
{Math.round(diskStats().avgWearout)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Disk Distribution */}
|
||||
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Distribution</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{Object.entries(diskStats().byNode).map(([node, count]) => (
|
||||
<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">
|
||||
{node}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 */}
|
||||
<td class="py-0.5 px-2 w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[180px]">
|
||||
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<MetricBar
|
||||
value={cpuPercent()}
|
||||
label={formatPercent(cpuPercent())}
|
||||
|
|
@ -598,7 +598,7 @@ export function GuestRow(props: GuestRowProps) {
|
|||
{/* Memory */}
|
||||
<td class="py-0.5 px-2 w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[180px]">
|
||||
<div title={memoryTooltip() ?? undefined}>
|
||||
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
||||
<MetricBar
|
||||
value={memPercent()}
|
||||
label={formatPercent(memPercent())}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export const DockerFilter: Component<DockerFilterProps> = (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<DockerFilterProps> = (props) => {
|
|||
<span class="sr-only">Show search history</span>
|
||||
</button>
|
||||
<SearchTipsPopover
|
||||
popoverId="docker-search-help"
|
||||
intro="Filter Docker containers quickly"
|
||||
popoverId="container-search-help"
|
||||
intro="Filter containers quickly"
|
||||
tips={[
|
||||
{ code: 'name:api', description: 'Match containers with "api" in the name' },
|
||||
{ code: 'image:postgres', description: 'Find containers running a specific image' },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { DockerHost } from '@/types/api';
|
|||
import { Card } from '@/components/shared/Card';
|
||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
||||
import { resolveHostRuntime } from './runtimeDisplay';
|
||||
import { formatPercent, formatUptime } from '@/utils/format';
|
||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||
|
||||
|
|
@ -234,6 +235,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
};
|
||||
|
||||
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
|
||||
const runtimeInfo = resolveHostRuntime(summary.host);
|
||||
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -251,12 +254,15 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
({summary.host.hostname})
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 whitespace-nowrap">
|
||||
Docker
|
||||
<span
|
||||
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
||||
title={runtimeInfo.raw || runtimeInfo.label}
|
||||
>
|
||||
{runtimeInfo.label}
|
||||
</span>
|
||||
<Show when={summary.host.dockerVersion}>
|
||||
<Show when={runtimeVersion}>
|
||||
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
v{summary.host.dockerVersion}
|
||||
v{runtimeVersion}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, DockerMetadata>;
|
||||
|
||||
interface DockerHostsProps {
|
||||
|
|
@ -36,17 +36,21 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
|||
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
||||
|
||||
// Load docker metadata from localStorage or API
|
||||
const [dockerMetadata, setDockerMetadata] = createSignal<DockerMetadataRecord>(() => {
|
||||
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<DockerMetadataRecord>(
|
||||
loadInitialDockerMetadata(),
|
||||
);
|
||||
|
||||
const sortedHosts = createMemo(() => {
|
||||
const hosts = props.hosts || [];
|
||||
|
|
@ -167,13 +171,13 @@ export const DockerHosts: Component<DockerHostsProps> = (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<DockerHostsProps> = (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<DockerHostsProps> = (props) => {
|
|||
/>
|
||||
</svg>
|
||||
}
|
||||
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<DockerHostsProps> = (props) => {
|
|||
/>
|
||||
</svg>
|
||||
}
|
||||
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={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/docker')}
|
||||
onClick={() => navigate('/settings/containers')}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<span>Set up Docker agent</span>
|
||||
<span>Set up container agent</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,331 +0,0 @@
|
|||
import { Component, For, Show } from 'solid-js';
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerHost,
|
||||
DockerService,
|
||||
DockerTask,
|
||||
} from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
|
||||
export type DockerTreeSelection =
|
||||
| { type: 'host'; hostId: string; id: string }
|
||||
| { type: 'service'; hostId: string; id: string }
|
||||
| { type: 'task'; hostId: string; serviceKey: string; id: string }
|
||||
| { type: 'container'; hostId: string; id: string };
|
||||
|
||||
export interface DockerTreeTaskEntry {
|
||||
nodeId: string;
|
||||
task: DockerTask;
|
||||
}
|
||||
|
||||
export interface DockerTreeServiceEntry {
|
||||
key: string;
|
||||
service: DockerService;
|
||||
tasks: DockerTreeTaskEntry[];
|
||||
}
|
||||
|
||||
export interface DockerTreeContainerEntry {
|
||||
nodeId: string;
|
||||
container: DockerContainer;
|
||||
}
|
||||
|
||||
export interface DockerTreeHostEntry {
|
||||
host: DockerHost;
|
||||
hostId: string;
|
||||
containers: DockerContainer[];
|
||||
services: DockerTreeServiceEntry[];
|
||||
standaloneContainers: DockerTreeContainerEntry[];
|
||||
}
|
||||
|
||||
interface ExpandState {
|
||||
isExpanded: () => boolean;
|
||||
toggle: () => void;
|
||||
setExpanded: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface DockerTreeProps {
|
||||
hosts: DockerTreeHostEntry[];
|
||||
selected?: DockerTreeSelection | null;
|
||||
onSelect?: (selection: DockerTreeSelection) => void;
|
||||
getHostState: (hostId: string) => ExpandState;
|
||||
getServiceState: (serviceKey: string) => ExpandState;
|
||||
}
|
||||
|
||||
export const DockerTree: Component<DockerTreeProps> = (props) => {
|
||||
const isNodeSelected = (node: DockerTreeSelection) => {
|
||||
const current = props.selected;
|
||||
if (!current) return false;
|
||||
return (
|
||||
current.type === node.type &&
|
||||
current.hostId === node.hostId &&
|
||||
current.id === node.id
|
||||
);
|
||||
};
|
||||
|
||||
const hostDisplayName = (host: DockerHost) =>
|
||||
host.displayName || host.hostname || host.id || 'Unknown host';
|
||||
|
||||
const hostStatusVariant = (host: DockerHost) => {
|
||||
const status = host.status?.toLowerCase() ?? 'unknown';
|
||||
if (status === 'online') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
if (
|
||||
status === 'offline' ||
|
||||
status === 'error' ||
|
||||
status === 'down' ||
|
||||
status === 'unreachable'
|
||||
) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
return 'bg-yellow-500';
|
||||
};
|
||||
|
||||
const taskStatusVariant = (task: DockerTask) => {
|
||||
const state = task.currentState?.toLowerCase() ?? '';
|
||||
if (state === 'running') return 'bg-green-500';
|
||||
if (state === 'failed' || state === 'error') return 'bg-red-500';
|
||||
return 'bg-yellow-500';
|
||||
};
|
||||
|
||||
const describeTaskLabel = (task: DockerTask) => {
|
||||
if (task.slot !== undefined && task.slot !== null) {
|
||||
return `${task.containerName || task.containerId || 'Task'}:${task.slot}`;
|
||||
}
|
||||
return task.containerName || task.containerId || task.id?.slice(0, 12) || 'Task';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
class="bg-white dark:bg-slate-900/60 p-2"
|
||||
>
|
||||
<nav class="space-y-1.5 text-xs">
|
||||
<For each={props.hosts}>
|
||||
{(entry) => {
|
||||
const hostState = props.getHostState(entry.hostId);
|
||||
|
||||
const selectHost = () => {
|
||||
hostState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'host',
|
||||
hostId: entry.hostId,
|
||||
id: entry.hostId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-start gap-1.5">
|
||||
{/*
|
||||
Small expand/collapse control for the host section. We avoid re-rendering
|
||||
the entire row by leaving the host summary text in a separate button.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hostState.toggle()}
|
||||
aria-label={
|
||||
hostState.isExpanded() ? 'Collapse host section' : 'Expand host section'
|
||||
}
|
||||
class="mt-0 h-4 w-4 flex items-center justify-center rounded border border-transparent text-slate-500 hover:text-sky-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500"
|
||||
>
|
||||
<svg
|
||||
class={`h-3 w-3 transition-transform duration-150 ${
|
||||
hostState.isExpanded() ? 'rotate-90' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 4l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectHost}
|
||||
class={`flex w-full items-center gap-2 truncate rounded border border-transparent px-1.5 py-0.5 text-left text-[12px] font-medium transition-colors ${
|
||||
isNodeSelected({ type: 'host', hostId: entry.hostId, id: entry.hostId })
|
||||
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 flex-shrink-0 rounded-full ${hostStatusVariant(entry.host)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate text-slate-700 dark:text-slate-100">{hostDisplayName(entry.host)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={hostState.isExpanded()}>
|
||||
<div class="space-y-1 border-l border-slate-200 pl-2 dark:border-slate-700">
|
||||
<Show when={entry.services.length > 0}>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-tight text-slate-400 dark:text-slate-500">
|
||||
Services
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={entry.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 (
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Show when={serviceEntry.tasks.length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
serviceState.isExpanded()
|
||||
? 'Collapse task list'
|
||||
: 'Expand task list'
|
||||
}
|
||||
class="mt-0.5 h-3.5 w-3.5 flex items-center justify-center rounded border border-transparent text-slate-400 hover:text-sky-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
serviceState.toggle();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class={`h-3 w-3 transition-transform duration-150 ${
|
||||
serviceState.isExpanded() ? 'rotate-90' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 4l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectService}
|
||||
class={`flex min-h-[24px] w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'service',
|
||||
hostId: entry.hostId,
|
||||
id: serviceEntry.key,
|
||||
})
|
||||
? 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span class="truncate text-slate-600 dark:text-slate-200">
|
||||
{serviceEntry.service.name || serviceEntry.service.id}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={serviceEntry.tasks.length > 0 && serviceState.isExpanded()}>
|
||||
<div class="space-y-0.5 border-l border-slate-200 pl-2 dark:border-slate-700">
|
||||
<For each={serviceEntry.tasks}>
|
||||
{(taskEntry) => {
|
||||
const task = taskEntry.task;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTask(taskEntry.nodeId)}
|
||||
class={`flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'task',
|
||||
hostId: entry.hostId,
|
||||
serviceKey: serviceEntry.key,
|
||||
id: taskEntry.nodeId,
|
||||
})
|
||||
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${taskStatusVariant(task)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate text-slate-500 dark:text-slate-300">
|
||||
{describeTaskLabel(task)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={entry.standaloneContainers.length > 0}>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-tight text-slate-400 dark:text-slate-500">
|
||||
Standalone Containers
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={entry.standaloneContainers}>
|
||||
{(containerEntry) => {
|
||||
const container = containerEntry.container;
|
||||
const selectContainer = () => {
|
||||
hostState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'container',
|
||||
hostId: entry.hostId,
|
||||
id: containerEntry.nodeId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectContainer}
|
||||
class={`flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'container',
|
||||
hostId: entry.hostId,
|
||||
id: containerEntry.nodeId,
|
||||
})
|
||||
? 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span class="truncate text-slate-600 dark:text-slate-200">
|
||||
{container.name || container.id || 'Unknown container'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string, string>,
|
||||
): PodmanMetadataSection[] => {
|
||||
const sections: PodmanMetadataSection[] = [];
|
||||
const consumed = new Set<string>();
|
||||
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<string>();
|
||||
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 (
|
||||
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<td colSpan={props.colspan} class="py-0.5 pr-2 pl-4">
|
||||
|
|
@ -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}
|
||||
</span>
|
||||
<Show when={podName()}>
|
||||
{(name) => (
|
||||
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
|
||||
Pod: {name()}
|
||||
<Show when={isPodInfra()}>
|
||||
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200">
|
||||
infra
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={customUrl()}>
|
||||
<a
|
||||
href={customUrl()}
|
||||
|
|
@ -726,11 +989,14 @@ const DockerContainerRow: Component<{
|
|||
</td>
|
||||
<td class="px-2 py-0.5">
|
||||
<span
|
||||
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass(
|
||||
'container',
|
||||
)}`}
|
||||
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
||||
title={
|
||||
runtimeVersion()
|
||||
? `${runtimeInfo.label} ${runtimeVersion()}`
|
||||
: runtimeInfo.raw || runtimeInfo.label
|
||||
}
|
||||
>
|
||||
Container
|
||||
{runtimeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
|
|
@ -764,7 +1030,7 @@ const DockerContainerRow: Component<{
|
|||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-0.5 min-w-[180px]">
|
||||
<td class="px-2 py-0.5 min-w-[200px]">
|
||||
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||
<Show
|
||||
when={diskPercent() !== null}
|
||||
|
|
@ -778,19 +1044,6 @@ const DockerContainerRow: Component<{
|
|||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={hasBlockIoRates()}>
|
||||
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<Show when={blockIoReadRateLabel()}>
|
||||
<span>R {blockIoReadRateLabel()}</span>
|
||||
</Show>
|
||||
<Show when={blockIoReadRateLabel() && blockIoWriteRateLabel()}>
|
||||
<span class="mx-1 text-gray-300 dark:text-gray-600">•</span>
|
||||
</Show>
|
||||
<Show when={blockIoWriteRateLabel()}>
|
||||
<span>W {blockIoWriteRateLabel()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
<Show when={isRunning()} fallback={<span class="text-gray-400">—</span>}>
|
||||
|
|
@ -809,6 +1062,134 @@ const DockerContainerRow: Component<{
|
|||
<tr class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<td colSpan={props.columns} class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-start gap-3">
|
||||
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
Summary
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
|
||||
<span
|
||||
class={`inline-flex items-center gap-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${runtimeInfo.badgeClass}`}
|
||||
title={runtimeInfo.raw || runtimeInfo.label}
|
||||
>
|
||||
{runtimeInfo.label}
|
||||
<Show when={runtimeVersion()}>
|
||||
{(version) => (
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">{version()}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Image</span>
|
||||
<span class="flex-1 truncate text-right text-gray-600 dark:text-gray-300" title={container.image}>
|
||||
{container.image || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={podName()}>
|
||||
{(name) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Pod</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">
|
||||
{name()}
|
||||
<Show when={isPodInfra()}>
|
||||
<span class="ml-2 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
|
||||
infra
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={podmanMetadata()?.composeProject}>
|
||||
{(project) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Compose Project</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{project()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={podmanMetadata()?.composeService}>
|
||||
{(service) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Compose Service</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{service()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={podmanMetadata()?.autoUpdatePolicy}>
|
||||
{(policy) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Auto Update</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">
|
||||
{policy()}
|
||||
<Show when={podmanMetadata()?.autoUpdateRestart}>
|
||||
{(restart) => (
|
||||
<span class="ml-2 text-[10px] text-gray-500 dark:text-gray-400">restart: {restart()}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={podmanMetadata()?.userNamespace}>
|
||||
{(userns) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">User Namespace</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{userns()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{statusLabel()}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Restarts</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{restarts()}</span>
|
||||
</div>
|
||||
<Show when={createdRelative()}>
|
||||
{(created) => (
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Created</span>
|
||||
<div class="text-right text-gray-600 dark:text-gray-300">
|
||||
{created()}
|
||||
<Show when={createdAbsolute()}>
|
||||
{(abs) => (
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400">{abs()}</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={startedRelative()}>
|
||||
{(started) => (
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Started</span>
|
||||
<div class="text-right text-gray-600 dark:text-gray-300">
|
||||
{started()}
|
||||
<Show when={startedAbsolute()}>
|
||||
{(abs) => (
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400">{abs()}</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Uptime</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{uptime()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={runtimeInfo.id === 'podman'}>
|
||||
<div class="mt-3 rounded border border-dashed border-purple-200 px-2 py-1 text-[10px] text-purple-700 dark:border-purple-700/60 dark:text-purple-200">
|
||||
Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={container.ports && container.ports.length > 0}>
|
||||
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
|
|
@ -856,6 +1237,40 @@ const DockerContainerRow: Component<{
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasPodmanMetadata()}>
|
||||
<div class="min-w-[220px] rounded border border-purple-200 bg-white/70 p-2 shadow-sm dark:border-purple-700/60 dark:bg-purple-950/20">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-purple-700 dark:text-purple-200">
|
||||
Podman Metadata
|
||||
</div>
|
||||
<div class="mt-1 space-y-2 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<For each={podmanMetadataSections()}>
|
||||
{(section) => (
|
||||
<div class="space-y-1 border-b border-purple-100 pb-1 last:border-b-0 last:pb-0 dark:border-purple-800/30">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wide text-purple-600 dark:text-purple-300">
|
||||
{section.title}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={section.items}>
|
||||
{(item) => (
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">{item.label}</span>
|
||||
<span
|
||||
class="max-w-[220px] break-all text-right text-gray-600 dark:text-gray-300"
|
||||
title={item.value || '—'}
|
||||
>
|
||||
{item.value || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasBlockIo()}>
|
||||
<div class="min-w-[220px] rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
|
|
@ -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<{
|
|||
</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[150px]">—</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[210px]">—</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[180px]">—</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[200px]">—</td>
|
||||
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
|
||||
|
|
@ -1628,48 +2043,48 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
|||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
title="No Docker workloads found"
|
||||
title="No container workloads found"
|
||||
description={
|
||||
totalContainers() === 0 && totalServices() === 0
|
||||
? 'Add a Docker agent in Settings to start gathering container and service metrics.'
|
||||
? 'Add a container agent in Settings to start gathering container and service metrics.'
|
||||
: props.searchTerm || props.statsFilter
|
||||
? 'No Docker containers or services match your current filters.'
|
||||
: 'Docker data is currently unavailable.'
|
||||
? 'No containers or services match your current filters.'
|
||||
: 'Container runtime data is currently unavailable.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" class="overflow-hidden">
|
||||
<ScrollableTable minWidth="1024px">
|
||||
<table class="w-full min-w-[1024px] table-fixed border-collapse whitespace-nowrap">
|
||||
<ScrollableTable minWidth="1080px">
|
||||
<table class="w-full min-w-[1080px] table-fixed border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]">
|
||||
<th class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||
Resource
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[17%]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%]">
|
||||
Image / Stack
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] min-w-[150px]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] min-w-[150px]">
|
||||
CPU
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] min-w-[210px]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] min-w-[210px]">
|
||||
Memory
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] min-w-[180px]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] min-w-[200px]">
|
||||
Disk
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%]">
|
||||
Tasks / Restarts
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%]">
|
||||
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
|
||||
Updated / Uptime
|
||||
</th>
|
||||
</tr>
|
||||
|
|
|
|||
196
frontend-modern/src/components/Docker/runtimeDisplay.ts
Normal file
|
|
@ -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<RuntimeKind, 'unknown'> | 'unknown';
|
||||
}
|
||||
|
||||
const BADGE_CLASSES: Record<Exclude<RuntimeKind, 'unknown'>, 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<RuntimeKind, string> = {
|
||||
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<RuntimeKind, 'unknown'>; 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<DockerHost, 'containers'> | 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<DockerHost, 'runtime' | 'runtimeVersion' | 'dockerVersion' | 'containers'>,
|
||||
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;
|
||||
};
|
||||
|
|
@ -40,7 +40,9 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
|
|||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 mb-4">
|
||||
<p class="text-sm text-red-800 dark:text-red-200 font-mono">{props.error.message}</p>
|
||||
<p class="text-sm text-red-800 dark:text-red-200">
|
||||
Please try again or reload the page. If the problem persists, contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
|
|
@ -60,21 +62,9 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetails(!details())}
|
||||
class="mt-4 text-sm text-gray-500 dark:text-gray-400 underline hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
{details() ? 'Hide' : 'Show'} error details
|
||||
</button>
|
||||
|
||||
{details() && (
|
||||
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded overflow-x-auto">
|
||||
<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{props.error.stack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div class="mt-4 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
Technical details are suppressed in this view. Check server logs for full context.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Show when={isVisible()}>
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 border-b border-orange-200 dark:border-orange-800 text-orange-800 dark:text-orange-200 relative animate-slideDown">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{/* Warning icon */}
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="text-sm space-x-1">
|
||||
<span class="font-medium">Legacy temperature monitoring detected.</span>
|
||||
<span>Remove each node and re-add it using the installer script in Settings → Nodes (advanced: rerun the host installer script directly).</span>
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/blob/main/docs/PULSE_SENSOR_PROXY_HARDENING.md#upgrading-existing-installations"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="underline decoration-dashed underline-offset-2 hover:decoration-solid"
|
||||
>
|
||||
View upgrade guide ↗
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/settings/nodes')}
|
||||
class="px-3 py-1 text-xs font-medium bg-orange-600 hover:bg-orange-700 dark:bg-orange-700 dark:hover:bg-orange-800 text-white rounded transition-colors"
|
||||
>
|
||||
Go to Nodes →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
class="p-1 hover:bg-orange-100 dark:hover:bg-orange-800/30 rounded transition-colors flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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 = () =>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
|
||||
const NotificationContainer: Component = () => null;
|
||||
|
||||
export default NotificationContainer;
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (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<APITokenManagerProps> = (props) => {
|
|||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-200">
|
||||
<ApiIcon class="h-5 w-5" />
|
||||
<BadgeCheck class="h-5 w-5" />
|
||||
</div>
|
||||
<SectionHeader
|
||||
label="Token inventory"
|
||||
|
|
@ -561,11 +560,11 @@ export const APITokenManager: Component<APITokenManagerProps> = (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<APITokenManagerProps> = (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"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<AgentStepSectionProps> = (props) => {
|
||||
return (
|
||||
<section class="space-y-5">
|
||||
<header class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-900/30 text-xs font-bold text-blue-700 dark:text-blue-300">
|
||||
{props.step.replace('Step ', '')}
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{props.title}
|
||||
</h3>
|
||||
</div>
|
||||
<Show when={props.description}>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed ml-9">
|
||||
{props.description}
|
||||
</p>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="ml-9">{props.children}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentStepSection;
|
||||
|
|
@ -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<BatchCredentialModalProps> = (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 (
|
||||
<Portal>
|
||||
<Show when={props.isOpen}>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={!isAdding() ? props.onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<SectionHeader
|
||||
title={`Add ${props.servers.length} discovered servers`}
|
||||
size="md"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={!isAdding()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="p-6 space-y-6">
|
||||
{/* Server List */}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Servers to Add
|
||||
</h4>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={props.servers}>
|
||||
{(server) => (
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded">
|
||||
<span
|
||||
class={`w-2 h-2 rounded-full ${server.type === 'pve' ? 'bg-orange-500' : 'bg-green-500'}`}
|
||||
></span>
|
||||
{server.hostname || server.ip}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared Credentials */}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Shared Credentials
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
(Same credentials will be used for all servers)
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
{/* Auth Type Selector */}
|
||||
<div class="mb-4">
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="batchAuthType"
|
||||
value="token"
|
||||
checked={authType() === 'token'}
|
||||
onChange={() => setAuthType('token')}
|
||||
disabled={isAdding()}
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
API Token (Recommended)
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="batchAuthType"
|
||||
value="password"
|
||||
checked={authType() === 'password'}
|
||||
onChange={() => setAuthType('password')}
|
||||
disabled={isAdding()}
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Username & Password
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Auth Fields */}
|
||||
<Show when={authType() === 'token'}>
|
||||
<div class="space-y-4">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>
|
||||
Token ID <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tokenName()}
|
||||
onInput={(e) => setTokenName(e.currentTarget.value)}
|
||||
placeholder="user@realm!tokenname"
|
||||
disabled={isAdding()}
|
||||
class={controlClass('font-mono')}
|
||||
/>
|
||||
<p class={`${formHelpText} mt-1`}>
|
||||
Example: pulse-monitor@pam!pulse-token or pulse-monitor@pbs!pulse-token
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>
|
||||
Token Value <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenValue()}
|
||||
onInput={(e) => setTokenValue(e.currentTarget.value)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
disabled={isAdding()}
|
||||
class={controlClass('font-mono')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Password Auth Fields */}
|
||||
<Show when={authType() === 'password'}>
|
||||
<div class="space-y-4">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>
|
||||
Username <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||
placeholder="root@pam or admin@pbs"
|
||||
disabled={isAdding()}
|
||||
class={controlClass()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>
|
||||
Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
placeholder="Password"
|
||||
disabled={isAdding()}
|
||||
class={controlClass()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<Show when={isAdding()}>
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
Adding servers... ({progress().current}/{progress().total})
|
||||
</p>
|
||||
<Show when={progress().currentServer}>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
Currently adding: {progress().currentServer}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all"
|
||||
style={`width: ${(progress().current / progress().total) * 100}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
disabled={isAdding()}
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAdd}
|
||||
disabled={isAdding()}
|
||||
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAdding() ? 'Adding...' : `Add ${props.servers.length} Servers`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<CommandBuilderProps> = (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<string | null>(null);
|
||||
const [latestGeneratedRecord, setLatestGeneratedRecord] = createSignal<APITokenRecord | null>(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 (
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
{/* Token Status Section */}
|
||||
<Show when={props.requiresToken}>
|
||||
<Show
|
||||
when={props.hasExistingToken}
|
||||
fallback={
|
||||
/* No token exists - offer to generate */
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-yellow-800 dark:text-yellow-300">No API token configured</span>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-400">Generate a token to secure your Docker agents.</p>
|
||||
</div>
|
||||
<Show when={canManageTokens()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
{isGenerating() ? 'Generating...' : 'Generate API Token'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!canManageTokens()}>
|
||||
<p class="mt-3 text-xs text-yellow-700 dark:text-yellow-400">
|
||||
Sign in with an administrator account to create tokens from the browser.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Token exists - show status and actions */}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-green-800 dark:text-green-300">API token configured</span>
|
||||
</div>
|
||||
<Show when={props.currentTokenHint}>
|
||||
<code class="text-xs font-mono text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/40 px-2 py-0.5 rounded">
|
||||
{props.currentTokenHint}
|
||||
</code>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={useExistingToken}
|
||||
disabled={!props.storedToken}
|
||||
class="px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded hover:bg-green-200 dark:hover:bg-green-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
title={props.storedToken ? "Fill the command with your existing token" : "Token not saved in browser - use the token input below instead"}
|
||||
>
|
||||
Use This Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyExistingToken}
|
||||
disabled={!props.storedToken}
|
||||
class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title={props.storedToken ? "Copy token to clipboard" : "Token not saved in browser - generate a new one if needed"}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<Show when={canManageTokens()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGenerateModal}
|
||||
disabled={isGenerating()}
|
||||
class="px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded hover:bg-blue-200 dark:hover:bg-blue-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
title="Generate another token for a new host or automation workflow"
|
||||
>
|
||||
Generate Token
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
Manage or revoke tokens from the table above whenever a credential is no longer needed.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Security explanation */}
|
||||
<Show when={props.requiresToken}>
|
||||
<div class="flex items-start gap-2 text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-2">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-medium text-blue-800 dark:text-blue-300">Security Note:</span>{' '}
|
||||
<span class="text-blue-700 dark:text-blue-400">
|
||||
Tokens are not auto-inserted to prevent accidental exposure. Paste your token below to build the command.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Token input field */}
|
||||
<Show when={props.requiresToken}>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Token
|
||||
<Show when={props.storedToken}>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
(prefilled from saved token)
|
||||
</span>
|
||||
</Show>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type={showToken() ? 'text' : 'password'}
|
||||
value={tokenInput()}
|
||||
onInput={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken())}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={showToken() ? 'Hide token' : 'Show token'}
|
||||
>
|
||||
<Show
|
||||
when={showToken()}
|
||||
fallback={
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={validateToken}
|
||||
disabled={isValidating() || !tokenInput().trim()}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Test if this token is valid"
|
||||
>
|
||||
{isValidating() ? 'Testing...' : 'Test Token'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Validation result */}
|
||||
<Show when={validationResult()}>
|
||||
<div class={`text-xs ${validationResult() === 'valid' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{validationResult() === 'valid' ? '✓ Token is valid' : '✗ Token is invalid'}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Command preview */}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Command Preview</span>
|
||||
{indicatorIcon()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCommand}
|
||||
disabled={hasPlaceholder()}
|
||||
class={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
hasPlaceholder()
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
title={hasPlaceholder() ? `Replace ${props.placeholder} with your token first` : 'Copy to clipboard'}
|
||||
>
|
||||
{copied() ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div class={`relative rounded border-2 ${borderColor()} ${bgColor()} p-3 overflow-x-auto transition-colors`}>
|
||||
<code class="text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{finalCommand()}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<Show when={hasPlaceholder()}>
|
||||
<div class="text-xs text-yellow-700 dark:text-yellow-400 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Paste your API token above to enable the copy button</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!hasPlaceholder() && commandState() === 'success'}>
|
||||
<div class="text-xs text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Ready to copy! Your command is complete.</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Generate Token Confirmation Modal */}
|
||||
<Show when={showGenerateModal()}>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Generate API Token</h3>
|
||||
<div class="space-y-3 mb-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a dedicated token for this host or automation workflow. Tokens remain active until you revoke them from the API tokens list.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400" for="command-builder-token-name">
|
||||
Token name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="command-builder-token-name"
|
||||
type="text"
|
||||
value={tokenLabel()}
|
||||
onInput={(event) => 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()}
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3">
|
||||
<p class="text-xs text-blue-800 dark:text-blue-300 font-medium">
|
||||
Tip: Issue one token per host so you can revoke compromised credentials without affecting other agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateNewToken}
|
||||
disabled={isGenerating()}
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating() ? 'Generating…' : 'Generate Token'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* New Token Display Modal */}
|
||||
<Show
|
||||
when={latestGeneratedToken() && !isRevealActiveForLatestToken()}
|
||||
>
|
||||
<div class="mt-4 space-y-3 rounded-lg border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-shrink-0 text-blue-600 dark:text-blue-300">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||
New token ready
|
||||
</p>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300 leading-snug">
|
||||
Reopen the secure dialog if you still need to copy the value before you leave this page. The command above already includes it.
|
||||
</p>
|
||||
<Show when={latestGeneratedRecord()}>
|
||||
<div class="text-xs text-blue-700/80 dark:text-blue-300/90">
|
||||
Label{' '}
|
||||
<span class="font-semibold">
|
||||
{latestGeneratedRecord()?.name || 'Untitled token'}
|
||||
</span>
|
||||
<Show when={latestGeneratedRecord()?.prefix || latestGeneratedRecord()?.suffix}>
|
||||
{' '}· Hint{' '}
|
||||
<code class="rounded bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 font-mono text-[11px] text-blue-700 dark:text-blue-200">
|
||||
{latestGeneratedRecord()?.prefix}…{latestGeneratedRecord()?.suffix}
|
||||
</code>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reopenLatestTokenDialog}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-3 py-2 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
Show token dialog
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLatestGeneratedToken(null);
|
||||
setLatestGeneratedRecord(null);
|
||||
}}
|
||||
class="inline-flex items-center rounded-md border border-blue-300 dark:border-blue-700 px-3 py-2 text-xs font-medium text-blue-800 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
|
||||
>
|
||||
Dismiss reminder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<DiscoveryModalProps> = (props) => {
|
||||
const [isScanning, setIsScanning] = createSignal(false);
|
||||
const [subnet, setSubnet] = createSignal('auto');
|
||||
const [discoveryResult, setDiscoveryResult] = createSignal<DiscoveryResult | null>(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 (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||
<rect x="9" y="9" width="6" height="6"></rect>
|
||||
<line x1="9" y1="1" x2="9" y2="4"></line>
|
||||
<line x1="15" y1="1" x2="15" y2="4"></line>
|
||||
<line x1="9" y1="20" x2="9" y2="23"></line>
|
||||
<line x1="15" y1="20" x2="15" y2="23"></line>
|
||||
<line x1="20" y1="9" x2="23" y2="9"></line>
|
||||
<line x1="20" y1="14" x2="23" y2="14"></line>
|
||||
<line x1="1" y1="9" x2="4" y2="9"></line>
|
||||
<line x1="1" y1="14" x2="4" y2="14"></line>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (type === 'pmg') {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||
<polyline points="22,6 12,13 2,6"></polyline>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 01-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 011-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 011.52 0C14.51 3.81 17 5 19 5a1 1 0 011 1v7z"></path>
|
||||
<path d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Show when={props.isOpen}>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" onClick={props.onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div class="relative w-full max-w-3xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<SectionHeader title="Network discovery" size="md" class="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div class="p-6">
|
||||
{/* Quick Actions Bar */}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Show when={isScanning()} fallback="Network Servers">
|
||||
Scanning Network...
|
||||
</Show>
|
||||
</h4>
|
||||
<Show when={discoveryResult() && !isScanning()}>
|
||||
<span class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded-full">
|
||||
{discoveryResult()!.servers.length} found
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Subnet selector */}
|
||||
<select
|
||||
value={subnet()}
|
||||
onChange={(e) => {
|
||||
setSubnet(e.currentTarget.value);
|
||||
setHasScanned(false);
|
||||
handleScan();
|
||||
}}
|
||||
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isScanning()}
|
||||
>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="192.168.1.0/24">192.168.1.x</option>
|
||||
<option value="192.168.0.0/24">192.168.0.x</option>
|
||||
<option value="10.0.0.0/24">10.0.0.x</option>
|
||||
<option value="172.16.0.0/24">172.16.0.x</option>
|
||||
</select>
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isScanning()}
|
||||
title="Refresh scan"
|
||||
class="p-1.5 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Show
|
||||
when={isScanning()}
|
||||
fallback={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Loader class="animate-spin h-4 w-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show loading message when scanning with no results yet */}
|
||||
<Show when={isScanning() && !discoveryResult()}>
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Loader class="animate-spin h-12 w-12 mx-auto mb-4 text-blue-500" />
|
||||
<p>Scanning network...</p>
|
||||
<p class="text-xs mt-2">Servers will appear here as they're discovered</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Discovery Results - show even while scanning */}
|
||||
<Show when={discoveryResult()}>
|
||||
<div class="space-y-4">
|
||||
{/* Server Cards */}
|
||||
<Show
|
||||
when={discoveryResult()!.servers.length > 0}
|
||||
fallback={
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="mx-auto mb-3 opacity-50"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
<path d="M11 8v3m0 4h.01"></path>
|
||||
</svg>
|
||||
<p>No Proxmox servers found on this network</p>
|
||||
<p class="text-xs mt-2">
|
||||
Try a different subnet or check if servers are online
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<For each={discoveryResult()!.servers}>
|
||||
{(server) => (
|
||||
<div
|
||||
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 cursor-pointer transition-all hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/30 group"
|
||||
onClick={() => handleAddServer(server)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
{/* Server Icon */}
|
||||
<div
|
||||
class={`mt-0.5 ${
|
||||
server.type === 'pve'
|
||||
? 'text-orange-500'
|
||||
: server.type === 'pmg'
|
||||
? 'text-purple-500'
|
||||
: 'text-green-500'
|
||||
}`}
|
||||
>
|
||||
{getServerIcon(server.type)}
|
||||
</div>
|
||||
|
||||
{/* Server Details */}
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{server.hostname || server.ip}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{server.ip}:{server.port}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{server.type === 'pve'
|
||||
? 'Proxmox VE'
|
||||
: server.type === 'pmg'
|
||||
? 'PMG'
|
||||
: 'PBS'}
|
||||
</span>
|
||||
<Show when={server.version !== 'Unknown'}>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
v{server.version}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Arrow */}
|
||||
<div class="text-gray-400 group-hover:text-blue-500 transition-colors">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Errors */}
|
||||
<Show when={discoveryResult()!.errors && discoveryResult()!.errors!.length > 0}>
|
||||
<div class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<h5 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
|
||||
Discovery Warnings
|
||||
</h5>
|
||||
<ul class="text-xs text-yellow-700 dark:text-yellow-300 space-y-1">
|
||||
<For each={discoveryResult()!.errors}>
|
||||
{(error) => <li>• {error}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex items-center justify-center px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<Date | null>(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 = '<api-token>';
|
||||
|
||||
|
|
@ -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<boolean> => {
|
||||
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"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">Recently removed Docker hosts</h3>
|
||||
<h3 class="text-sm font-semibold">Recently removed container hosts</h3>
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
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 <code>docker:manage</code> scope.
|
||||
|
|
@ -603,10 +577,10 @@ WantedBy=multi-user.target`;
|
|||
</Show>
|
||||
|
||||
<Card padding="lg" class="space-y-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Add a Docker host</h3>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Enroll a container runtime</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -678,7 +652,7 @@ WantedBy=multi-user.target`;
|
|||
<code>{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}</code>
|
||||
</pre>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -773,13 +747,13 @@ WantedBy=multi-user.target`;
|
|||
</details>
|
||||
</Card>
|
||||
|
||||
{/* Remove Docker Host Modal */}
|
||||
{/* Remove Container Host Modal */}
|
||||
<Show when={showRemoveModal()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Remove Docker host "{modalDisplayName()}"
|
||||
Remove container host "{modalDisplayName()}"
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Pulse guides you through uninstalling the agent and safely cleaning up the host entry.
|
||||
|
|
@ -1161,7 +1135,7 @@ WantedBy=multi-user.target`;
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Active Docker Hosts */}
|
||||
{/* Active container hosts */}
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
{/* Pending hosts banner */}
|
||||
|
|
@ -1248,21 +1222,21 @@ WantedBy=multi-user.target`;
|
|||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Reporting Docker hosts ({dockerHosts().length})
|
||||
Reporting container hosts ({dockerHosts().length})
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<a
|
||||
href="/docker"
|
||||
href="/containers"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-600/60 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Open Docker monitoring
|
||||
Open container monitoring
|
||||
</a>
|
||||
<Show when={hiddenCount() > 0}>
|
||||
<button
|
||||
|
|
@ -1291,7 +1265,7 @@ WantedBy=multi-user.target`;
|
|||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
No Docker agents are currently reporting.
|
||||
No container agents are currently reporting.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Click "Show deployment instructions" above to get started.
|
||||
|
|
@ -1305,7 +1279,7 @@ WantedBy=multi-user.target`;
|
|||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Host</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Agent & Docker</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Agent & runtime</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Last Seen</th>
|
||||
<th class="text-right py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
|
|
@ -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`;
|
|||
<td class="py-3 px-4 align-top">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{displayName}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{host.hostname}</div>
|
||||
<div class="mt-1 text-[10px] uppercase tracking-wide">
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold ${runtimeInfo.badgeClass}`}
|
||||
title={runtimeInfo.raw || runtimeInfo.label}
|
||||
>
|
||||
{runtimeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={host.os || host.architecture}>
|
||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{host.os}
|
||||
|
|
@ -1396,16 +1379,19 @@ WantedBy=multi-user.target`;
|
|||
</Show>
|
||||
</div>
|
||||
<Show when={removalStatusInfo}>
|
||||
{(info) => (
|
||||
<div class={`mt-2 text-xs ${removalTextClassMap[info.tone]}`}>
|
||||
{info.label}
|
||||
</div>
|
||||
)}
|
||||
{(info) => {
|
||||
const details = info();
|
||||
return (
|
||||
<div class={`mt-2 text-xs ${removalTextClassMap[details.tone]}`}>
|
||||
{details.label}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{host.dockerVersion ? `Docker ${host.dockerVersion}` : 'Docker version unavailable'}
|
||||
{describeRuntime(host)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Agent {host.agentVersion ? `v${host.agentVersion}` : 'not reporting'}
|
||||
|
|
|
|||
|
|
@ -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 = '<api-token>';
|
||||
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<boolean> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14.94 5.19A4.38 4.38 0 0 0 16 2a4.44 4.44 0 0 0-3 1.52 4.17 4.17 0 0 0-1 3.09 3.69 3.69 0 0 0 2.94-1.42zm2.52 7.44a4.51 4.51 0 0 1 2.16-3.81 4.66 4.66 0 0 0-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87A4.92 4.92 0 0 0 4.69 9.39C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 0 0 1.38-2.85 4.41 4.41 0 0 1-2.68-4.08z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WindowsIcon = () => (
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<HostsSectionNavProps> = (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 (
|
||||
<div class={`flex flex-wrap items-center gap-3 sm:gap-4 ${props.class ?? ''}`} aria-label="Host platform sections">
|
||||
<For each={allSections}>
|
||||
{(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 (
|
||||
<button
|
||||
type="button"
|
||||
class={classes()}
|
||||
onClick={() => props.onSelect(section.id)}
|
||||
aria-pressed={isActive()}
|
||||
>
|
||||
<Icon size={16} stroke-width={2} />
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<NodeModalProps> = (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<NodeModalProps> = (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<NodeModalProps> = (props) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
console.log('[Quick Setup] Copy button clicked');
|
||||
logger.debug('[Quick Setup] Copy button clicked');
|
||||
try {
|
||||
// Check if host is populated
|
||||
if (!formData().host || formData().host.trim() === '') {
|
||||
console.log('[Quick Setup] No host entered');
|
||||
logger.debug('[Quick Setup] No host entered');
|
||||
showError('Please enter the Host URL first');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Quick Setup] Generating setup URL for host:', formData().host);
|
||||
logger.debug('[Quick Setup] Generating setup URL for host', {
|
||||
host: formData().host,
|
||||
});
|
||||
// Always regenerate URL when host changes
|
||||
const { apiFetch } = await import('@/utils/apiClient');
|
||||
const response = await apiFetch('/api/setup-script-url', {
|
||||
|
|
@ -673,10 +677,13 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
}),
|
||||
});
|
||||
|
||||
console.log('[Quick Setup] API response:', response.status, response.ok);
|
||||
logger.debug('[Quick Setup] API response', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('[Quick Setup] Setup data received:', data);
|
||||
logger.debug('[Quick Setup] Setup data received', data);
|
||||
|
||||
setQuickSetupToken(data.setupToken ?? '');
|
||||
setQuickSetupExpiry(typeof data.expires === 'number' ? data.expires : null);
|
||||
|
|
@ -684,17 +691,17 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
// Backend returns url, command, and expires
|
||||
// Just copy the command - don't show the modal
|
||||
if (data.command) {
|
||||
console.log('[Quick Setup] Copying command to clipboard:', data.command);
|
||||
logger.debug('[Quick Setup] Copying command to clipboard');
|
||||
setQuickSetupCommand(data.command);
|
||||
const copied = await copyToClipboard(data.command);
|
||||
console.log('[Quick Setup] Copy result:', copied);
|
||||
logger.debug('[Quick Setup] Copy result', copied);
|
||||
if (copied) {
|
||||
showSuccess('Command copied to clipboard! Paste the setup token shown below when prompted.');
|
||||
} else {
|
||||
showError('Failed to copy to clipboard');
|
||||
}
|
||||
} else {
|
||||
console.log('[Quick Setup] No command in response');
|
||||
logger.debug('[Quick Setup] No command in response');
|
||||
}
|
||||
} else {
|
||||
setQuickSetupToken('');
|
||||
|
|
@ -702,7 +709,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
showError('Failed to generate setup URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Quick Setup] Error:', error);
|
||||
logger.error('[Quick Setup] Error:', error);
|
||||
setQuickSetupToken('');
|
||||
setQuickSetupExpiry(null);
|
||||
showError('Failed to copy command');
|
||||
|
|
@ -799,7 +806,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
const hostValue = formData().host || '';
|
||||
const encodedHost = encodeURIComponent(hostValue);
|
||||
const pulseUrl = encodeURIComponent(
|
||||
window.location.origin,
|
||||
getPulseBaseUrl(),
|
||||
);
|
||||
const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}&backup_perms=true`;
|
||||
|
||||
|
|
@ -827,7 +834,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
'Script downloaded! Upload it to your server and run: bash pulse-setup.sh',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to download script:', error);
|
||||
logger.error('Failed to download script:', error);
|
||||
showSuccess(
|
||||
'Failed to download script. Please check your connection.',
|
||||
);
|
||||
|
|
@ -1173,7 +1180,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
showError('Failed to generate setup URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy command:', error);
|
||||
logger.error('Failed to copy command:', error);
|
||||
showError('Failed to copy command');
|
||||
}
|
||||
}}
|
||||
|
|
@ -1269,7 +1276,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
const hostValue = formData().host || '';
|
||||
const encodedHost = encodeURIComponent(hostValue);
|
||||
const pulseUrl = encodeURIComponent(
|
||||
window.location.origin,
|
||||
getPulseBaseUrl(),
|
||||
);
|
||||
const scriptUrl = `/api/setup-script?type=pbs&host=${encodedHost}&pulse_url=${pulseUrl}`;
|
||||
|
||||
|
|
@ -1297,7 +1304,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
'Script downloaded! Upload it to your PBS and run: bash pulse-pbs-setup.sh',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to download script:', error);
|
||||
logger.error('Failed to download script:', error);
|
||||
showSuccess(
|
||||
'Failed to download script. Please check your connection.',
|
||||
);
|
||||
|
|
@ -1801,7 +1808,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
|
|||
<Show when={testResult()}>
|
||||
{(() => {
|
||||
const result = testResult();
|
||||
console.log('Test result display:', {
|
||||
logger.debug('Test result display', {
|
||||
status: result?.status,
|
||||
message: result?.message,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
|
|||
import { Toggle } from '@/components/shared/Toggle';
|
||||
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface OIDCConfigResponse {
|
||||
enabled: boolean;
|
||||
|
|
@ -115,7 +116,7 @@ export const OIDCPanel: Component<Props> = (props) => {
|
|||
resetForm(data);
|
||||
props.onConfigUpdated?.(data);
|
||||
} catch (error) {
|
||||
console.error('[OIDCPanel] Failed to load config:', error);
|
||||
logger.error('[OIDCPanel] Failed to load config:', error);
|
||||
notificationStore.error('Failed to load OIDC settings');
|
||||
setConfig(null);
|
||||
resetForm(null);
|
||||
|
|
@ -177,7 +178,7 @@ export const OIDCPanel: Component<Props> = (props) => {
|
|||
notificationStore.success('OIDC settings updated');
|
||||
props.onConfigUpdated?.(updated);
|
||||
} catch (error) {
|
||||
console.error('[OIDCPanel] Failed to save config:', error);
|
||||
logger.error('[OIDCPanel] Failed to save config:', error);
|
||||
notificationStore.error('Failed to save OIDC settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Component, createSignal, Show } from 'solid-js';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { clearStoredAPIToken } from '@/utils/tokenStorage';
|
||||
import { clearApiToken as clearApiClientToken } from '@/utils/apiClient';
|
||||
import { clearAuth as clearApiClientAuth } from '@/utils/apiClient';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
||||
|
||||
|
|
@ -124,19 +123,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
|||
setCredentials(newCredentials);
|
||||
setShowCredentials(true);
|
||||
|
||||
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();
|
||||
|
||||
// Show success message
|
||||
showSuccess(
|
||||
|
|
@ -146,9 +133,6 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
|||
);
|
||||
|
||||
// DON'T notify parent yet - wait until user dismisses credentials
|
||||
// if (props.onConfigured) {
|
||||
// props.onConfigured();
|
||||
// }
|
||||
} catch (error) {
|
||||
showError(`Failed to setup security: ${error}`);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import { useNavigate, useLocation } from '@solidjs/router';
|
|||
import { useWebSocket } from '@/App';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { clearStoredAPIToken, getStoredAPIToken, setStoredAPIToken } from '@/utils/tokenStorage';
|
||||
import { getPulsePort, getPulseWebSocketUrl } from '@/utils/url';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
clearApiToken as clearApiClientToken,
|
||||
getApiToken as getApiClientToken,
|
||||
setApiToken as setApiClientToken,
|
||||
} from '@/utils/apiClient';
|
||||
import { NodeModal } from './NodeModal';
|
||||
|
|
@ -50,9 +52,9 @@ import Monitor from 'lucide-solid/icons/monitor';
|
|||
import Sliders from 'lucide-solid/icons/sliders-horizontal';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
import Clock from 'lucide-solid/icons/clock';
|
||||
import { ApiIcon } from '@/components/icons/ApiIcon';
|
||||
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
|
||||
import { DockerIcon } from '@/components/icons/DockerIcon';
|
||||
import Boxes from 'lucide-solid/icons/boxes';
|
||||
import BadgeCheck from 'lucide-solid/icons/badge-check';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
import type { UpdateInfo, VersionInfo } from '@/api/updates';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
|
|
@ -272,7 +274,7 @@ type SettingsTab =
|
|||
| 'diagnostics'
|
||||
| 'updates';
|
||||
|
||||
type AgentKey = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host';
|
||||
type AgentKey = 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: string }> = {
|
||||
proxmox: {
|
||||
|
|
@ -370,7 +372,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
if (
|
||||
path.includes('/settings/hosts') ||
|
||||
path.includes('/settings/host-agents') ||
|
||||
path.includes('/settings/servers')
|
||||
path.includes('/settings/servers') ||
|
||||
path.includes('/settings/linuxServers') ||
|
||||
path.includes('/settings/windowsServers') ||
|
||||
path.includes('/settings/macServers')
|
||||
)
|
||||
return 'hosts';
|
||||
if (path.includes('/settings/system-general')) return 'system-general';
|
||||
|
|
@ -404,16 +409,6 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
if (path.includes('/settings/pve')) return 'pve';
|
||||
if (path.includes('/settings/pbs')) return 'pbs';
|
||||
if (path.includes('/settings/pmg')) return 'pmg';
|
||||
if (path.includes('/settings/docker')) return 'docker';
|
||||
if (
|
||||
path.includes('/settings/host') ||
|
||||
path.includes('/settings/host-agents') ||
|
||||
path.includes('/settings/linuxServers') ||
|
||||
path.includes('/settings/windowsServers') ||
|
||||
path.includes('/settings/macServers')
|
||||
) {
|
||||
return 'host';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -428,8 +423,6 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
pve: '/settings/pve',
|
||||
pbs: '/settings/pbs',
|
||||
pmg: '/settings/pmg',
|
||||
docker: '/settings/docker',
|
||||
host: '/settings/host-agents',
|
||||
};
|
||||
|
||||
const handleSelectAgent = (agent: AgentKey) => {
|
||||
|
|
@ -444,10 +437,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
};
|
||||
|
||||
const setActiveTab = (tab: SettingsTab) => {
|
||||
if (tab === 'proxmox' && !['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) {
|
||||
if (tab === 'proxmox' && deriveAgentFromPath(location.pathname) === null) {
|
||||
setSelectedAgent('pve');
|
||||
}
|
||||
|
||||
const targetPath = `/settings/${tab}`;
|
||||
if (location.pathname !== targetPath) {
|
||||
navigate(targetPath, { scroll: false });
|
||||
|
|
@ -496,6 +488,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
path.startsWith('/settings/linuxServers') ||
|
||||
path.startsWith('/settings/windowsServers') ||
|
||||
path.startsWith('/settings/macServers')
|
||||
) {
|
||||
navigate('/settings/hosts', {
|
||||
replace: true,
|
||||
scroll: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = deriveTabFromPath(path);
|
||||
if (resolved !== currentTab()) {
|
||||
setCurrentTab(resolved);
|
||||
|
|
@ -503,11 +507,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
|
||||
if (resolved === 'proxmox') {
|
||||
const agentFromPath = deriveAgentFromPath(path);
|
||||
if (agentFromPath) {
|
||||
setSelectedAgent(agentFromPath);
|
||||
} else if (!['pve', 'pbs', 'pmg', 'docker', 'host'].includes(selectedAgent())) {
|
||||
setSelectedAgent('pve');
|
||||
}
|
||||
setSelectedAgent(agentFromPath ?? 'pve');
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -776,7 +776,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const diag = await response.json();
|
||||
setDiagnosticsData(diag);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch diagnostics:', err);
|
||||
logger.error('Failed to fetch diagnostics', err);
|
||||
showError('Failed to run diagnostics');
|
||||
} finally {
|
||||
setRunningDiagnostics(false);
|
||||
|
|
@ -810,7 +810,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
showSuccess('Queried proxy node registration state');
|
||||
await runDiagnostics();
|
||||
} catch (err) {
|
||||
console.error('Failed to query proxy node registration state:', err);
|
||||
logger.error('Failed to query proxy node registration state', err);
|
||||
showError('Failed to query proxy nodes');
|
||||
} finally {
|
||||
setProxyActionLoading(null);
|
||||
|
|
@ -874,7 +874,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
showSuccess(`Generated dedicated token for ${hostName}`);
|
||||
await runDiagnostics();
|
||||
} catch (err) {
|
||||
console.error('Failed to prepare Docker token', err);
|
||||
logger.error('Failed to prepare Docker token', err);
|
||||
showError('Failed to prepare Docker token');
|
||||
} finally {
|
||||
setDockerActionLoading(null);
|
||||
|
|
@ -893,26 +893,33 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const tabGroups: {
|
||||
id: 'platforms' | 'operations' | 'system' | 'security';
|
||||
label: string;
|
||||
items: { id: SettingsTab; label: string; icon: JSX.Element; disabled?: boolean }[];
|
||||
items: {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: Component<{ class?: string; strokeWidth?: number }>;
|
||||
iconProps?: { strokeWidth?: number };
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
}[] = [
|
||||
{
|
||||
id: 'platforms',
|
||||
label: 'Platforms',
|
||||
items: [
|
||||
{ id: 'proxmox', label: 'Proxmox', icon: <ProxmoxIcon class="w-4 h-4" /> },
|
||||
{ id: 'docker', label: 'Docker', icon: <DockerIcon class="w-4 h-4" /> },
|
||||
{ id: 'hosts', label: 'Hosts', icon: <Monitor class="w-4 h-4" strokeWidth={2} /> },
|
||||
{ id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon },
|
||||
{ id: 'docker', label: 'Docker', icon: Boxes },
|
||||
{ id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ id: 'api', label: 'API Tokens', icon: <ApiIcon class="w-4 h-4" /> },
|
||||
{ id: 'api', label: 'API Tokens', icon: BadgeCheck },
|
||||
{
|
||||
id: 'diagnostics',
|
||||
label: 'Diagnostics',
|
||||
icon: <Activity class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Activity,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -923,19 +930,27 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{
|
||||
id: 'system-general',
|
||||
label: 'General',
|
||||
icon: <Sliders class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Sliders,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-network',
|
||||
label: 'Network',
|
||||
icon: <Network class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Network,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-updates',
|
||||
label: 'Updates',
|
||||
icon: <RefreshCw class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: RefreshCw,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-backups',
|
||||
label: 'Backups',
|
||||
icon: Clock,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{ id: 'system-backups', label: 'Backups', icon: <Clock class="w-4 h-4" strokeWidth={2} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -945,17 +960,20 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{
|
||||
id: 'security-overview',
|
||||
label: 'Overview',
|
||||
icon: <Shield class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Shield,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'security-auth',
|
||||
label: 'Authentication',
|
||||
icon: <Lock class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Lock,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'security-sso',
|
||||
label: 'Single Sign-On',
|
||||
icon: <Key class="w-4 h-4" strokeWidth={2} />,
|
||||
icon: Key,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -1002,13 +1020,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
});
|
||||
setNodes(nodesWithStatus);
|
||||
} catch (error) {
|
||||
console.error('Failed to load nodes:', error);
|
||||
logger.error('Failed to load nodes', error);
|
||||
// If we get a 429 or network error, retry after a delay
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('429') || error.message.includes('fetch'))
|
||||
) {
|
||||
console.log('Retrying node load after delay...');
|
||||
logger.info('Retrying node load after delay');
|
||||
setTimeout(() => loadNodes(), 3000);
|
||||
}
|
||||
}
|
||||
|
|
@ -1022,13 +1040,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const response = await apiFetch('/api/security/status');
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
console.log('Security status loaded:', status);
|
||||
logger.debug('Security status loaded', status);
|
||||
setSecurityStatus(status);
|
||||
} else {
|
||||
console.error('Failed to fetch security status:', response.status);
|
||||
logger.error('Failed to fetch security status', { status: response.status });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch security status:', err);
|
||||
logger.error('Failed to fetch security status', err);
|
||||
} finally {
|
||||
setSecurityStatusLoading(false);
|
||||
}
|
||||
|
|
@ -1182,7 +1200,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load discovered nodes:', error);
|
||||
logger.error('Failed to load discovered nodes', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1216,7 +1234,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
notificationStore.info('Discovery scan started', 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start discovery scan:', error);
|
||||
logger.error('Failed to start discovery scan', error);
|
||||
notificationStore.error('Failed to start discovery scan');
|
||||
setDiscoveryScanStatus((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -1285,7 +1303,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery setting:', error);
|
||||
logger.error('Failed to update discovery setting', error);
|
||||
notificationStore.error('Failed to update discovery setting');
|
||||
setDiscoveryEnabled(previousEnabled);
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
|
|
@ -1348,7 +1366,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery subnet:', error);
|
||||
logger.error('Failed to update discovery subnet', error);
|
||||
notificationStore.error('Failed to update discovery subnet');
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet));
|
||||
|
|
@ -1388,7 +1406,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
4000,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery subnet:', error);
|
||||
logger.error('Failed to update discovery subnet', error);
|
||||
notificationStore.error('Failed to update discovery subnet');
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
} finally {
|
||||
|
|
@ -1539,50 +1557,43 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
|
||||
// Load system settings
|
||||
try {
|
||||
const systemResponse = await fetch('/api/config/system');
|
||||
if (systemResponse.ok) {
|
||||
const systemSettings = await systemResponse.json();
|
||||
// PBS polling interval is now fixed at 10 seconds
|
||||
setAllowedOrigins(systemSettings.allowedOrigins || '*');
|
||||
// Connection timeout is backend-only
|
||||
// Load discovery settings
|
||||
// Backend defaults to false, so we should respect that
|
||||
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); // Default to false if undefined
|
||||
applySavedDiscoverySubnet(systemSettings.discoverySubnet);
|
||||
// Load embedding settings
|
||||
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
||||
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
||||
// Backup polling controls
|
||||
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
|
||||
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
|
||||
} else {
|
||||
setBackupPollingEnabled(true);
|
||||
}
|
||||
const intervalSeconds =
|
||||
typeof systemSettings.backupPollingInterval === 'number'
|
||||
? Math.max(0, Math.floor(systemSettings.backupPollingInterval))
|
||||
: 0;
|
||||
setBackupPollingInterval(intervalSeconds);
|
||||
if (intervalSeconds > 0) {
|
||||
setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60)));
|
||||
}
|
||||
// Load auto-update settings
|
||||
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
|
||||
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
|
||||
setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00');
|
||||
if (systemSettings.updateChannel) {
|
||||
setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc');
|
||||
}
|
||||
// Track environment variable overrides
|
||||
if (systemSettings.envOverrides) {
|
||||
setEnvOverrides(systemSettings.envOverrides);
|
||||
}
|
||||
const systemSettings = await SettingsAPI.getSystemSettings();
|
||||
// PBS polling interval is now fixed at 10 seconds
|
||||
setAllowedOrigins(systemSettings.allowedOrigins || '*');
|
||||
// Connection timeout is backend-only
|
||||
// Load discovery settings (default to false when unset)
|
||||
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false);
|
||||
applySavedDiscoverySubnet(systemSettings.discoverySubnet);
|
||||
// Load embedding settings
|
||||
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
||||
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
||||
// Backup polling controls
|
||||
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
|
||||
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
|
||||
} else {
|
||||
// Fallback to old endpoint
|
||||
await SettingsAPI.getSettings();
|
||||
setBackupPollingEnabled(true);
|
||||
}
|
||||
const intervalSeconds =
|
||||
typeof systemSettings.backupPollingInterval === 'number'
|
||||
? Math.max(0, Math.floor(systemSettings.backupPollingInterval))
|
||||
: 0;
|
||||
setBackupPollingInterval(intervalSeconds);
|
||||
if (intervalSeconds > 0) {
|
||||
setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60)));
|
||||
}
|
||||
// Load auto-update settings
|
||||
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
|
||||
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
|
||||
setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00');
|
||||
if (systemSettings.updateChannel) {
|
||||
setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc');
|
||||
}
|
||||
// Track environment variable overrides
|
||||
if (systemSettings.envOverrides) {
|
||||
setEnvOverrides(systemSettings.envOverrides);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
logger.error('Failed to load settings', error);
|
||||
}
|
||||
|
||||
// Load version information
|
||||
|
|
@ -1595,10 +1606,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
setUpdateChannel(version.channel as 'stable' | 'rc');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load version:', error);
|
||||
logger.error('Failed to load version', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
logger.error('Failed to load configuration', error);
|
||||
} finally {
|
||||
// Mark initial load as complete even if there were errors
|
||||
setInitialLoadComplete(true);
|
||||
|
|
@ -1766,7 +1777,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
} catch (error) {
|
||||
showError('Failed to check for updates');
|
||||
console.error('Update check error:', error);
|
||||
logger.error('Update check error', error);
|
||||
} finally {
|
||||
setCheckingForUpdates(false);
|
||||
}
|
||||
|
|
@ -1795,7 +1806,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
// Only check for API token if user is not authenticated via password
|
||||
// If user is logged in with password, session auth is sufficient
|
||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getStoredAPIToken()) {
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
|
||||
setApiTokenModalSource('export');
|
||||
setShowApiTokenModal(true);
|
||||
return;
|
||||
|
|
@ -1818,7 +1829,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
|
||||
// Add API token if configured
|
||||
const apiToken = getStoredAPIToken();
|
||||
const apiToken = getApiClientToken();
|
||||
if (apiToken) {
|
||||
headers['X-API-Token'] = apiToken;
|
||||
}
|
||||
|
|
@ -1838,9 +1849,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth) {
|
||||
// Clear invalid token if we had one
|
||||
const hadToken = getStoredAPIToken();
|
||||
const hadToken = getApiClientToken();
|
||||
if (hadToken) {
|
||||
clearStoredAPIToken();
|
||||
clearApiClientToken();
|
||||
showError('Invalid or expired API token. Please re-enter.');
|
||||
setApiTokenModalSource('export');
|
||||
|
|
@ -1878,7 +1888,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to export configuration';
|
||||
showError(errorMessage);
|
||||
console.error('Export error:', error);
|
||||
logger.error('Export error', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1896,7 +1906,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
// Only check for API token if user is not authenticated via password
|
||||
// If user is logged in with password, session auth is sufficient
|
||||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getStoredAPIToken()) {
|
||||
if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) {
|
||||
setApiTokenModalSource('import');
|
||||
setShowApiTokenModal(true);
|
||||
return;
|
||||
|
|
@ -1909,7 +1919,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
exportData = JSON.parse(fileContent);
|
||||
} catch (parseError) {
|
||||
showError('Invalid JSON file format');
|
||||
console.error('JSON parse error:', parseError);
|
||||
logger.error('JSON parse error', parseError);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1929,7 +1939,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
|
||||
// Add API token if configured
|
||||
const apiToken = getStoredAPIToken();
|
||||
const apiToken = getApiClientToken();
|
||||
if (apiToken) {
|
||||
headers['X-API-Token'] = apiToken;
|
||||
}
|
||||
|
|
@ -1952,9 +1962,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const hasPasswordAuth = securityStatus()?.hasAuthentication;
|
||||
if (!hasPasswordAuth) {
|
||||
// Clear invalid token if we had one
|
||||
const hadToken = getStoredAPIToken();
|
||||
const hadToken = getApiClientToken();
|
||||
if (hadToken) {
|
||||
clearStoredAPIToken();
|
||||
clearApiClientToken();
|
||||
showError('Invalid or expired API token. Please re-enter.');
|
||||
setApiTokenModalSource('import');
|
||||
|
|
@ -1981,7 +1990,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
showError('Failed to import configuration');
|
||||
console.error('Import error:', error);
|
||||
logger.error('Import error', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2136,7 +2145,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}}
|
||||
title={sidebarCollapsed() ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<item.icon class="w-4 h-4" {...(item.iconProps || {})} />
|
||||
<Show when={!sidebarCollapsed()}>
|
||||
<span class="truncate">{item.label}</span>
|
||||
</Show>
|
||||
|
|
@ -3059,13 +3068,6 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Docker Tab */}
|
||||
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'docker'}>
|
||||
<div class="space-y-6 mt-6">
|
||||
<DockerAgents />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Docker Platform Tab */}
|
||||
<Show when={activeTab() === 'docker'}>
|
||||
<DockerAgents />
|
||||
|
|
@ -3076,13 +3078,6 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<HostAgents />
|
||||
</Show>
|
||||
|
||||
{/* Host Agents */}
|
||||
<Show when={activeTab() === 'proxmox' && selectedAgent() === 'host'}>
|
||||
<div class="space-y-6 mt-6">
|
||||
<HostAgents />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* System General Tab */}
|
||||
<Show when={activeTab() === 'system-general'}>
|
||||
<div class="space-y-6">
|
||||
|
|
@ -4265,7 +4260,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<ApiIcon class="w-5 h-5 text-blue-600 dark:text-blue-300" />
|
||||
<BadgeCheck class="w-5 h-5 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="API Access"
|
||||
|
|
@ -5448,8 +5443,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Server Port:</span>
|
||||
<span class="font-medium">
|
||||
{window.location.port ||
|
||||
(window.location.protocol === 'https:' ? '443' : '80')}
|
||||
{getPulsePort()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -6163,7 +6157,7 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (props) => {
|
|||
onClick={() => {
|
||||
if (apiTokenInput()) {
|
||||
const tokenValue = apiTokenInput()!;
|
||||
setStoredAPIToken(tokenValue);
|
||||
setApiClientToken(tokenValue);
|
||||
const source = apiTokenModalSource();
|
||||
setShowApiTokenModal(false);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<UpdateHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = createSignal<string>('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 (
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded">
|
||||
Success
|
||||
</span>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 rounded">
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
case 'in_progress':
|
||||
return (
|
||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded">
|
||||
In Progress
|
||||
</span>
|
||||
);
|
||||
case 'rolled_back':
|
||||
return (
|
||||
<span class="px-2 py-1 text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 rounded">
|
||||
Rolled Back
|
||||
</span>
|
||||
);
|
||||
case 'cancelled':
|
||||
return (
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-900/30 text-gray-800 dark:text-gray-200 rounded">
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return <span class="px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-900/30 text-gray-800 dark:text-gray-200 rounded">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div class="space-y-6">
|
||||
<SectionHeader title="Update History" />
|
||||
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
{/* Filter Controls */}
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Filter by status:
|
||||
</label>
|
||||
<select
|
||||
value={filterStatus()}
|
||||
onChange={(e) => setFilterStatus(e.currentTarget.value)}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="rolled_back">Rolled Back</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Loading update history...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error State */}
|
||||
<Show when={error()}>
|
||||
<div class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400">{error()}</div>
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
class="mt-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!loading() && !error() && filteredHistory().length === 0}>
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No update history available
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* History Table */}
|
||||
<Show when={!loading() && !error() && filteredHistory().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Version
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Deployment
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={filteredHistory()}>
|
||||
{(entry) => (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(entry.timestamp)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<span class="capitalize">{entry.action}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{entry.version_from} → {entry.version_to}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
{getStatusBadge(entry.status)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDuration(entry.duration_ms)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="capitalize">{entry.deployment_type}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Details Section - Could be expanded with more info */}
|
||||
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {filteredHistory().length} {filteredHistory().length === 1 ? 'entry' : 'entries'}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(instruction, index())}
|
||||
onClick={() => handleCopy(instruction, index())}
|
||||
class="flex-shrink-0 p-1 hover:bg-blue-100 dark:hover:bg-blue-800/30 rounded transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createSignal, Show, onMount, onCleanup, createEffect } from 'solid-js';
|
||||
import { UpdatesAPI, type UpdateStatus } from '@/api/updates';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface UpdateProgressModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -57,7 +58,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll update status:', error);
|
||||
logger.error('Failed to poll update status', error);
|
||||
// If we get errors during update, assume we're restarting
|
||||
const currentStatus = status();
|
||||
const shouldAssumeRestart =
|
||||
|
|
@ -95,12 +96,12 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
|
|||
isHealthy = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Health check request failed, will retry', error);
|
||||
logger.warn('Health check request failed, will retry', error);
|
||||
}
|
||||
|
||||
if (isHealthy) {
|
||||
// Backend is back! Reload the page to get the new version
|
||||
console.log('Backend is healthy again, reloading...');
|
||||
logger.info('Backend is healthy again, reloading...');
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
|
@ -130,17 +131,17 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
|
|||
|
||||
// If websocket reconnected after being disconnected, the backend is likely back
|
||||
if (wsDisconnected() && connected === true && !reconnecting) {
|
||||
console.log('WebSocket reconnected after restart, verifying health...');
|
||||
logger.info('WebSocket reconnected after restart, verifying health...');
|
||||
// Give it a moment for the backend to fully initialize
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/health', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
console.log('Backend healthy after websocket reconnect, reloading...');
|
||||
logger.info('Backend healthy after websocket reconnect, reloading...');
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (_error) {
|
||||
console.warn('Health check failed after websocket reconnect, will keep trying');
|
||||
logger.warn('Health check failed after websocket reconnect, will keep trying');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import Bell from 'lucide-solid/icons/bell';
|
||||
|
||||
interface AlertsIconProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const AlertsIcon: Component<AlertsIconProps> = (props) => (
|
||||
<Bell {...props} strokeWidth={2} />
|
||||
);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
|
||||
interface ApiIconProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const ApiIcon: Component<ApiIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 7v10" />
|
||||
<path d="M20 7v10" />
|
||||
<path d="M9 12h6" />
|
||||
<path d="M12 9v6" />
|
||||
<rect x="2.5" y="5" width="19" height="14" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ApiIcon;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { siDocker } from 'simple-icons';
|
||||
|
||||
interface DockerIconProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DockerIcon: Component<DockerIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={props.title ?? 'Docker'}
|
||||
>
|
||||
<title>{props.title ?? 'Docker'}</title>
|
||||
<path d={siDocker.path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import Monitor from 'lucide-solid/icons/monitor';
|
||||
|
||||
interface HostsIconProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const HostsIcon: Component<HostsIconProps> = (props) => (
|
||||
<Monitor class={props.class} />
|
||||
);
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { siProxmox } from 'simple-icons';
|
||||
|
||||
interface ProxmoxBackupIconProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ProxmoxBackupIcon: Component<ProxmoxBackupIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label={props.title ?? 'Proxmox Backup'}
|
||||
>
|
||||
<title>{props.title ?? 'Proxmox Backup'}</title>
|
||||
<path d={siProxmox.path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { siProxmox } from 'simple-icons';
|
||||
|
||||
interface ProxmoxIconProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Path data sourced from simple-icons (CC0); inlined to avoid pulling the full dependency.
|
||||
const PROXMOX_PATH =
|
||||
'M4.928 1.825c-1.09.553-1.09.64-.07 1.78 5.655 6.295 7.004 7.782 7.107 7.782.139.017 7.971-8.542 8.058-8.801.034-.07-.208-.312-.519-.536-.415-.312-.864-.433-1.712-.467-1.59-.104-2.144.242-4.115 2.455-.899 1.003-1.66 1.833-1.66 1.833-.017 0-.76-.813-1.642-1.798S8.473 2.1 8.127 1.91c-.796-.45-2.421-.484-3.2-.086zM1.297 4.367C.45 4.695 0 5.007 0 5.248c0 .121 1.331 1.678 2.94 3.459 1.625 1.78 2.939 3.268 2.939 3.302 0 .035-1.331 1.522-2.94 3.303C1.314 17.11.017 18.683.035 18.822c.086.467 1.504 1.055 2.541 1.055 1.678-.018 2.058-.312 5.603-4.202 1.78-1.954 3.233-3.614 3.233-3.666 0-.069-1.435-1.694-3.199-3.63-2.3-2.508-3.423-3.632-3.96-3.874-.812-.398-2.126-.467-2.956-.138zm18.467.12c-.502.26-1.764 1.505-3.943 3.891-1.763 1.937-3.199 3.562-3.199 3.631 0 .07 1.453 1.712 3.234 3.666 3.544 3.89 3.925 4.184 5.602 4.202 1.038 0 2.455-.588 2.542-1.055.017-.156-1.28-1.712-2.905-3.493-1.608-1.78-2.94-3.285-2.94-3.32 0-.034 1.332-1.539 2.94-3.32C22.72 6.91 24.017 5.352 24 5.214c-.087-.45-1.366-.968-2.473-1.038-.795-.034-1.21.035-1.763.312zM7.954 16.973c-2.144 2.369-3.908 4.374-3.943 4.46-.034.07.208.312.52.537.414.311.864.432 1.711.467 1.574.103 2.161-.26 4.15-2.508.864-.968 1.608-1.78 1.625-1.78s.761.812 1.643 1.798c2.023 2.248 2.559 2.576 4.132 2.49.848-.035 1.297-.156 1.712-.467.311-.225.553-.467.519-.536-.087-.26-7.92-8.819-8.058-8.801-.069 0-1.867 1.954-4.011 4.34z';
|
||||
|
||||
export const ProxmoxIcon: Component<ProxmoxIconProps> = (props) => (
|
||||
<svg
|
||||
class={props.class}
|
||||
|
|
@ -15,6 +18,6 @@ export const ProxmoxIcon: Component<ProxmoxIconProps> = (props) => (
|
|||
aria-label={props.title ?? 'Proxmox'}
|
||||
>
|
||||
<title>{props.title ?? 'Proxmox'}</title>
|
||||
<path d={siProxmox.path} fill="currentColor" />
|
||||
<path d={PROXMOX_PATH} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import Settings from 'lucide-solid/icons/settings';
|
||||
|
||||
interface SettingsGearIconProps {
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const SettingsGearIcon: Component<SettingsGearIconProps> = (props) => (
|
||||
<Settings {...props} strokeWidth={2} />
|
||||
);
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { Component, createMemo } from 'solid-js';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
const getMetadataUnit = (alert: Alert): string | undefined => {
|
||||
const rawUnit = alert.metadata?.['unit'];
|
||||
if (typeof rawUnit === 'string') {
|
||||
const trimmed = rawUnit.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const formatAlertValue = (alert: Alert): string => {
|
||||
const metric = alert.type.toLowerCase();
|
||||
const unitFromMetadata = getMetadataUnit(alert);
|
||||
|
||||
switch (metric) {
|
||||
case 'temperature':
|
||||
return `${alert.value.toFixed(1)}°C`;
|
||||
case 'diskread':
|
||||
case 'diskwrite':
|
||||
case 'networkin':
|
||||
case 'networkout':
|
||||
return `${alert.value.toFixed(1)} MB/s`;
|
||||
case 'cpu':
|
||||
case 'memory':
|
||||
case 'disk':
|
||||
case 'usage':
|
||||
return `${alert.value.toFixed(1)}%`;
|
||||
default:
|
||||
if (unitFromMetadata) {
|
||||
return `${alert.value.toFixed(1)} ${unitFromMetadata}`;
|
||||
}
|
||||
return alert.value.toFixed(1);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertThreshold = (alert: Alert): string => {
|
||||
const metric = alert.type.toLowerCase();
|
||||
const unitFromMetadata = getMetadataUnit(alert);
|
||||
|
||||
switch (metric) {
|
||||
case 'temperature':
|
||||
return `${alert.threshold.toFixed(0)}°C`;
|
||||
case 'diskread':
|
||||
case 'diskwrite':
|
||||
case 'networkin':
|
||||
case 'networkout':
|
||||
return `${alert.threshold.toFixed(0)} MB/s`;
|
||||
case 'cpu':
|
||||
case 'memory':
|
||||
case 'disk':
|
||||
case 'usage':
|
||||
return `${alert.threshold.toFixed(0)}%`;
|
||||
default:
|
||||
if (unitFromMetadata) {
|
||||
return `${alert.threshold.toFixed(0)} ${unitFromMetadata}`;
|
||||
}
|
||||
return alert.threshold.toFixed(0);
|
||||
}
|
||||
};
|
||||
|
||||
interface AlertIndicatorProps {
|
||||
severity: 'critical' | 'warning' | null;
|
||||
alerts?: Alert[];
|
||||
}
|
||||
|
||||
export const AlertIndicator: Component<AlertIndicatorProps> = (props) => {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
if (!alertsEnabled() || !props.severity) return null;
|
||||
|
||||
const dotClass = props.severity === 'critical' ? 'bg-red-500 animate-pulse' : 'bg-orange-500';
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
if (!props.alerts || props.alerts.length === 0) return;
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect();
|
||||
const content = props.alerts
|
||||
.map(
|
||||
(alert) => `${alert.type}: ${formatAlertValue(alert)} (threshold: ${formatAlertThreshold(alert)})`,
|
||||
)
|
||||
.join('\n');
|
||||
showTooltip(content, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
maxWidth: 260,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hideTooltip();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
class={`inline-block w-2 h-2 rounded-full ${dotClass}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlertCountBadgeProps {
|
||||
count: number;
|
||||
severity: 'critical' | 'warning';
|
||||
alerts?: Alert[];
|
||||
}
|
||||
|
||||
export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
if (!alertsEnabled()) return null;
|
||||
|
||||
const badgeClass =
|
||||
props.severity === 'critical' ? 'bg-red-500 text-white' : 'bg-orange-500 text-white';
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
if (!props.alerts || props.alerts.length === 0) return;
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect();
|
||||
const header = `${props.count} Active Alert${props.count === 1 ? '' : 's'}:`;
|
||||
const details = props.alerts
|
||||
.map(
|
||||
(alert, index) =>
|
||||
`${index + 1}. ${alert.type}: ${formatAlertValue(alert)} (threshold: ${formatAlertThreshold(alert)})`,
|
||||
)
|
||||
.join('\n');
|
||||
const content = [header, details].filter(Boolean).join('\n');
|
||||
showTooltip(content, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
maxWidth: 300,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hideTooltip();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
class={`inline-flex items-center justify-center min-w-[20px] h-5 px-1 text-xs font-medium rounded-full ${badgeClass}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{props.count}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { Component, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
interface AnimatedMetricProps {
|
||||
value: number;
|
||||
formatter?: (value: number) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
|
||||
const [displayValue, setDisplayValue] = createSignal(props.value);
|
||||
const [oldValue, setOldValue] = createSignal(props.value);
|
||||
const [showGhost, setShowGhost] = createSignal(false);
|
||||
const [animClass, setAnimClass] = createSignal('');
|
||||
let timeoutId: number;
|
||||
let hasInitialized = false;
|
||||
|
||||
createEffect(() => {
|
||||
const newVal = props.value;
|
||||
const prevVal = displayValue();
|
||||
|
||||
// Skip first render
|
||||
if (!hasInitialized) {
|
||||
hasInitialized = true;
|
||||
setDisplayValue(newVal);
|
||||
setOldValue(newVal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only animate if value changed
|
||||
if (newVal !== prevVal) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Store old value for ghost
|
||||
setOldValue(prevVal);
|
||||
setShowGhost(true);
|
||||
|
||||
// Set animation direction
|
||||
if (newVal > prevVal) {
|
||||
setAnimClass('up');
|
||||
console.log('[AnimatedMetric] Going UP:', prevVal, '->', newVal);
|
||||
} else {
|
||||
setAnimClass('down');
|
||||
console.log('[AnimatedMetric] Going DOWN:', prevVal, '->', newVal);
|
||||
}
|
||||
|
||||
// Update to new value
|
||||
setDisplayValue(newVal);
|
||||
|
||||
// Remove ghost after animation
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setShowGhost(false);
|
||||
setAnimClass('');
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => clearTimeout(timeoutId));
|
||||
|
||||
const format = props.formatter || ((v: number) => formatBytes(v) + '/s');
|
||||
|
||||
return (
|
||||
<div
|
||||
class="metric-container"
|
||||
style="position: relative; display: inline-block; overflow: visible;"
|
||||
>
|
||||
{showGhost() && (
|
||||
<span
|
||||
class={`metric-ghost metric-ghost-${animClass()}`}
|
||||
style="position: absolute; top: 0; left: 0; z-index: 1;"
|
||||
>
|
||||
{format(oldValue())}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
class={`metric-value ${showGhost() ? `metric-entering-${animClass()}` : ''}`}
|
||||
style="position: relative; z-index: 2; display: inline-block;"
|
||||
>
|
||||
{format(displayValue())}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { createSignal, onCleanup, splitProps } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface CopyButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: string;
|
||||
|
|
@ -12,39 +14,6 @@ export function CopyButton(props: CopyButtonProps) {
|
|||
const [copied, setCopied] = createSignal(false);
|
||||
let resetTimeout: number | undefined;
|
||||
|
||||
const copyText = async (text: string): Promise<boolean> => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API copy failed, attempting fallback copy.', error);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error('Fallback copy failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async (event: MouseEvent) => {
|
||||
const handler = local.onClick as
|
||||
| ((event: MouseEvent) => void)
|
||||
|
|
@ -60,7 +29,7 @@ export function CopyButton(props: CopyButtonProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
const success = await copyText(local.text);
|
||||
const success = await copyToClipboard(local.text);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
window.clearTimeout(resetTimeout);
|
||||
|
|
@ -69,7 +38,7 @@ export function CopyButton(props: CopyButtonProps) {
|
|||
try {
|
||||
local.onCopied();
|
||||
} catch (error) {
|
||||
console.error('onCopied handler failed', error);
|
||||
logger.error('onCopied handler failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,6 @@ export function controlClass(extra?: string) {
|
|||
return join(baseControl, extra);
|
||||
}
|
||||
|
||||
export function helpTextClass(extra?: string) {
|
||||
return join(baseHelp, extra);
|
||||
}
|
||||
|
||||
export default {
|
||||
formSection,
|
||||
formField,
|
||||
|
|
@ -54,5 +50,4 @@ export default {
|
|||
formCheckbox,
|
||||
labelClass,
|
||||
controlClass,
|
||||
helpTextClass,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,31 +9,6 @@ export const POLLING_INTERVALS = {
|
|||
TOAST_DURATION: 5000, // 5 seconds - default toast notification duration
|
||||
} as const;
|
||||
|
||||
// Display thresholds (percentages)
|
||||
export const THRESHOLDS = {
|
||||
WARNING: 60, // Yellow warning threshold
|
||||
CRITICAL: 80, // Orange critical threshold
|
||||
DANGER: 90, // Red danger threshold
|
||||
} as const;
|
||||
|
||||
// Network and I/O metrics thresholds (MB/s)
|
||||
export const IO_THRESHOLDS = {
|
||||
LOW: 1,
|
||||
MEDIUM: 10,
|
||||
HIGH: 50,
|
||||
VERY_HIGH: 100,
|
||||
} as const;
|
||||
|
||||
// Animation durations (in milliseconds)
|
||||
export const ANIMATIONS = {
|
||||
TOAST_SLIDE: 300, // Toast slide in/out animation
|
||||
} as const;
|
||||
|
||||
// UI configuration
|
||||
export const UI = {
|
||||
DEBOUNCE_DELAY: 300, // 300ms - input debounce delay
|
||||
} as const;
|
||||
|
||||
// WebSocket configuration
|
||||
export const WEBSOCKET = {
|
||||
PING_INTERVAL: 25000, // 25 seconds - WebSocket ping interval
|
||||
|
|
@ -43,27 +18,3 @@ export const WEBSOCKET = {
|
|||
ERROR: 'error',
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
// Storage keys for localStorage
|
||||
export const STORAGE_KEYS = {
|
||||
DARK_MODE: 'darkMode',
|
||||
VIEW_MODE: 'viewMode',
|
||||
DISPLAY_MODE: 'displayMode',
|
||||
SORT_KEY: 'sortKey',
|
||||
SORT_DIRECTION: 'sortDirection',
|
||||
ALERT_THRESHOLDS: 'alertThresholds',
|
||||
ACTIVE_TAB: 'activeTab',
|
||||
} as const;
|
||||
|
||||
// File size units
|
||||
export const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const;
|
||||
|
||||
// Log levels for the logger
|
||||
export const LOG_LEVELS = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
} as const;
|
||||
|
||||
export type LogLevel = keyof typeof LOG_LEVELS;
|
||||
|
|
|
|||