Merge ai-features into main for 5.0 release

Major features:
- Pulse AI (chat, patrol, auto-fix, multi-provider)
- Kubernetes monitoring via agents
- OCI container support (Proxmox 9.1+)
- Proxmox Mail Gateway monitoring
- Metrics history with configurable retention
- Multi-step Setup Wizard
- One-click updates
- OIDC/SSO support

Documentation:
- Added AI.md, METRICS_HISTORY.md, MAIL_GATEWAY.md, AUTO_UPDATE.md
- Updated README with 5.0 features
- Added AI endpoints to API.md

Fixes:
- TypeScript strict mode compliance
- ARIA accessibility in wizard
- Shell script permissions
- Console.log cleanup
This commit is contained in:
rcourtman 2025-12-13 15:49:57 +00:00
commit 21e992ac7a
272 changed files with 65403 additions and 8771 deletions

View file

@ -15,7 +15,7 @@
## 🚀 Overview
Pulse is a modern, unified dashboard for your **Proxmox** and **Docker** estate. It consolidates metrics, logs, and alerts from Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, and standalone Docker hosts into a single, beautiful interface.
Pulse is a modern, unified dashboard for monitoring your **infrastructure** across Proxmox, Docker, and Kubernetes. It consolidates metrics, alerts, and AI-powered insights from all your systems into a single, beautiful interface.
Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" without the complexity of enterprise monitoring stacks.
@ -23,13 +23,30 @@ Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" wit
## ✨ Features
- **Unified Monitoring**: View health and metrics for PVE, PBS, PMG, and Docker containers in one place.
- **Smart Alerts**: Get notified via Discord, Slack, Telegram, Email, and more when things go wrong (e.g., "VM down", "Storage full").
- **Auto-Discovery**: Automatically finds Proxmox nodes on your network.
- **Secure by Design**: Credentials encrypted at rest, no external dependencies, and strict API scoping.
- **Backup Explorer**: Visualize backup jobs and storage usage across your entire infrastructure.
- **Privacy Focused**: No telemetry, no phone-home, all data stays on your server.
- **Lightweight**: Built with Go and React, running as a single binary or container.
### Core Monitoring
- **Unified Monitoring**: View health and metrics for PVE, PBS, PMG, Docker, and Kubernetes in one place
- **Smart Alerts**: Get notified via Discord, Slack, Telegram, Email, and more
- **Auto-Discovery**: Automatically finds Proxmox nodes on your network
- **Metrics History**: Persistent storage with configurable retention
- **Backup Explorer**: Visualize backup jobs and storage usage
### Pulse AI *(New in 5.0)*
- **Chat Assistant**: Ask questions about your infrastructure in natural language
- **Patrol Mode**: Automated health checks with proactive issue detection
- **Auto-Fix**: Automatically resolve common issues with AI-guided remediation
- **Predictive Intelligence**: Forecast problems before they happen
### Multi-Platform
- **Proxmox VE/PBS/PMG**: Full monitoring and management
- **Kubernetes**: Complete K8s cluster monitoring via agents
- **Docker/Podman**: Container and Swarm service monitoring
- **OCI Containers**: Proxmox 9.1+ native container support
### Security & Operations
- **Secure by Design**: Credentials encrypted at rest, strict API scoping
- **One-Click Updates**: Easy upgrades for supported deployments
- **OIDC/SSO**: Enterprise authentication support
- **Privacy Focused**: No telemetry, all data stays on your server
## ⚡ Quick Start
@ -65,7 +82,7 @@ Access the dashboard at `http://<your-ip>:7655`.
Community-maintained integrations and addons:
- **[Home Assistant Addon](https://github.com/Kosztyk/pulse-docker-agent-addon)** - Run the Pulse Docker Agent as a Home Assistant addon.
- **[Home Assistant Addons](https://github.com/Kosztyk/homeassistant-addons)** - Run Pulse Agent and Pulse Server as Home Assistant addons.
## ❤️ Support Pulse Development

View file

@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync/atomic"
"syscall"
@ -18,6 +19,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
@ -30,7 +32,7 @@ var (
agentInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "pulse_agent_info",
Help: "Information about the Pulse agent",
}, []string{"version", "host_enabled", "docker_enabled"})
}, []string{"version", "host_enabled", "docker_enabled", "kubernetes_enabled"})
agentUp = promauto.NewGauge(prometheus.GaugeOpts{
Name: "pulse_agent_up",
@ -78,6 +80,8 @@ func main() {
Str("pulse_url", cfg.PulseURL).
Bool("host_agent", cfg.EnableHost).
Bool("docker_agent", cfg.EnableDocker).
Bool("kubernetes_agent", cfg.EnableKubernetes).
Bool("proxmox_mode", cfg.EnableProxmox).
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse Unified Agent")
@ -86,6 +90,7 @@ func main() {
Version,
fmt.Sprintf("%t", cfg.EnableHost),
fmt.Sprintf("%t", cfg.EnableDocker),
fmt.Sprintf("%t", cfg.EnableKubernetes),
).Set(1)
agentUp.Set(1)
@ -126,6 +131,8 @@ func main() {
InsecureSkipVerify: cfg.InsecureSkipVerify,
LogLevel: cfg.LogLevel,
Logger: &logger,
EnableProxmox: cfg.EnableProxmox,
ProxmoxType: cfg.ProxmoxType,
}
agent, err := hostagent.New(hostCfg)
@ -163,19 +170,71 @@ func main() {
dockerAgent, err = dockeragent.New(dockerCfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to initialize docker agent")
// Docker isn't available yet - start retry loop in background
logger.Warn().Err(err).Msg("Docker not available, will retry with exponential backoff")
g.Go(func() error {
agent := initDockerWithRetry(ctx, dockerCfg, &logger)
if agent != nil {
dockerAgent = agent
logger.Info().Msg("Docker agent module started (after retry)")
return agent.Run(ctx)
}
// Docker never became available, continue without it
return nil
})
} else {
g.Go(func() error {
logger.Info().Msg("Docker agent module started")
return dockerAgent.Run(ctx)
})
}
}
// 10. Start Kubernetes Agent (if enabled)
if cfg.EnableKubernetes {
kubeCfg := kubernetesagent.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
Interval: cfg.Interval,
AgentID: cfg.AgentID,
AgentType: "unified",
AgentVersion: Version,
InsecureSkipVerify: cfg.InsecureSkipVerify,
LogLevel: cfg.LogLevel,
Logger: &logger,
KubeconfigPath: cfg.KubeconfigPath,
KubeContext: cfg.KubeContext,
IncludeNamespaces: cfg.KubeIncludeNamespaces,
ExcludeNamespaces: cfg.KubeExcludeNamespaces,
IncludeAllPods: cfg.KubeIncludeAllPods,
MaxPods: cfg.KubeMaxPods,
}
g.Go(func() error {
logger.Info().Msg("Docker agent module started")
return dockerAgent.Run(ctx)
})
agent, err := kubernetesagent.New(kubeCfg)
if err != nil {
logger.Warn().Err(err).Msg("Kubernetes not available, will retry with exponential backoff")
g.Go(func() error {
retried := initKubernetesWithRetry(ctx, kubeCfg, &logger)
if retried != nil {
logger.Info().Msg("Kubernetes agent module started (after retry)")
return retried.Run(ctx)
}
return nil
})
} else {
g.Go(func() error {
logger.Info().Msg("Kubernetes agent module started")
return agent.Run(ctx)
})
}
}
// Mark as ready after all agents started
ready.Store(true)
// 10. Wait for all agents to exit
// 11. Wait for all agents to exit
if err := g.Wait(); err != nil && err != context.Canceled {
logger.Error().Err(err).Msg("Agent terminated with error")
agentUp.Set(0)
@ -183,7 +242,7 @@ func main() {
os.Exit(1)
}
// 11. Cleanup
// 12. Cleanup
agentUp.Set(0)
cleanupDockerAgent(dockerAgent, &logger)
@ -259,14 +318,25 @@ type Config struct {
Logger *zerolog.Logger
// Module flags
EnableHost bool
EnableDocker bool
EnableHost bool
EnableDocker bool
EnableKubernetes bool
EnableProxmox bool
ProxmoxType string // "pve", "pbs", or "" for auto-detect
// Auto-update
DisableAutoUpdate bool
// Health/metrics server
HealthAddr string
// Kubernetes
KubeconfigPath string
KubeContext string
KubeIncludeNamespaces []string
KubeExcludeNamespaces []string
KubeIncludeAllPods bool
KubeMaxPods int
}
func loadConfig() Config {
@ -281,8 +351,17 @@ func loadConfig() Config {
envLogLevel := utils.GetenvTrim("LOG_LEVEL")
envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST")
envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER")
envEnableKubernetes := utils.GetenvTrim("PULSE_ENABLE_KUBERNETES")
envEnableProxmox := utils.GetenvTrim("PULSE_ENABLE_PROXMOX")
envProxmoxType := utils.GetenvTrim("PULSE_PROXMOX_TYPE")
envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE")
envHealthAddr := utils.GetenvTrim("PULSE_HEALTH_ADDR")
envKubeconfig := utils.GetenvTrim("PULSE_KUBECONFIG")
envKubeContext := utils.GetenvTrim("PULSE_KUBE_CONTEXT")
envKubeIncludeNamespaces := utils.GetenvTrim("PULSE_KUBE_INCLUDE_NAMESPACES")
envKubeExcludeNamespaces := utils.GetenvTrim("PULSE_KUBE_EXCLUDE_NAMESPACES")
envKubeIncludeAllPods := utils.GetenvTrim("PULSE_KUBE_INCLUDE_ALL_PODS")
envKubeMaxPods := utils.GetenvTrim("PULSE_KUBE_MAX_PODS")
// Defaults
defaultInterval := 30 * time.Second
@ -302,6 +381,16 @@ func loadConfig() Config {
defaultEnableDocker = utils.ParseBool(envEnableDocker)
}
defaultEnableKubernetes := false
if envEnableKubernetes != "" {
defaultEnableKubernetes = utils.ParseBool(envEnableKubernetes)
}
defaultEnableProxmox := false
if envEnableProxmox != "" {
defaultEnableProxmox = utils.ParseBool(envEnableProxmox)
}
defaultHealthAddr := envHealthAddr
if defaultHealthAddr == "" {
defaultHealthAddr = ":9191"
@ -318,12 +407,23 @@ func loadConfig() Config {
enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module")
enableKubernetesFlag := flag.Bool("enable-kubernetes", defaultEnableKubernetes, "Enable Kubernetes Agent module")
enableProxmoxFlag := flag.Bool("enable-proxmox", defaultEnableProxmox, "Enable Proxmox mode (creates API token, registers node)")
proxmoxTypeFlag := flag.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)")
disableAutoUpdateFlag := flag.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates")
healthAddrFlag := flag.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)")
kubeconfigFlag := flag.String("kubeconfig", envKubeconfig, "Path to kubeconfig (optional; uses in-cluster config if available)")
kubeContextFlag := flag.String("kube-context", envKubeContext, "Kubeconfig context (optional)")
kubeIncludeAllPodsFlag := flag.Bool("kube-include-all-pods", utils.ParseBool(envKubeIncludeAllPods), "Include all non-succeeded pods (may be large)")
kubeMaxPodsFlag := flag.Int("kube-max-pods", defaultInt(envKubeMaxPods, 200), "Max pods included in report")
showVersion := flag.Bool("version", false, "Print the agent version and exit")
var tagFlags multiValue
flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)")
var kubeIncludeNamespaceFlags multiValue
flag.Var(&kubeIncludeNamespaceFlags, "kube-include-namespace", "Namespace to include (repeatable; default is all)")
var kubeExcludeNamespaceFlags multiValue
flag.Var(&kubeExcludeNamespaceFlags, "kube-exclude-namespace", "Namespace to exclude (repeatable)")
flag.Parse()
@ -350,20 +450,31 @@ func loadConfig() Config {
}
tags := gatherTags(envTags, tagFlags)
kubeIncludeNamespaces := gatherCSV(envKubeIncludeNamespaces, kubeIncludeNamespaceFlags)
kubeExcludeNamespaces := gatherCSV(envKubeExcludeNamespaces, kubeExcludeNamespaceFlags)
return Config{
PulseURL: pulseURL,
APIToken: token,
Interval: *intervalFlag,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
LogLevel: logLevel,
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
DisableAutoUpdate: *disableAutoUpdateFlag,
HealthAddr: strings.TrimSpace(*healthAddrFlag),
PulseURL: pulseURL,
APIToken: token,
Interval: *intervalFlag,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
LogLevel: logLevel,
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
EnableKubernetes: *enableKubernetesFlag,
EnableProxmox: *enableProxmoxFlag,
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
DisableAutoUpdate: *disableAutoUpdateFlag,
HealthAddr: strings.TrimSpace(*healthAddrFlag),
KubeconfigPath: strings.TrimSpace(*kubeconfigFlag),
KubeContext: strings.TrimSpace(*kubeContextFlag),
KubeIncludeNamespaces: kubeIncludeNamespaces,
KubeExcludeNamespaces: kubeExcludeNamespaces,
KubeIncludeAllPods: *kubeIncludeAllPodsFlag,
KubeMaxPods: *kubeMaxPodsFlag,
}
}
@ -386,6 +497,37 @@ func gatherTags(env string, flags []string) []string {
return tags
}
func gatherCSV(env string, flags []string) []string {
values := make([]string, 0)
if env != "" {
for _, value := range strings.Split(env, ",") {
value = strings.TrimSpace(value)
if value != "" {
values = append(values, value)
}
}
}
for _, value := range flags {
value = strings.TrimSpace(value)
if value != "" {
values = append(values, value)
}
}
return values
}
func defaultInt(value string, fallback int) int {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func parseLogLevel(value string) (zerolog.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
@ -400,3 +542,114 @@ func defaultLogLevel(envValue string) string {
}
return envValue
}
// initDockerWithRetry attempts to initialize the Docker agent with exponential backoff.
// It returns the agent when Docker becomes available, or nil if the context is cancelled.
// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes.
func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) *dockeragent.Agent {
const (
initialDelay = 5 * time.Second
maxDelay = 5 * time.Minute
multiplier = 2.0
)
delay := initialDelay
attempt := 0
for {
select {
case <-ctx.Done():
logger.Info().Msg("Docker retry cancelled, context done")
return nil
default:
}
attempt++
logger.Info().
Int("attempt", attempt).
Str("next_retry", delay.String()).
Msg("Waiting to retry Docker connection")
select {
case <-ctx.Done():
logger.Info().Msg("Docker retry cancelled during wait")
return nil
case <-time.After(delay):
}
agent, err := dockeragent.New(cfg)
if err == nil {
logger.Info().
Int("attempts", attempt).
Msg("Successfully connected to Docker after retry")
return agent
}
logger.Warn().
Err(err).
Int("attempt", attempt).
Str("next_retry", (time.Duration(float64(delay) * multiplier)).String()).
Msg("Docker still not available, will retry")
// Calculate next delay with exponential backoff, capped at maxDelay
delay = time.Duration(float64(delay) * multiplier)
if delay > maxDelay {
delay = maxDelay
}
}
}
// initKubernetesWithRetry attempts to initialize the Kubernetes agent with exponential backoff.
// It returns the agent when Kubernetes becomes available, or nil if the context is cancelled.
// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes.
func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, logger *zerolog.Logger) *kubernetesagent.Agent {
const (
initialDelay = 5 * time.Second
maxDelay = 5 * time.Minute
multiplier = 2.0
)
delay := initialDelay
attempt := 0
for {
select {
case <-ctx.Done():
logger.Info().Msg("Kubernetes retry cancelled, context done")
return nil
default:
}
attempt++
logger.Info().
Int("attempt", attempt).
Str("next_retry", delay.String()).
Msg("Waiting to retry Kubernetes connection")
select {
case <-ctx.Done():
logger.Info().Msg("Kubernetes retry cancelled during wait")
return nil
case <-time.After(delay):
}
agent, err := kubernetesagent.New(cfg)
if err == nil {
logger.Info().
Int("attempts", attempt).
Msg("Successfully connected to Kubernetes after retry")
return agent
}
logger.Warn().
Err(err).
Int("attempt", attempt).
Str("next_retry", (time.Duration(float64(delay) * multiplier)).String()).
Msg("Kubernetes still not available, will retry")
delay = time.Duration(float64(delay) * multiplier)
if delay > maxDelay {
delay = maxDelay
}
}
}

View file

@ -55,6 +55,12 @@ func main() {
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger()
cfg.Logger = &logger
// Deprecation warning
logger.Warn().Msg("pulse-docker-agent is DEPRECATED and will be removed in a future release")
logger.Warn().Msg("Please migrate to the unified 'pulse-agent' with --enable-docker flag")
logger.Warn().Msg("Example: pulse-agent --url <URL> --token <TOKEN> --enable-docker")
logger.Warn().Msg("")
agent, err := dockeragent.New(cfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to create docker agent")

View file

@ -61,6 +61,12 @@ func main() {
g, ctx := errgroup.WithContext(ctx)
// Deprecation warning
logger.Warn().Msg("pulse-host-agent is DEPRECATED and will be removed in a future release")
logger.Warn().Msg("Please migrate to the unified 'pulse-agent' with --enable-host flag")
logger.Warn().Msg("Example: pulse-agent --url <URL> --token <TOKEN> --enable-host")
logger.Warn().Msg("")
logger.Info().
Str("version", Version).
Str("pulse_url", hostCfg.PulseURL).

View file

@ -1,5 +1,10 @@
# pulse-sensor-proxy
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead. The agent provides
> temperature monitoring plus additional features (AI, automatic Proxmox API setup).
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md) for details.
The sensor proxy keeps SSH identities and temperature polling logic on the
Proxmox host while presenting a small RPC surface (Unix socket or HTTPS) to the
Pulse server. It protects SSH keys from container breakouts, enforces per-UID

View file

@ -141,8 +141,15 @@ func runServer() {
}
// Set state getter for WebSocket hub
// IMPORTANT: Return StateFrontend (not StateSnapshot) to match broadcast format.
// StateSnapshot uses time.Time fields while StateFrontend uses Unix timestamps,
// and includes frontend-specific field transformations. Without this conversion,
// nodes/hosts would be missing on initial page load but appear after broadcasts.
wsHub.SetStateGetter(func() interface{} {
return reloadableMonitor.GetState()
// GetMonitor().GetState() returns models.StateSnapshot
state := reloadableMonitor.GetMonitor().GetState()
// Convert to frontend format, matching what BroadcastState does
return state.ToFrontend()
})
// Wire up Prometheus metrics for alert lifecycle
@ -172,15 +179,29 @@ func runServer() {
return nil
}
router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc, Version)
// Inject resource store into monitor for WebSocket broadcasts
// This must be done after router creation since resourceHandlers is created in NewRouter
router.SetMonitor(reloadableMonitor.GetMonitor())
// Start AI patrol service for background infrastructure monitoring
router.StartPatrol(ctx)
// Wire alert-triggered AI analysis (token-efficient real-time insights when alerts fire)
router.WireAlertTriggeredAI()
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port
// NOTE: We use ReadHeaderTimeout instead of ReadTimeout to avoid affecting
// WebSocket connections. ReadTimeout sets a deadline on the underlying connection
// that persists even after WebSocket upgrade, causing premature disconnections.
// ReadHeaderTimeout only applies during header reading, not the full request body.
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second, // Increased from 15s to 60s to support large JSON responses (e.g., mock data)
IdleTimeout: 60 * time.Second,
Addr: fmt.Sprintf("%s:%d", cfg.BackendHost, cfg.FrontendPort),
Handler: router.Handler(),
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 0, // Disabled to support SSE/streaming - each handler manages its own deadline
IdleTimeout: 120 * time.Second,
}
// Start config watcher for .env file changes

123
docs/AI.md Normal file
View file

@ -0,0 +1,123 @@
# Pulse AI
Pulse AI is an intelligent monitoring assistant that helps you understand and manage your infrastructure through natural language conversations, proactive monitoring, and automated issue resolution.
## Features
### Chat Assistant
Ask questions about your infrastructure in plain English:
- "What's using the most CPU right now?"
- "Show me VMs with high memory usage"
- "Why is my backup failing?"
- "Summarize the health of my cluster"
### Patrol Mode
Automated monitoring that runs on a schedule to:
- Analyze resource utilization patterns
- Detect potential issues before they cause problems
- Generate actionable recommendations
- Track trends over time
### Auto-Fix
Automatically resolve common issues:
- Restart stuck services
- Clear disk space
- Restart unresponsive containers
- Apply recommended optimizations
### Predictive Intelligence
- Identify resources trending toward problems
- Forecast disk space exhaustion
- Detect unusual patterns
- Alert on anomalies
## Configuration
### Enable Pulse AI
1. Navigate to **Settings → AI**
2. Toggle **Enable Pulse AI**
3. Configure your AI provider
### Supported Providers
| Provider | Models | Notes |
|----------|--------|-------|
| **OpenAI** | GPT-4, GPT-4 Turbo, GPT-3.5 | Recommended for best results |
| **Anthropic** | Claude 3 Opus, Sonnet, Haiku | Excellent for complex analysis |
| **Ollama** | Llama 3, Mistral, etc. | Self-hosted, privacy-focused |
| **OpenRouter** | Multiple models | Pay-per-use routing |
| **Custom** | Any OpenAI-compatible API | For enterprise deployments |
### API Key Setup
```bash
# Environment variable (recommended for production)
export PULSE_AI_PROVIDER=openai
export PULSE_AI_API_KEY=sk-...
# Or configure via Settings UI
```
### Patrol Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| Patrol Enabled | Off | Run automated checks |
| Patrol Interval | 30 minutes | How often to run patrol |
| Auto-Fix Enabled | Off | Allow automatic remediation |
| Auto-Fix Model | Same as chat | Model for auto-fix decisions |
## Usage
### Chat Interface
Access the AI chat from the bottom-right corner of any page. Type your question and press Enter.
**Example queries:**
```
"What VMs are using more than 80% memory?"
"Show me the status of all backups"
"Why is node pve1 showing high load?"
"Compare resource usage between this week and last week"
```
### Context Selection
Click on any resource (VM, container, host) to add it to the AI context. This helps the AI provide more specific answers.
### Patrol Reports
When Patrol is enabled, Pulse AI will:
1. Run periodic health checks
2. Generate findings (issues, warnings, info)
3. Offer to auto-fix issues (if enabled)
4. Track trends over time
## Cost Tracking
Track AI API usage in **Settings → AI → Cost Dashboard**:
- Daily/monthly usage statistics
- Cost breakdown by feature
- Usage trends over time
## Privacy & Security
- **Data stays local**: Only resource metadata is sent to AI providers
- **No training**: Your data is never used for model training
- **Audit logging**: All AI interactions are logged
- **Self-hosted option**: Use Ollama for complete data privacy
## Troubleshooting
### AI not responding
1. Check API key is configured correctly
2. Verify network connectivity to AI provider
3. Check Settings → AI for error messages
### Patrol not running
1. Ensure Patrol is enabled in Settings
2. Check system resource availability
3. Review logs: `journalctl -u pulse -f`
### Auto-fix not working
1. Enable Auto-Fix in Settings → AI
2. Verify the connected agents have execute permissions
3. Check the Auto-Fix model is configured

View file

@ -112,23 +112,6 @@ Triggers a test alert to all configured channels.
---
---
## 🖥️ Host Agent
### Submit Report
`POST /api/agents/host/report`
Used by the Pulse Host Agent to push system metrics.
### Lookup Agent
`POST /api/agents/host/lookup`
Check if a host agent is already registered.
### Delete Host
`DELETE /api/agents/host/<id>`
Remove a host agent from monitoring.
---
## ⚙️ System Settings
### Get Settings
@ -157,15 +140,55 @@ Initiate OIDC login flow.
---
## 🐳 Docker Agent
## 🤖 Pulse AI *(New in 5.0)*
### Submit Report
`POST /api/agents/docker/report`
Used by the Pulse Docker Agent to push container metrics.
### Get AI Settings
`GET /api/settings/ai`
Returns current AI configuration (providers, models, patrol status).
### Download Agent
`GET /download/pulse-docker-agent`
Downloads the binary for the current platform.
### Update AI Settings
`PUT /api/settings/ai`
Configure AI providers, API keys, and preferences.
### Chat
`POST /api/ai/chat`
Send a message to the AI assistant.
```json
{ "message": "What VMs are using the most CPU?", "context": ["vm-100", "vm-101"] }
```
### Patrol Status
`GET /api/ai/patrol/status`
Get current patrol status and recent findings.
### Patrol Findings
`GET /api/ai/patrol/findings`
List all patrol findings with severity and recommendations.
### Cost Tracking
`GET /api/ai/cost?period=30d`
Get AI usage statistics and costs.
---
## 🤖 Agent Endpoints
### Unified Agent (Recommended)
`GET /download/pulse-agent`
Downloads the unified agent binary for the current platform.
The unified agent combines host, Docker, and Kubernetes monitoring. Use `--enable-docker` or `--enable-kubernetes` to enable additional metrics.
See [UNIFIED_AGENT.md](UNIFIED_AGENT.md) for installation instructions.
### Legacy Agents (Deprecated)
`GET /download/pulse-host-agent` - *Deprecated, use pulse-agent*
`GET /download/pulse-docker-agent` - *Deprecated, use pulse-agent --enable-docker*
### Submit Reports
`POST /api/agents/host/report` - Host metrics
`POST /api/agents/docker/report` - Docker container metrics
`POST /api/agents/kubernetes/report` - Kubernetes cluster metrics
---

141
docs/AUTO_UPDATE.md Normal file
View file

@ -0,0 +1,141 @@
# Automatic Updates
Pulse 5.0 introduces one-click updates for supported deployment types, making it easy to keep your monitoring system up to date.
## Supported Deployment Types
| Deployment | Auto-Update | Method |
|------------|-------------|--------|
| **ProxmoxVE LXC** | ✅ Yes | In-app update button |
| **Systemd Service** | ✅ Yes | In-app update button |
| **Docker** | ❌ Manual | Pull new image |
| **Source Build** | ❌ Manual | Git pull + rebuild |
## Using One-Click Updates
### When an Update is Available
1. Navigate to **Settings → System Updates**
2. If an update is available, you'll see an **"Install Update"** button
3. Click the button to open the confirmation dialog
4. Review the update details:
- Current version → New version
- Estimated time
- Changelog highlights
5. Click **"Install Update"** to begin
### Update Process
1. **Download**: New version is downloaded
2. **Backup**: Current installation is backed up
3. **Apply**: Files are updated
4. **Restart**: Service restarts automatically
5. **Verify**: Health check confirms success
### Progress Tracking
A real-time progress modal shows:
- Current step
- Download progress
- Any warnings or errors
- Automatic page reload on success
## Configuration
### Update Preferences
In **Settings → System Updates**:
| Setting | Description |
|---------|-------------|
| **Update Channel** | Stable (recommended) or Release Candidate |
| **Auto-Check** | Automatically check for updates daily |
### Environment Variables
```bash
# Disable auto-update check
PULSE_AUTO_UPDATE_CHECK=false
# Use release candidate channel
PULSE_UPDATE_CHANNEL=rc
```
## Manual Update Methods
### Docker
```bash
# Pull latest image
docker pull ghcr.io/rcourtman/pulse:latest
# Restart container
docker-compose down && docker-compose up -d
```
### ProxmoxVE LXC (Manual)
```bash
# Inside the container
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install.sh | bash
```
### Systemd Service (Manual)
```bash
# Download new release
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install.sh | bash
```
### Source Build
```bash
cd /opt/pulse
git pull
make build
sudo systemctl restart pulse
```
## Rollback
If an update causes issues:
### Automatic Rollback
Pulse creates a backup before updating. If the update fails:
1. The previous version is automatically restored
2. Service restarts with the old version
3. Error details are logged
### Manual Rollback
```bash
# Backups are stored in /etc/pulse/backups/
ls /etc/pulse/backups/
# Restore a specific backup
sudo /opt/pulse/scripts/restore-backup.sh /etc/pulse/backups/pulse-backup-20250101.tar.gz
```
## Update History
View past updates in **Settings → System Updates → Update History**:
- Previous versions installed
- Update timestamps
- Success/failure status
## Troubleshooting
### Update button not showing
1. Check if your deployment supports auto-update
2. Verify an update is actually available
3. Ensure you have the latest frontend loaded (hard refresh)
### Update failed
1. Check the error message in the progress modal
2. Review logs: `journalctl -u pulse -n 100`
3. Verify disk space is available
4. Check network connectivity to GitHub
### Service won't restart after update
1. Check systemd status: `systemctl status pulse`
2. View recent logs: `journalctl -u pulse -f`
3. Manually restore from backup if needed

99
docs/MAIL_GATEWAY.md Normal file
View file

@ -0,0 +1,99 @@
# Proxmox Mail Gateway (PMG) Monitoring
Pulse 5.0 adds support for monitoring Proxmox Mail Gateway instances alongside your PVE and PBS infrastructure.
## Features
- **Mail Queue Monitoring**: Track active, deferred, and held messages
- **Spam Statistics**: View spam detection rates and virus blocks
- **Cluster Status**: Monitor PMG cluster node health
- **Quarantine Overview**: See quarantine size and pending reviews
## Adding a PMG Instance
### Via Settings UI
1. Navigate to **Settings → Proxmox**
2. Click **Add Node**
3. Select **Proxmox Mail Gateway** as the type
4. Enter connection details:
- Host: Your PMG IP or hostname
- Port: 8006 (default)
- API Token ID: e.g., `root@pam!pulse`
- API Token Secret: Your token secret
### Via Discovery
Pulse can automatically discover PMG instances on your network:
1. Go to **Settings → Discovery**
2. Enable network discovery
3. PMG instances on port 8006 will be detected
4. Click to add discovered instances
## API Token Setup on PMG
Create an API token on your PMG server:
```bash
# SSH to your PMG server
pveum user token add root@pam pulse --privsep 0
# Note the token secret - it's only shown once!
```
Required permissions:
- `Sys.Audit` - Read system status
- `Datastore.Audit` - Read mail statistics
## Dashboard
The Mail Gateway tab shows:
| Metric | Description |
|--------|-------------|
| **Mail Processed** | Total emails processed today |
| **Spam Rate** | Percentage of spam detected |
| **Virus Blocked** | Malicious emails caught |
| **Queue Depth** | Messages pending delivery |
| **Quarantine Size** | Emails in quarantine |
### Status Indicators
- 🟢 **Healthy**: Normal operation
- 🟡 **Warning**: Queue building up or high spam rate
- 🔴 **Critical**: Delivery issues or cluster problems
## Alerts
Configure alerts for PMG metrics in **Settings → Alerts**:
- Queue depth exceeding threshold
- Spam rate spike
- Delivery failures
- Cluster node offline
## Multi-Instance Support
Monitor multiple PMG instances from a single Pulse dashboard:
- Compare spam rates across gateways
- Aggregate mail statistics
- View cluster-wide health
## Troubleshooting
### Connection refused
1. Verify PMG is accessible on port 8006
2. Check firewall rules
3. Ensure API token has correct permissions
### No statistics showing
1. Wait for initial data collection (may take 1-2 polling cycles)
2. Verify PMG has mail activity
3. Check Pulse logs for API errors
### Cluster nodes missing
1. PMG cluster must be properly configured
2. API token needs cluster-wide permissions
3. All nodes must be reachable from Pulse

87
docs/METRICS_HISTORY.md Normal file
View file

@ -0,0 +1,87 @@
# Metrics History
Pulse 5.0 introduces persistent metrics history, allowing you to view historical resource usage data and trends over time.
## Features
- **Persistent Storage**: Metrics are saved to disk and survive restarts
- **Configurable Retention**: Set how long to keep different metric types
- **Trend Analysis**: View resource usage patterns over time
- **Spark Lines**: See at-a-glance trends in the dashboard
## Configuration
### Retention Settings
Configure retention periods in **Settings → General → Metrics History**:
| Metric Type | Default | Description |
|-------------|---------|-------------|
| **Host Metrics** | 7 days | CPU, memory, disk for hypervisors |
| **Guest Metrics** | 7 days | VM and container metrics |
| **Container Metrics** | 3 days | Docker/Podman container stats |
| **Aggregate Metrics** | 30 days | Cluster-wide summaries |
### Environment Variables
```bash
# Override via environment
PULSE_METRICS_HOST_RETENTION_DAYS=14
PULSE_METRICS_GUEST_RETENTION_DAYS=14
PULSE_METRICS_CONTAINER_RETENTION_DAYS=7
PULSE_METRICS_AGGREGATE_RETENTION_DAYS=60
```
## Storage
Metrics are stored in `/etc/pulse/data/metrics/` (or your configured data directory).
### Disk Usage
Approximate storage requirements:
- ~1 KB per resource per hour
- 10 hosts × 50 guests × 7 days ≈ 8 MB
### Database Maintenance
Pulse automatically:
- Compacts old data
- Prunes metrics beyond retention period
- Optimizes storage during low-usage periods
## API Access
Query historical metrics via the API:
```bash
# Get metrics for a specific resource
curl -H "X-API-Token: $TOKEN" \
"http://localhost:7655/api/metrics/history?resource=vm-100&hours=24"
# Get aggregated cluster metrics
curl -H "X-API-Token: $TOKEN" \
"http://localhost:7655/api/metrics/history?type=aggregate&days=7"
```
## Visualization
### Dashboard Sparklines
The dashboard shows 24-hour trend sparklines for each resource, updating in real-time.
### Detailed Charts
Click on any resource to see detailed historical charts with:
- Selectable time ranges (1h, 6h, 24h, 7d, 30d)
- Multiple metric overlays (CPU, memory, disk, network)
- Zoom and pan controls
## Troubleshooting
### Metrics not persisting
1. Check data directory permissions
2. Verify disk space availability
3. Check logs: `journalctl -u pulse | grep metrics`
### High disk usage
1. Reduce retention periods in Settings
2. Exclude low-value resources from history
3. Run manual cleanup: Settings → General → Clear Old Metrics

View file

@ -26,6 +26,14 @@ Welcome to the Pulse documentation portal. Here you'll find everything you need
- **[Security Policy](../SECURITY.md)** The core security model (Encryption, Auth, API Scopes).
## ✨ New in 5.0
- **[Pulse AI](AI.md)** Intelligent monitoring assistant with chat, patrol mode, and auto-fix.
- **[Metrics History](METRICS_HISTORY.md)** Persistent metrics storage with configurable retention.
- **[Mail Gateway](MAIL_GATEWAY.md)** Proxmox Mail Gateway (PMG) monitoring.
- **[Auto Updates](AUTO_UPDATE.md)** One-click updates for supported deployments.
- **[Kubernetes](KUBERNETES.md)** Complete K8s cluster monitoring via agents.
## 📡 Monitoring & Agents
- **[Unified Agent](UNIFIED_AGENT.md)** Single binary for Host and Docker monitoring.

View file

@ -1,6 +1,6 @@
# Pulse Unified Agent
The unified agent (`pulse-agent`) combines host and Docker monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management.
The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management.
## Quick Start
@ -29,6 +29,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
- **Host Metrics**: CPU, memory, disk, network I/O, temperatures
- **Docker Monitoring**: Container metrics, health checks, Swarm support (when enabled)
- **Kubernetes Monitoring**: Cluster, node, pod, and deployment health (when enabled)
- **Auto-Update**: Automatically updates when a new version is released
- **Multi-Platform**: Linux, macOS, Windows support
@ -41,6 +42,13 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` |
| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` |
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | `false` |
| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (optional) | *(auto)* |
| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context (optional) | *(auto)* |
| `--kube-include-namespace` | `PULSE_KUBE_INCLUDE_NAMESPACES` | Limit namespaces (repeatable or CSV) | *(all)* |
| `--kube-exclude-namespace` | `PULSE_KUBE_EXCLUDE_NAMESPACES` | Exclude namespaces (repeatable or CSV) | *(none)* |
| `--kube-include-all-pods` | `PULSE_KUBE_INCLUDE_ALL_PODS` | Include all non-succeeded pods | `false` |
| `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` |
| `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` |
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
@ -61,6 +69,12 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
```
### Host + Kubernetes Monitoring
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
```
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \

View file

@ -25,6 +25,8 @@ Edit `mock.env` (or `mock.env.local` for overrides):
| `PULSE_MOCK_LXCS_PER_NODE` | `8` | Containers per node. |
| `PULSE_MOCK_RANDOM_METRICS` | `true` | Jitter metrics. |
| `PULSE_MOCK_STOPPED_PERCENT` | `20` | % of offline guests. |
| `PULSE_MOCK_TRENDS_SEED_DURATION` | `1h` | Pre-seed backend chart history (improves demo “Trends” immediately). |
| `PULSE_MOCK_TRENDS_SAMPLE_INTERVAL` | `30s` | Backend chart sampling interval while in mock mode. |
## How it Works
* **Data**: Swaps `PULSE_DATA_DIR` to `/opt/pulse/tmp/mock-data`.

View file

@ -1,5 +1,9 @@
# ⚙️ Sensor Proxy Configuration
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Safe configuration management using the CLI (v4.31.1+).
## 📂 Files

View file

@ -1,5 +1,9 @@
# 📝 Sensor Proxy Log Forwarding
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Forward `audit.log` and `proxy.log` to a central SIEM via RELP + TLS.
## 🚀 Quick Start

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Secure `pulse-sensor-proxy` with AppArmor and Seccomp.
## 🛡️ AppArmor

View file

@ -1,5 +1,9 @@
# 🛡️ Sensor Proxy Hardening
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
The `pulse-sensor-proxy` runs on the host to securely collect temperatures, keeping SSH keys out of containers.
## 🏗️ Architecture

View file

@ -1,5 +1,9 @@
# 🌐 Sensor Proxy Network Segmentation
> **⚠️ Deprecated:** The sensor-proxy is deprecated in favor of the unified Pulse agent.
> For new installations, use `install.sh --enable-proxmox` instead.
> See [TEMPERATURE_MONITORING.md](/docs/security/TEMPERATURE_MONITORING.md).
Isolate the proxy to prevent lateral movement.
## 🚧 Zones

View file

@ -1,29 +1,57 @@
# 🌡️ Temperature Monitoring Security
# 🌡️ Temperature Monitoring
Secure architecture for collecting hardware temperatures.
Pulse supports two methods for collecting hardware temperatures from Proxmox nodes.
## 🛡️ Security Model
## Recommended: Pulse Agent
The simplest and most feature-rich method is installing the Pulse agent on your Proxmox nodes:
```bash
curl -fsSL http://your-pulse-server:7655/api/download/install.sh | bash -s -- \
--url http://your-pulse-server:7655 \
--token YOUR_TOKEN \
--enable-proxmox
```
**Benefits:**
- ✅ One-command setup
- ✅ Automatic API token creation
- ✅ Temperature monitoring built-in
- ✅ Enables AI features for VM/container management
- ✅ No SSH keys or proxy configuration required
The agent runs `sensors -j` locally and reports temperatures directly to Pulse.
---
## Legacy: Sensor Proxy (SSH-based)
For users who prefer not to install an agent on their hypervisor, the sensor-proxy method is still available.
> **Note:** This method is deprecated and will be removed in a future release. Consider migrating to the agent-based approach.
### 🛡️ Security Model
* **Isolation**: SSH keys live on the host, not in the container.
* **Least Privilege**: Proxy runs as `pulse-sensor-proxy` (no shell).
* **Verification**: Container identity verified via `SO_PEERCRED`.
## 🏗️ Components
### 🏗️ Components
1. **Pulse Backend**: Connects to Unix socket `/mnt/pulse-proxy/pulse-sensor-proxy.sock`.
2. **Sensor Proxy**: Validates request, executes SSH to node.
3. **Target Node**: Accepts SSH key restricted to `sensors -j`.
## 🔒 Key Restrictions
### 🔒 Key Restrictions
SSH keys deployed to nodes are locked down:
```
command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty
```
## 🚦 Rate Limiting
### 🚦 Rate Limiting
* **Per Peer**: ~12 req/min.
* **Concurrency**: Max 2 parallel requests per peer.
* **Global**: Max 8 concurrent requests.
## 📝 Auditing
### 📝 Auditing
All requests logged to system journal:
```bash
journalctl -u pulse-sensor-proxy

View file

@ -6,6 +6,34 @@
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse</title>
<!-- Inline critical styles to prevent flash of unstyled content -->
<style>
/* Apply theme immediately before any CSS loads */
html { background-color: #f3f4f6; } /* gray-100 */
html.dark { background-color: #111827; } /* gray-900 */
body { margin: 0; min-height: 100vh; }
#root { min-height: 100vh; }
/* Hide content until app is ready to prevent layout shift */
#root:empty {
display: flex;
align-items: center;
justify-content: center;
background-color: inherit;
}
</style>
<!-- Apply dark mode immediately from localStorage to prevent flash -->
<script>
(function() {
try {
var darkMode = localStorage.getItem('pulse_dark_mode');
var prefersDark = darkMode === 'true' ||
(darkMode === null && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (prefersDark) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

Binary file not shown.

View file

@ -27,11 +27,14 @@
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"dompurify": "^3.3.1",
"lucide-solid": "^0.545.0",
"marked": "^17.0.1",
"solid-js": "^1.8.0"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.5",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.5.0",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",

View file

@ -35,14 +35,21 @@ import { createTooltipSystem } from './components/shared/Tooltip';
import type { State } from '@/types/api';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { startMetricsSampler } from './stores/metricsSampler';
import { seedFromBackend } from './stores/metricsHistory';
import { getMetricsViewMode } from './stores/metricsViewMode';
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 NetworkIcon from 'lucide-solid/icons/network';
import { TokenRevealDialog } from './components/TokenRevealDialog';
import { useAlertsActivation } from './stores/alertsActivation';
import { UpdateProgressModal } from './components/UpdateProgressModal';
import type { UpdateStatus } from './api/updates';
import { AIChat } from './components/AI/AIChat';
import { AIStatusIndicator } from './components/AI/AIStatusIndicator';
import { aiChatStore } from './stores/aiChat';
import { useResourcesAsLegacy } from './hooks/useResources';
const Dashboard = lazy(() =>
import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })),
@ -51,6 +58,7 @@ const StorageComponent = lazy(() => import('./components/Storage/Storage'));
const Backups = lazy(() => import('./components/Backups/Backups'));
const Replication = lazy(() => import('./components/Replication/Replication'));
const MailGateway = lazy(() => import('./components/PMG/MailGateway'));
const CephPage = lazy(() => import('./pages/Ceph'));
const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
);
@ -58,12 +66,18 @@ const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const DockerHosts = lazy(() =>
import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })),
);
const KubernetesClusters = lazy(() =>
import('./components/Kubernetes/KubernetesClusters').then((module) => ({
default: module.KubernetesClusters,
})),
);
const HostsOverview = lazy(() =>
import('./components/Hosts/HostsOverview').then((module) => ({
default: module.HostsOverview,
})),
);
// Enhanced store type with proper typing
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
@ -87,26 +101,29 @@ export const useDarkMode = () => {
return context;
};
// Docker route component that properly uses activeAlerts from useWebSocket
// Docker route component - uses unified resources via useResourcesAsLegacy hook
function DockerRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { state, activeAlerts } = wsContext;
const hosts = createMemo(() => state.dockerHosts ?? []);
return <DockerHosts hosts={hosts()} activeAlerts={activeAlerts} />;
const { activeAlerts } = wsContext;
const { asDockerHosts } = useResourcesAsLegacy();
return <DockerHosts hosts={asDockerHosts() as any} activeAlerts={activeAlerts} />;
}
// Hosts route component - HostsOverview uses useResourcesAsLegacy directly for proper reactivity
function HostsRoute() {
return <HostsOverview />;
}
function KubernetesRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { state } = wsContext;
return (
<HostsOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
);
return <KubernetesClusters clusters={wsContext.state.kubernetesClusters ?? []} />;
}
// Helper to detect if an update is actively in progress (not just checking for updates)
@ -211,6 +228,13 @@ function App() {
// Start metrics sampler for sparklines
onMount(() => {
startMetricsSampler();
// If user already has sparklines mode enabled, seed historical data immediately
if (getMetricsViewMode() === 'sparklines') {
seedFromBackend('1h').catch(() => {
// Errors are already logged in seedFromBackend
});
}
});
let hasPreloadedRoutes = false;
@ -226,6 +250,7 @@ function App() {
() => import('./components/Replication/Replication'),
() => import('./components/PMG/MailGateway'),
() => import('./components/Hosts/HostsOverview'),
() => import('./pages/Alerts'),
() => import('./components/Settings/Settings'),
() => import('./components/Docker/DockerHosts'),
@ -251,23 +276,23 @@ function App() {
pmg: [],
replicationJobs: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbsBackups: [],
pmgBackups: [],
backups: {
pve: {
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbs: [],
pmg: [],
},
performance: {
pbsBackups: [],
pmgBackups: [],
backups: {
pve: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbs: [],
pmg: [],
},
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
@ -498,9 +523,9 @@ function App() {
// Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning
if (securityData.deprecatedDisableAuth === true) {
logger.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;
@ -713,9 +738,14 @@ function App() {
// Pass through the store directly (only when initialized)
const enhancedStore = () => wsStore();
const DashboardView = () => (
<Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />
);
// Dashboard view - uses unified resources via useResourcesAsLegacy hook
const DashboardView = () => {
const { asVMs, asContainers, asNodes } = useResourcesAsLegacy();
return (
<Dashboard vms={asVMs() as any} containers={asContainers() as any} nodes={asNodes() as any} />
);
};
const SettingsRoute = () => (
<SettingsPage darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
@ -723,42 +753,120 @@ function App() {
// Root layout component for Router
const RootLayout = (props: { children?: JSX.Element }) => {
// Check AI settings on mount and setup keyboard shortcut
onMount(() => {
// Only check AI settings if already authenticated (not on login screen)
// Otherwise, the 401 response triggers a redirect loop
if (!needsAuth()) {
import('./api/ai').then(({ AIAPI }) => {
AIAPI.getSettings()
.then((settings) => {
aiChatStore.setEnabled(settings.enabled && settings.configured);
})
.catch(() => {
aiChatStore.setEnabled(false);
});
});
}
// Keyboard shortcut: Cmd/Ctrl+K to toggle AI
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (aiChatStore.enabled) {
aiChatStore.toggle();
}
}
// Escape to close
if (e.key === 'Escape' && aiChatStore.isOpen) {
aiChatStore.close();
}
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
});
});
return (
<Show
when={!isLoading()}
fallback={
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="text-gray-600 dark:text-gray-400">Loading...</div>
</div>
}
>
<Show when={!needsAuth()} fallback={<Login onLogin={handleLogin} />}>
<Show when={!needsAuth()} fallback={<Login onLogin={handleLogin} hasAuth={hasAuth()} />}>
<ErrorBoundary>
<Show when={enhancedStore()} fallback={<div>Initializing...</div>}>
<Show when={enhancedStore()} fallback={
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="text-gray-600 dark:text-gray-400">Initializing...</div>
</div>
}>
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<GlobalUpdateProgressWatcher />
<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}
reconnecting={reconnecting}
dataUpdated={dataUpdated}
lastUpdateText={lastUpdateText}
versionInfo={versionInfo}
hasAuth={hasAuth}
needsAuth={needsAuth}
proxyAuthInfo={proxyAuthInfo}
handleLogout={handleLogout}
state={state}
>
{props.children}
</AppLayout>
{/* Main layout container - flexbox to allow AI panel to push content */}
<div class="flex h-screen overflow-hidden">
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
<div class={`flex-1 min-w-0 overflow-y-auto bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans py-4 sm:py-6 transition-all duration-300`}>
<AppLayout
connected={connected}
reconnecting={reconnecting}
dataUpdated={dataUpdated}
lastUpdateText={lastUpdateText}
versionInfo={versionInfo}
hasAuth={hasAuth}
needsAuth={needsAuth}
proxyAuthInfo={proxyAuthInfo}
handleLogout={handleLogout}
state={state}
>
{props.children}
</AppLayout>
</div>
{/* AI Panel - slides in from right, pushes content */}
<AIChat onClose={() => aiChatStore.close()} />
</div>
<ToastContainer />
<TokenRevealDialog />
{/* Fixed AI Assistant Button - always visible on the side when AI is enabled */}
<Show when={aiChatStore.enabled !== false && !aiChatStore.isOpen}>
{/* This component only shows when chat is closed */}
<button
type="button"
onClick={() => aiChatStore.toggle()}
class="fixed right-0 top-1/2 -translate-y-1/2 z-40 flex items-center gap-1.5 pl-2 pr-1.5 py-3 rounded-l-xl bg-gradient-to-r from-purple-600 to-purple-700 text-white shadow-lg hover:from-purple-700 hover:to-purple-800 transition-all duration-200 group"
title={aiChatStore.context.context?.name ? `AI Assistant - ${aiChatStore.context.context.name}` : 'AI Assistant (⌘K)'}
aria-label="Expand AI Assistant"
>
{/* Double chevron left - expand */}
<svg
class="h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 19l-7-7 7-7M18 19l-7-7 7-7"
/>
</svg>
{/* Context indicator - shows count when items are in context */}
<Show when={aiChatStore.contextItems.length > 0}>
<span class="min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold bg-green-500 text-white rounded-full">
{aiChatStore.contextItems.length}
</span>
</Show>
</button>
</Show>
<TooltipRoot />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
@ -776,13 +884,16 @@ function App() {
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox/overview" component={DashboardView} />
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/ceph" component={CephPage} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
<Route path="/proxmox/backups" component={Backups} />
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
<Route path="/docker" component={DockerRoute} />
<Route path="/kubernetes" component={KubernetesRoute} />
<Route path="/hosts" component={HostsRoute} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/alerts/*" component={AlertsPage} />
<Route path="/settings/*" component={SettingsRoute} />
@ -797,13 +908,12 @@ function ConnectionStatusBadge(props: {
}) {
return (
<div
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
props.connected()
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
: props.reconnecting()
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-w-6 h-6 group-hover:px-3'
} ${props.class ?? ''}`}
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${props.connected()
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
: props.reconnecting()
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-w-6 h-6 group-hover:px-3'
} ${props.class ?? ''}`}
>
<Show when={props.reconnecting()}>
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
@ -829,11 +939,10 @@ function ConnectionStatusBadge(props: {
<span class="h-2.5 w-2.5 rounded-full bg-gray-600 dark:bg-gray-400 flex-shrink-0"></span>
</Show>
<span
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
props.connected() || (!props.connected() && !props.reconnecting())
? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
: 'max-w-[100px] ml-1 opacity-100'
}`}
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${props.connected() || (!props.connected() && !props.reconnecting())
? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
: 'max-w-[100px] ml-1 opacity-100'
}`}
>
{props.connected()
? 'Connected'
@ -904,6 +1013,7 @@ function AppLayout(props: {
const path = location.pathname;
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/kubernetes')) return 'kubernetes';
if (path.startsWith('/hosts')) return 'hosts';
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
if (path.startsWith('/alerts')) return 'alerts';
@ -911,6 +1021,7 @@ function AppLayout(props: {
return 'proxmox';
};
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
const hasKubernetesClusters = createMemo(() => (props.state().kubernetesClusters?.length ?? 0) > 0);
const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
const hasProxmoxHosts = createMemo(
() =>
@ -925,6 +1036,12 @@ function AppLayout(props: {
}
});
createEffect(() => {
if (hasKubernetesClusters()) {
markPlatformSeen('kubernetes');
}
});
createEffect(() => {
if (hasProxmoxHosts()) {
markPlatformSeen('proxmox');
@ -938,7 +1055,7 @@ function AppLayout(props: {
});
const platformTabs = createMemo(() => {
return [
const allPlatforms = [
{
id: 'proxmox' as const,
label: 'Proxmox',
@ -950,6 +1067,7 @@ function AppLayout(props: {
icon: (
<ProxmoxIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Proxmox is the default, always show
},
{
id: 'docker' as const,
@ -962,6 +1080,20 @@ function AppLayout(props: {
icon: (
<BoxesIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Docker is commonly used, keep visible
},
{
id: 'kubernetes' as const,
label: 'Kubernetes',
route: '/kubernetes',
settingsRoute: '/settings/agents',
tooltip: 'Monitor Kubernetes clusters and workloads',
enabled: hasKubernetesClusters(),
live: hasKubernetesClusters(),
icon: (
<NetworkIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: false, // Only show when clusters exist
},
{
id: 'hosts' as const,
@ -974,8 +1106,12 @@ function AppLayout(props: {
icon: (
<MonitorIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Hosts is commonly used, keep visible
},
];
// Filter out platforms that should be hidden when not configured
return allPlatforms.filter(p => p.alwaysShow || p.enabled);
});
const utilityTabs = createMemo(() => {
@ -1074,6 +1210,8 @@ function AppLayout(props: {
<div class="header-controls flex items-center gap-2 justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-2">
{/* AI Patrol Status Indicator */}
<AIStatusIndicator />
<Show when={props.proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
{props.proxyAuthInfo()?.username}
@ -1168,12 +1306,12 @@ function AppLayout(props: {
const baseClasses =
'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`;
}
return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`;
};
const className = () => {
if (isActive()) {
return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`;
}
return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`;
};
return (
<div

View file

@ -0,0 +1,259 @@
/**
* Tests for Charts API types and interface
*/
import { describe, expect, it } from 'vitest';
import type { ChartData, ChartsResponse, TimeRange, MetricPoint, ChartStats } from '@/api/charts';
// Note: We test the types and interfaces here since the actual API calls
// require a running backend. Integration tests should cover the full flow.
describe('Charts API Types', () => {
describe('TimeRange', () => {
it('supports all expected time range values', () => {
const validRanges: TimeRange[] = ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d'];
validRanges.forEach(range => {
// This is a compile-time check - if it compiles, the types are correct
const r: TimeRange = range;
expect(r).toBe(range);
});
});
});
describe('MetricPoint', () => {
it('has required timestamp and value properties', () => {
const point: MetricPoint = {
timestamp: 1733700000000,
value: 45.7,
};
expect(point.timestamp).toBe(1733700000000);
expect(point.value).toBe(45.7);
});
});
describe('ChartData', () => {
it('supports all metric types', () => {
const data: ChartData = {
cpu: [{ timestamp: 1000, value: 50 }],
memory: [{ timestamp: 1000, value: 60 }],
disk: [{ timestamp: 1000, value: 70 }],
diskread: [{ timestamp: 1000, value: 1000000 }],
diskwrite: [{ timestamp: 1000, value: 500000 }],
netin: [{ timestamp: 1000, value: 2000000 }],
netout: [{ timestamp: 1000, value: 1500000 }],
};
expect(data.cpu).toHaveLength(1);
expect(data.memory![0].value).toBe(60);
});
it('allows partial data (all fields optional)', () => {
const cpuOnly: ChartData = {
cpu: [{ timestamp: 1000, value: 25 }],
};
expect(cpuOnly.cpu).toBeDefined();
expect(cpuOnly.memory).toBeUndefined();
expect(cpuOnly.disk).toBeUndefined();
});
it('allows empty arrays for metrics', () => {
const emptyData: ChartData = {
cpu: [],
memory: [],
};
expect(emptyData.cpu).toHaveLength(0);
});
});
describe('ChartsResponse', () => {
it('includes all required fields', () => {
const response: ChartsResponse = {
data: {},
nodeData: {},
storageData: {},
timestamp: Date.now(),
stats: { oldestDataTimestamp: Date.now() - 3600000 },
};
expect(response.data).toBeDefined();
expect(response.nodeData).toBeDefined();
expect(response.timestamp).toBeGreaterThan(0);
});
it('supports optional dockerData field', () => {
const response: ChartsResponse = {
data: {},
nodeData: {},
storageData: {},
dockerData: {
'container-abc123': {
cpu: [{ timestamp: 1000, value: 10 }],
memory: [{ timestamp: 1000, value: 20 }],
},
},
timestamp: Date.now(),
stats: { oldestDataTimestamp: Date.now() },
};
expect(response.dockerData).toBeDefined();
expect(response.dockerData!['container-abc123'].cpu).toHaveLength(1);
});
it('supports optional dockerHostData field', () => {
const response: ChartsResponse = {
data: {},
nodeData: {},
storageData: {},
dockerHostData: {
'host-1': {
cpu: [{ timestamp: 1000, value: 30 }],
memory: [{ timestamp: 1000, value: 40 }],
},
},
timestamp: Date.now(),
stats: { oldestDataTimestamp: Date.now() },
};
expect(response.dockerHostData).toBeDefined();
expect(response.dockerHostData!['host-1'].cpu![0].value).toBe(30);
});
it('supports optional guestTypes field for VM/container type mapping', () => {
const response: ChartsResponse = {
data: {
'vm-100': { cpu: [{ timestamp: 1000, value: 50 }] },
'ct-200': { cpu: [{ timestamp: 1000, value: 30 }] },
},
nodeData: {},
storageData: {},
guestTypes: {
'vm-100': 'vm',
'ct-200': 'container',
},
timestamp: Date.now(),
stats: { oldestDataTimestamp: Date.now() },
};
expect(response.guestTypes!['vm-100']).toBe('vm');
expect(response.guestTypes!['ct-200']).toBe('container');
});
it('contains all data sources for comprehensive monitoring', () => {
const comprehensiveResponse: ChartsResponse = {
// VM and container metrics (legacy format)
data: {
'pve1/qemu/100': {
cpu: [{ timestamp: 1000, value: 45 }],
memory: [{ timestamp: 1000, value: 55 }],
disk: [{ timestamp: 1000, value: 30 }],
},
'pve1/lxc/200': {
cpu: [{ timestamp: 1000, value: 15 }],
},
},
// Node metrics
nodeData: {
'pve1': {
cpu: [{ timestamp: 1000, value: 35 }],
memory: [{ timestamp: 1000, value: 65 }],
},
},
// Storage metrics
storageData: {
'local-zfs': {
disk: [{ timestamp: 1000, value: 50 }],
},
},
// Docker container metrics
dockerData: {
'abc123': {
cpu: [{ timestamp: 1000, value: 5 }],
memory: [{ timestamp: 1000, value: 128 }],
},
},
// Docker host metrics
dockerHostData: {
'docker-host-1': {
cpu: [{ timestamp: 1000, value: 25 }],
},
},
// Guest type mapping
guestTypes: {
'pve1/qemu/100': 'vm',
'pve1/lxc/200': 'container',
},
timestamp: 1733700000000,
stats: {
oldestDataTimestamp: 1733696400000, // 1 hour ago
},
};
expect(Object.keys(comprehensiveResponse.data)).toHaveLength(2);
expect(Object.keys(comprehensiveResponse.nodeData)).toHaveLength(1);
expect(Object.keys(comprehensiveResponse.dockerData!)).toHaveLength(1);
expect(Object.keys(comprehensiveResponse.dockerHostData!)).toHaveLength(1);
});
});
describe('ChartStats', () => {
it('contains oldest data timestamp for data availability', () => {
const stats: ChartStats = {
oldestDataTimestamp: 1733696400000,
};
expect(stats.oldestDataTimestamp).toBe(1733696400000);
});
it('can be used to determine available data range', () => {
const now = Date.now();
const oneHourAgo = now - 3600000;
const stats: ChartStats = {
oldestDataTimestamp: oneHourAgo,
};
const availableRangeMs = now - stats.oldestDataTimestamp;
expect(availableRangeMs).toBeCloseTo(3600000, -2); // Allow 100ms tolerance
});
});
});
describe('Time Range to Milliseconds Conversion', () => {
// This tests the concept matching metricsHistory.ts timeRangeToMs
function timeRangeToMs(range: TimeRange): number {
switch (range) {
case '5m': return 5 * 60 * 1000;
case '15m': return 15 * 60 * 1000;
case '30m': return 30 * 60 * 1000;
case '1h': return 60 * 60 * 1000;
case '4h': return 4 * 60 * 60 * 1000;
case '12h': return 12 * 60 * 60 * 1000;
case '24h': return 24 * 60 * 60 * 1000;
case '7d': return 7 * 24 * 60 * 60 * 1000;
default: return 60 * 60 * 1000;
}
}
const expectedValues: [TimeRange, number][] = [
['5m', 300000],
['15m', 900000],
['30m', 1800000],
['1h', 3600000],
['4h', 14400000],
['12h', 43200000],
['24h', 86400000],
['7d', 604800000],
];
it.each(expectedValues)('converts %s to %d ms', (range, expectedMs) => {
expect(timeRangeToMs(range)).toBe(expectedMs);
});
it('defaults to 1 hour for unknown range', () => {
// @ts-expect-error - Testing invalid input
expect(timeRangeToMs('invalid')).toBe(3600000);
});
});

View file

@ -0,0 +1,417 @@
import { apiFetchJSON, apiFetch } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
import type {
AISettings,
AISettingsUpdateRequest,
AITestResult,
AIExecuteRequest,
AIExecuteResponse,
AIStreamEvent,
AICostSummary,
} from '@/types/ai';
import type {
PatternsResponse,
PredictionsResponse,
CorrelationsResponse,
ChangesResponse,
BaselinesResponse,
} from '@/types/aiIntelligence';
export class AIAPI {
private static baseUrl = '/api';
// Get AI settings
static async getSettings(): Promise<AISettings> {
return apiFetchJSON(`${this.baseUrl}/settings/ai`) as Promise<AISettings>;
}
// Update AI settings
static async updateSettings(settings: AISettingsUpdateRequest): Promise<AISettings> {
return apiFetchJSON(`${this.baseUrl}/settings/ai/update`, {
method: 'PUT',
body: JSON.stringify(settings),
}) as Promise<AISettings>;
}
// Test AI connection
static async testConnection(): Promise<AITestResult> {
return apiFetchJSON(`${this.baseUrl}/ai/test`, {
method: 'POST',
}) as Promise<AITestResult>;
}
// Test a specific provider connection
static async testProvider(provider: string): Promise<{ success: boolean; message: string; provider: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/test/${provider}`, {
method: 'POST',
}) as Promise<{ success: boolean; message: string; provider: string }>;
}
// Get available models from the AI provider
static async getModels(): Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }>;
}
// Get AI cost/usage summary
static async getCostSummary(days = 30): Promise<AICostSummary> {
return apiFetchJSON(`${this.baseUrl}/ai/cost/summary?days=${days}`) as Promise<AICostSummary>;
}
// Reset AI usage history (admin-only)
static async resetCostHistory(): Promise<{ ok: boolean; backup_file?: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/cost/reset`, {
method: 'POST',
body: JSON.stringify({}),
}) as Promise<{ ok: boolean; backup_file?: string }>;
}
static async exportCostHistory(days = 30, format: 'json' | 'csv' = 'csv'): Promise<Response> {
return apiFetch(`${this.baseUrl}/ai/cost/export?days=${days}&format=${format}`, { method: 'GET' });
}
// ============================================
// AI Intelligence API - Patterns, Predictions, Correlations
// ============================================
// Get detected failure patterns
static async getPatterns(resourceId?: string): Promise<PatternsResponse> {
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/patterns${params}`) as Promise<PatternsResponse>;
}
// Get failure predictions
static async getPredictions(resourceId?: string): Promise<PredictionsResponse> {
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/predictions${params}`) as Promise<PredictionsResponse>;
}
// Get resource correlations
static async getCorrelations(resourceId?: string): Promise<CorrelationsResponse> {
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/correlations${params}`) as Promise<CorrelationsResponse>;
}
// Get recent infrastructure changes
static async getRecentChanges(hours = 24): Promise<ChangesResponse> {
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/changes?hours=${hours}`) as Promise<ChangesResponse>;
}
// Get learned baselines
static async getBaselines(resourceId?: string): Promise<BaselinesResponse> {
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/baselines${params}`) as Promise<BaselinesResponse>;
}
// Start OAuth flow for Claude Pro/Max subscription
// Returns the authorization URL to redirect the user to
static async startOAuth(): Promise<{ auth_url: string; state: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/oauth/start`, {
method: 'POST',
}) as Promise<{ auth_url: string; state: string }>;
}
// Exchange manually-pasted authorization code for tokens
static async exchangeOAuthCode(code: string, state: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/oauth/exchange`, {
method: 'POST',
body: JSON.stringify({ code, state }),
}) as Promise<{ success: boolean; message: string }>;
}
// Disconnect OAuth and clear tokens
static async disconnectOAuth(): Promise<{ success: boolean; message: string }> {
return apiFetchJSON(`${this.baseUrl}/ai/oauth/disconnect`, {
method: 'POST',
}) as Promise<{ success: boolean; message: string }>;
}
// Execute an AI prompt
static async execute(request: AIExecuteRequest): Promise<AIExecuteResponse> {
return apiFetchJSON(`${this.baseUrl}/ai/execute`, {
method: 'POST',
body: JSON.stringify(request),
}) as Promise<AIExecuteResponse>;
}
// Run a single command (for approved commands)
static async runCommand(request: {
command: string;
target_type: string;
target_id: string;
run_on_host: boolean;
vmid?: string;
target_host?: string; // Explicit host for command routing
}): Promise<{ output: string; success: boolean; error?: string }> {
// Ensure run_on_host is explicitly a boolean (not undefined)
const sanitizedRequest = {
command: request.command,
target_type: request.target_type,
target_id: request.target_id,
run_on_host: Boolean(request.run_on_host),
...(request.vmid ? { vmid: String(request.vmid) } : {}),
...(request.target_host ? { target_host: request.target_host } : {}),
};
const body = JSON.stringify(sanitizedRequest);
logger.debug('[AI] runCommand', { request: sanitizedRequest, bodyLength: body.length });
return apiFetchJSON(`${this.baseUrl}/ai/run-command`, {
method: 'POST',
body,
}) as Promise<{ output: string; success: boolean; error?: string }>;
}
// Investigate an alert with AI (one-click investigation)
static async investigateAlert(
request: {
alert_id: string;
resource_id: string;
resource_name: string;
resource_type: string;
alert_type: string;
level: string;
value: number;
threshold: number;
message: string;
duration: string;
node?: string;
vmid?: number;
},
onEvent: (event: AIStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
logger.debug('[AI] Starting alert investigation', request);
const response = await apiFetch(`${this.baseUrl}/ai/investigate-alert`, {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
signal,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed with status ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
// 5 minutes timeout - Opus models can take a long time
const STREAM_TIMEOUT_MS = 300000;
let lastEventTime = Date.now();
try {
for (;;) {
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
console.warn('[AI] Alert investigation stream timeout');
break;
}
const readPromise = reader.read();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Read timeout')), STREAM_TIMEOUT_MS);
});
let result: ReadableStreamReadResult<Uint8Array>;
try {
result = await Promise.race([readPromise, timeoutPromise]);
} catch (e) {
if ((e as Error).message === 'Read timeout') break;
throw e;
}
const { done, value } = result;
if (done) break;
lastEventTime = Date.now();
buffer += decoder.decode(value, { stream: true });
const normalizedBuffer = buffer.replace(/\r\n/g, '\n');
const messages = normalizedBuffer.split('\n\n');
buffer = messages.pop() || '';
for (const message of messages) {
if (!message.trim() || message.trim().startsWith(':')) continue;
const dataLines = message.split('\n').filter((line) => line.startsWith('data: '));
for (const line of dataLines) {
try {
const jsonStr = line.slice(6);
if (!jsonStr.trim()) continue;
const data = JSON.parse(jsonStr);
onEvent(data as AIStreamEvent);
} catch (e) {
console.error('[AI] Failed to parse investigation event:', e);
}
}
}
}
} finally {
reader.releaseLock();
}
}
// Execute an AI prompt with streaming
// Returns an abort function to cancel the request
static async executeStream(
request: AIExecuteRequest,
onEvent: (event: AIStreamEvent) => void,
signal?: AbortSignal
): Promise<void> {
logger.debug('[AI SSE] Starting streaming request', request);
const response = await apiFetch(`${this.baseUrl}/ai/execute/stream`, {
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
signal,
});
logger.debug('[AI SSE] Response status', { status: response.status, contentType: response.headers.get('content-type') });
if (!response.ok) {
const text = await response.text();
logger.error('[AI SSE] Request failed', text);
throw new Error(text || `Request failed with status ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
logger.error('[AI SSE] No response body');
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
let lastEventTime = Date.now();
let receivedComplete = false;
let receivedDone = false;
// Timeout to detect stalled streams (5 minutes - Opus models can take a long time)
const STREAM_TIMEOUT_MS = 300000;
logger.debug('[AI SSE] Starting to read stream');
try {
for (;;) {
// Check for stream timeout
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
logger.warn('[AI SSE] Stream timeout', { seconds: STREAM_TIMEOUT_MS / 1000 });
break;
}
// Create a promise with timeout for the read operation
const readPromise = reader.read();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Read timeout')), STREAM_TIMEOUT_MS);
});
let result: ReadableStreamReadResult<Uint8Array>;
try {
result = await Promise.race([readPromise, timeoutPromise]);
} catch (e) {
if ((e as Error).message === 'Read timeout') {
logger.warn('[AI SSE] Read timeout, ending stream');
break;
}
throw e;
}
const { done, value } = result;
if (done) {
logger.debug('[AI SSE] Stream ended normally');
break;
}
lastEventTime = Date.now();
const chunk = decoder.decode(value, { stream: true });
// Log chunk info only if it's not just a heartbeat
if (!chunk.includes(': heartbeat')) {
logger.debug('[AI SSE] Received chunk', { bytes: chunk.length });
}
buffer += chunk;
// Process complete SSE messages (separated by double newlines)
// Handle both \n\n and \r\n\r\n for cross-platform compatibility
const normalizedBuffer = buffer.replace(/\r\n/g, '\n');
const messages = normalizedBuffer.split('\n\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
// Skip empty messages and heartbeat comments
if (!message.trim() || message.trim().startsWith(':')) {
if (message.includes('heartbeat')) {
logger.debug('[AI SSE] Received heartbeat');
}
continue;
}
// Parse SSE message (can have multiple lines, look for data: prefix)
const dataLines = message.split('\n').filter(line => line.startsWith('data: '));
for (const line of dataLines) {
try {
const jsonStr = line.slice(6); // Remove 'data: ' prefix
if (!jsonStr.trim()) continue;
const data = JSON.parse(jsonStr);
logger.debug('[AI SSE] Parsed event', { type: data.type, data });
// Track completion events
if (data.type === 'complete') {
receivedComplete = true;
}
if (data.type === 'done') {
receivedDone = true;
}
onEvent(data as AIStreamEvent);
} catch (e) {
logger.error('[AI SSE] Failed to parse event', { error: e, line });
}
}
}
}
// Process any remaining buffer content
if (buffer.trim() && buffer.trim().startsWith('data: ')) {
try {
const jsonStr = buffer.slice(6);
if (jsonStr.trim()) {
const data = JSON.parse(jsonStr);
logger.debug('[AI SSE] Parsed final buffered event', { type: data.type });
onEvent(data as AIStreamEvent);
if (data.type === 'complete') receivedComplete = true;
if (data.type === 'done') receivedDone = true;
}
} catch {
logger.warn('[AI SSE] Could not parse remaining buffer', { preview: buffer.substring(0, 100) });
}
}
// If we ended without receiving a done event, send a synthetic one
// This ensures the UI properly clears the streaming state
if (!receivedDone) {
logger.warn('[AI SSE] Stream ended without done event, sending synthetic done');
onEvent({ type: 'done', data: undefined });
}
} finally {
reader.releaseLock();
logger.debug('[AI SSE] Reader released', { receivedComplete, receivedDone });
}
}
}

View file

@ -0,0 +1,151 @@
/**
* Charts API
*
* Fetches historical metrics data from the backend for sparkline visualizations.
* The backend maintains proper historical data with 30s sample intervals.
*/
import { apiFetchJSON } from '@/utils/apiClient';
// Types matching backend response format
export interface MetricPoint {
timestamp: number; // Unix timestamp in milliseconds
value: number;
}
// Extended metric point with min/max for aggregated data
export interface AggregatedMetricPoint {
timestamp: number; // Unix timestamp in milliseconds
value: number;
min: number;
max: number;
}
export interface ChartData {
cpu?: MetricPoint[];
memory?: MetricPoint[];
disk?: MetricPoint[];
diskread?: MetricPoint[];
diskwrite?: MetricPoint[];
netin?: MetricPoint[];
netout?: MetricPoint[];
}
export interface ChartStats {
oldestDataTimestamp: number;
}
export interface ChartsResponse {
data: Record<string, ChartData>; // VM/Container data keyed by ID
nodeData: Record<string, ChartData>; // Node data keyed by ID
storageData: Record<string, ChartData>; // Storage data keyed by ID
dockerData?: Record<string, ChartData>; // Docker container data keyed by container ID
dockerHostData?: Record<string, ChartData>; // Docker host data keyed by host ID
guestTypes?: Record<string, 'vm' | 'container'>; // Maps guest ID to type
timestamp: number;
stats: ChartStats;
}
// Persistent metrics history types (SQLite-backed, longer retention)
export type HistoryTimeRange = '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | '90d';
export type ResourceType = 'node' | 'guest' | 'storage' | 'docker' | 'dockerHost';
export interface MetricsHistoryParams {
resourceType: ResourceType;
resourceId: string;
metric?: string; // Optional: 'cpu', 'memory', 'disk', etc. Omit for all metrics
range?: HistoryTimeRange; // Default: '24h'
}
export interface SingleMetricHistoryResponse {
resourceType: string;
resourceId: string;
metric: string;
range: string;
start: number; // Unix timestamp in milliseconds
end: number; // Unix timestamp in milliseconds
points: AggregatedMetricPoint[];
}
export interface AllMetricsHistoryResponse {
resourceType: string;
resourceId: string;
range: string;
start: number; // Unix timestamp in milliseconds
end: number; // Unix timestamp in milliseconds
metrics: Record<string, AggregatedMetricPoint[]>;
}
export interface MetricsStoreStats {
enabled: boolean;
dbPath?: string;
dbSize?: number;
rawCount?: number;
minuteCount?: number;
hourlyCount?: number;
dailyCount?: number;
totalWrites?: number;
bufferSize?: number;
lastFlush?: string;
lastRollup?: string;
lastRetention?: string;
error?: string;
}
export type TimeRange = '5m' | '15m' | '30m' | '1h' | '4h' | '12h' | '24h' | '7d';
export class ChartsAPI {
private static baseUrl = '/api';
/**
* Fetch historical chart data for all resources
* @param range Time range to fetch (default: 1h)
*/
static async getCharts(range: TimeRange = '1h'): Promise<ChartsResponse> {
const url = `${this.baseUrl}/charts?range=${range}`;
return apiFetchJSON(url);
}
/**
* Fetch storage-specific chart data
* @param rangeMinutes Range in minutes (default: 60)
*/
static async getStorageCharts(rangeMinutes: number = 60): Promise<Record<string, {
usage?: MetricPoint[];
used?: MetricPoint[];
total?: MetricPoint[];
avail?: MetricPoint[];
}>> {
const url = `${this.baseUrl}/storage/charts?range=${rangeMinutes}`;
return apiFetchJSON(url);
}
/**
* Fetch persistent metrics history for a specific resource
* This uses the SQLite-backed store with longer retention (up to 90 days)
* @param params Query parameters
*/
static async getMetricsHistory(params: MetricsHistoryParams): Promise<SingleMetricHistoryResponse | AllMetricsHistoryResponse> {
const searchParams = new URLSearchParams({
resourceType: params.resourceType,
resourceId: params.resourceId,
});
if (params.metric) {
searchParams.set('metric', params.metric);
}
if (params.range) {
searchParams.set('range', params.range);
}
const url = `${this.baseUrl}/metrics-store/history?${searchParams.toString()}`;
return apiFetchJSON(url);
}
/**
* Fetch statistics about the persistent metrics store
*/
static async getMetricsStoreStats(): Promise<MetricsStoreStats> {
const url = `${this.baseUrl}/metrics-store/stats`;
return apiFetchJSON(url);
}
}

View file

@ -6,6 +6,7 @@ export interface DockerMetadata {
customUrl?: string;
description?: string;
tags?: string[];
notes?: string[]; // User annotations for AI context
}
export class DockerMetadataAPI {

View file

@ -6,6 +6,7 @@ export interface GuestMetadata {
customUrl?: string;
description?: string;
tags?: string[];
notes?: string[]; // User annotations for AI context
}
export class GuestMetadataAPI {

View file

@ -0,0 +1,42 @@
// Host Metadata API
import { apiFetchJSON } from '@/utils/apiClient';
export interface HostMetadata {
id: string;
customUrl?: string;
description?: string;
tags?: string[];
notes?: string[]; // User annotations for AI context
}
export class HostMetadataAPI {
private static baseUrl = '/api/hosts/metadata';
// Get metadata for a specific host
static async getMetadata(hostId: string): Promise<HostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`);
}
// Get all host metadata
static async getAllMetadata(): Promise<Record<string, HostMetadata>> {
return apiFetchJSON(this.baseUrl);
}
// Update metadata for a host
static async updateMetadata(
hostId: string,
metadata: Partial<HostMetadata>,
): Promise<HostMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
// Delete metadata for a host
static async deleteMetadata(hostId: string): Promise<void> {
await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, {
method: 'DELETE',
});
}
}

View file

@ -216,6 +216,191 @@ export class MonitoringAPI {
}
}
static async deleteKubernetesCluster(clusterId: string): Promise<DeleteKubernetesClusterResponse> {
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}`;
const response = await apiFetch(url, {
method: 'DELETE',
});
if (!response.ok) {
if (response.status === 404) {
return {};
}
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_jsonErr) {
// ignore parse errors
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
if (response.status === 204) {
return {};
}
const text = await response.text();
if (!text?.trim()) {
return {};
}
try {
return JSON.parse(text) as DeleteKubernetesClusterResponse;
} catch (err) {
throw new Error((err as Error).message || 'Failed to parse delete kubernetes cluster response');
}
}
static async unhideKubernetesCluster(clusterId: string): Promise<void> {
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/unhide`;
const response = await apiFetch(url, {
method: 'PUT',
});
if (!response.ok) {
if (response.status === 404) {
return;
}
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_jsonErr) {
// ignore parse errors
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
}
static async markKubernetesClusterPendingUninstall(clusterId: string): Promise<void> {
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/pending-uninstall`;
const response = await apiFetch(url, {
method: 'PUT',
});
if (!response.ok) {
if (response.status === 404) {
return;
}
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_jsonErr) {
// ignore parse errors
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
}
static async setKubernetesClusterDisplayName(clusterId: string, displayName: string): Promise<void> {
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/display-name`;
const response = await apiFetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ displayName }),
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kubernetes cluster not found');
}
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_jsonErr) {
// ignore parse errors
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
}
static async allowKubernetesClusterReenroll(clusterId: string): Promise<void> {
const url = `${this.baseUrl}/agents/kubernetes/clusters/${encodeURIComponent(clusterId)}/allow-reenroll`;
const response = await apiFetch(url, {
method: 'POST',
});
if (!response.ok) {
let message = `Failed with status ${response.status}`;
try {
const text = await response.text();
if (text?.trim()) {
message = text.trim();
try {
const parsed = JSON.parse(text);
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch (_err) {
// ignore parse error
}
}
} catch (_err) {
// ignore read error
}
throw new Error(message);
}
}
static async deleteHostAgent(hostId: string): Promise<void> {
if (!hostId) {
throw new Error('Host ID is required to remove a host agent.');
@ -313,3 +498,9 @@ export interface DeleteDockerHostResponse {
message?: string;
command?: DockerHostCommand;
}
export interface DeleteKubernetesClusterResponse {
success?: boolean;
clusterId?: string;
message?: string;
}

View file

@ -0,0 +1,379 @@
/**
* AI Patrol API client
* Provides access to background AI monitoring findings and status
*/
import { apiFetchJSON } from '@/utils/apiClient';
export type FindingSeverity = 'info' | 'watch' | 'warning' | 'critical';
export type FindingCategory = 'performance' | 'capacity' | 'reliability' | 'backup' | 'security' | 'general';
export interface Finding {
id: string;
severity: FindingSeverity;
category: FindingCategory;
resource_id: string;
resource_name: string;
resource_type: string; // node, vm, container, docker_host, docker_container, storage, pbs, host_raid
node?: string;
title: string;
description: string;
recommendation?: string;
evidence?: string;
detected_at: string;
last_seen_at: string;
resolved_at?: string;
auto_resolved: boolean;
acknowledged_at?: string;
snoozed_until?: string; // Finding hidden until this time
alert_id?: string;
// User feedback fields (LLM memory system)
dismissed_reason?: 'not_an_issue' | 'expected_behavior' | 'will_fix_later';
user_note?: string;
times_raised: number;
suppressed: boolean;
}
export interface FindingsSummary {
critical: number;
warning: number;
watch: number;
info: number;
}
export interface PatrolStatus {
running: boolean;
enabled: boolean;
last_patrol_at?: string;
last_deep_analysis_at?: string;
next_patrol_at?: string;
last_duration_ms: number;
resources_checked: number;
findings_count: number;
error_count: number;
healthy: boolean;
interval_ms: number; // Patrol interval in milliseconds
summary: FindingsSummary;
}
export interface PatrolRunRecord {
id: string;
started_at: string;
completed_at: string;
duration_ms: number;
type: string; // Always 'patrol' now (kept for backwards compat with old records)
resources_checked: number;
// Breakdown by resource type
nodes_checked: number;
guests_checked: number;
docker_checked: number;
storage_checked: number;
hosts_checked: number;
pbs_checked: number;
// Findings from this run
new_findings: number;
existing_findings: number;
resolved_findings: number;
findings_summary: string;
finding_ids: string[];
error_count: number;
status: 'healthy' | 'issues_found' | 'critical' | 'error';
// AI Analysis details
ai_analysis?: string; // The AI's raw response/analysis
input_tokens?: number; // Tokens sent to AI
output_tokens?: number; // Tokens received from AI
}
/**
* Get the current AI patrol status
*/
export async function getPatrolStatus(): Promise<PatrolStatus> {
const resp = await fetch('/api/ai/patrol/status', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get patrol status: ${resp.status}`);
}
return resp.json();
}
/**
* Get all active findings from the patrol service
* Optionally filter by resource ID
*/
export async function getFindings(resourceId?: string): Promise<Finding[]> {
const url = resourceId
? `/api/ai/patrol/findings?resource_id=${encodeURIComponent(resourceId)}`
: '/api/ai/patrol/findings';
const resp = await fetch(url, {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get findings: ${resp.status}`);
}
return resp.json();
}
/**
* Trigger an immediate patrol run
*/
export async function forcePatrol(deep: boolean = false): Promise<{ success: boolean; message: string }> {
const url = deep ? '/api/ai/patrol/run?deep=true' : '/api/ai/patrol/run';
return apiFetchJSON(url, { method: 'POST' });
}
/**
* Get AI findings history including resolved findings
* @param startTime Optional ISO timestamp to filter findings from
*/
export async function getFindingsHistory(startTime?: string): Promise<Finding[]> {
const url = startTime
? `/api/ai/patrol/history?start_time=${encodeURIComponent(startTime)}`
: '/api/ai/patrol/history';
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get findings history: ${resp.status}`);
}
return resp.json();
}
/**
* Get the history of patrol check runs
* @param limit Maximum number of records to return (default: 50, max: 100)
*/
export async function getPatrolRunHistory(limit?: number): Promise<PatrolRunRecord[]> {
const url = limit
? `/api/ai/patrol/runs?limit=${limit}`
: '/api/ai/patrol/runs';
const resp = await fetch(url, {
method: 'GET',
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get patrol run history: ${resp.status}`);
}
return resp.json();
}
/**
* Acknowledge a finding (marks as seen but keeps visible, like alert acknowledgement)
* Finding will auto-resolve when the underlying condition clears.
*/
export async function acknowledgeFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/acknowledge', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Snooze a finding for a specified duration
* @param findingId The ID of the finding to snooze
* @param durationHours Duration in hours (e.g., 1, 24, 168 for 7 days)
*/
export async function snoozeFinding(findingId: string, durationHours: number): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/snooze', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId, duration_hours: durationHours }),
});
}
/**
* Manually resolve a finding (mark as fixed)
* @param findingId The ID of the finding to resolve
*/
export async function resolveFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/resolve', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Dismiss a finding with a reason (LLM memory feature)
* The LLM will be told not to re-raise this issue in future patrols.
* @param findingId The ID of the finding to dismiss
* @param reason One of: "not_an_issue", "expected_behavior", "will_fix_later"
* @param note Optional freeform explanation
*/
export async function dismissFinding(
findingId: string,
reason: 'not_an_issue' | 'expected_behavior' | 'will_fix_later',
note?: string
): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/dismiss', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId, reason, note }),
});
}
/**
* Permanently suppress a finding type (LLM memory feature)
* The LLM will never re-raise this type of finding for this resource.
* @param findingId The ID of the finding to suppress
*/
export async function suppressFinding(findingId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON('/api/ai/patrol/suppress', {
method: 'POST',
body: JSON.stringify({ finding_id: findingId }),
});
}
/**
* Severity color mapping for UI
*/
export const severityColors: Record<FindingSeverity, { bg: string; text: string; border: string }> = {
critical: { bg: 'rgba(220, 38, 38, 0.15)', text: '#ef4444', border: 'rgba(220, 38, 38, 0.3)' },
warning: { bg: 'rgba(234, 179, 8, 0.15)', text: '#eab308', border: 'rgba(234, 179, 8, 0.3)' },
watch: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
info: { bg: 'rgba(107, 114, 128, 0.15)', text: '#9ca3af', border: 'rgba(107, 114, 128, 0.3)' },
};
/**
* Category labels for UI
*/
export const categoryLabels: Record<FindingCategory, string> = {
performance: 'Performance',
capacity: 'Capacity',
reliability: 'Reliability',
backup: 'Backup',
security: 'Security',
general: 'General',
};
/**
* Format a timestamp for display
*/
export function formatTimestamp(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Event types from patrol stream
*/
export interface PatrolStreamEvent {
type: 'start' | 'content' | 'thinking' | 'phase' | 'complete' | 'error';
content?: string;
phase?: string;
tokens?: number;
}
/**
* Subscribe to live patrol stream via SSE
* Returns an unsubscribe function
*/
export function subscribeToPatrolStream(
onEvent: (event: PatrolStreamEvent) => void,
onError?: (error: Error) => void
): () => void {
const eventSource = new EventSource('/api/ai/patrol/stream', { withCredentials: true });
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PatrolStreamEvent;
onEvent(data);
} catch (e) {
console.error('Failed to parse patrol stream event:', e);
}
};
eventSource.onerror = () => {
if (onError) {
onError(new Error('Patrol stream connection error'));
}
eventSource.close();
};
// Return unsubscribe function
return () => {
eventSource.close();
};
}
// === Suppression Rules ===
export interface SuppressionRule {
id: string;
resource_id?: string; // Empty means "any resource"
resource_name?: string; // Human-readable name
category?: FindingCategory; // Empty means "any category"
description: string; // User's reason
created_at: string;
created_from: 'finding' | 'manual';
finding_id?: string; // Original finding ID if created from dismissal
}
/**
* Get all suppression rules (both manual and from dismissed findings)
*/
export async function getSuppressionRules(): Promise<SuppressionRule[]> {
const resp = await fetch('/api/ai/patrol/suppressions', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get suppression rules: ${resp.status}`);
}
return resp.json();
}
/**
* Create a new manual suppression rule
* @param resourceId Resource ID (empty for "any resource")
* @param resourceName Human-readable name for display
* @param category Category (empty for "any category")
* @param description User's reason for the rule
*/
export async function addSuppressionRule(
resourceId: string,
resourceName: string,
category: FindingCategory | '',
description: string
): Promise<{ success: boolean; message: string; rule: SuppressionRule }> {
return apiFetchJSON('/api/ai/patrol/suppressions', {
method: 'POST',
body: JSON.stringify({
resource_id: resourceId,
resource_name: resourceName,
category: category,
description: description,
}),
});
}
/**
* Delete a suppression rule
* @param ruleId The ID of the rule to delete
*/
export async function deleteSuppressionRule(ruleId: string): Promise<{ success: boolean; message: string }> {
return apiFetchJSON(`/api/ai/patrol/suppressions/${encodeURIComponent(ruleId)}`, {
method: 'DELETE',
});
}
/**
* Get all dismissed/suppressed findings
*/
export async function getDismissedFindings(): Promise<Finding[]> {
const resp = await fetch('/api/ai/patrol/dismissed', {
credentials: 'include',
});
if (!resp.ok) {
throw new Error(`Failed to get dismissed findings: ${resp.status}`);
}
return resp.json();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,605 @@
import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { AIAPI } from '@/api/ai';
import { formatNumber } from '@/utils/format';
import { logger } from '@/utils/logger';
import { notificationStore } from '@/stores/notifications';
import type { AICostSummary, AISettings } from '@/types/ai';
import { PROVIDER_NAMES } from '@/types/ai';
const usdFormatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const TinySparkline: Component<{
values: number[];
width?: number;
height?: number;
stroke?: string;
}> = (props) => {
const width = () => props.width ?? 160;
const height = () => props.height ?? 28;
const stroke = () => props.stroke ?? '#22c55e';
const pathD = createMemo(() => {
const values = props.values;
const w = width();
const h = height();
if (!values || values.length === 0) return '';
const max = Math.max(...values, 0);
const safeMax = max <= 0 ? 1 : max;
// For single point, draw a horizontal line across the middle
if (values.length === 1) {
const y = h - (Math.max(0, values[0]) / safeMax) * h;
// Draw a short horizontal line to make the single point visible
return `M0,${y.toFixed(2)} L${w.toFixed(2)},${y.toFixed(2)}`;
}
const xStep = w / (values.length - 1);
let d = '';
values.forEach((v, idx) => {
const x = idx * xStep;
const y = h - (Math.max(0, v) / safeMax) * h;
d += `${idx === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)} `;
});
return d.trim();
});
return (
<svg width={width()} height={height()} viewBox={`0 0 ${width()} ${height()}`} class="block">
<path d={pathD()} fill="none" stroke={stroke()} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
};
export const AICostDashboard: Component = () => {
const [days, setDays] = createSignal(30);
const [loading, setLoading] = createSignal(false);
const [loadError, setLoadError] = createSignal<string | null>(null);
const [summary, setSummary] = createSignal<AICostSummary | null>(null);
const [aiSettings, setAISettings] = createSignal<AISettings | null>(null);
let requestSeq = 0;
const anyPricingKnown = createMemo(() => {
const data = summary();
if (!data) return false;
return data.provider_models.some((pm) => pm.pricing_known);
});
const unpricedProviderModels = createMemo(() => {
const data = summary();
if (!data) return [];
return (data.provider_models ?? []).filter((pm) => !pm.pricing_known && (pm.total_tokens ?? 0) > 0);
});
const estimatedTotalUSD = createMemo(() => {
const data = summary();
if (!data || !anyPricingKnown()) return null;
return data.totals.estimated_usd ?? 0;
});
const useCaseMap = createMemo(() => {
const data = summary();
const map = new Map<string, { tokens: number; usd: number; pricingKnown: boolean }>();
if (!data) return map;
for (const uc of data.use_cases ?? []) {
map.set(uc.use_case, {
tokens: uc.total_tokens,
usd: uc.estimated_usd ?? 0,
pricingKnown: uc.pricing_known,
});
}
return map;
});
const dailyTotals = createMemo(() => summary()?.daily_totals ?? []);
const dailyTokenValues = createMemo(() => dailyTotals().map((d) => d.total_tokens));
const dailyUSDValues = createMemo(() => dailyTotals().map((d) => d.estimated_usd ?? 0));
const lastDailyTokens = createMemo(() => {
const values = dailyTokenValues();
if (values.length === 0) return null;
return values[values.length - 1];
});
const lastDailyUSD = createMemo(() => {
const values = dailyUSDValues();
if (values.length === 0) return null;
return values[values.length - 1];
});
const formatUSD = (usd: number) => usdFormatter.format(usd);
const loadSummary = async (rangeDays: number) => {
const seq = ++requestSeq;
const isInitialLoad = summary() === null;
setLoading(true);
setLoadError(null);
try {
const data = await AIAPI.getCostSummary(rangeDays);
if (seq !== requestSeq) return;
setSummary(data);
} catch (err) {
if (seq !== requestSeq) return;
logger.error('[AICostDashboard] Failed to load cost summary:', err);
// Only show notification on refresh failures, not initial load
if (!isInitialLoad) {
notificationStore.error('Failed to refresh AI cost summary');
}
const message =
err instanceof Error && err.message ? err.message : 'Failed to load usage data';
setLoadError(message);
} finally {
if (seq === requestSeq) setLoading(false);
}
};
onMount(() => {
loadSummary(days());
});
const loadBudgetSettings = async () => {
try {
const s = await AIAPI.getSettings();
setAISettings(s);
} catch (err) {
logger.debug('[AICostDashboard] Failed to load AI settings for budget:', err);
setAISettings(null);
}
};
onMount(() => {
loadBudgetSettings();
});
const parsedBudgetUSD30d = createMemo(() => {
const s = aiSettings();
const n = s?.cost_budget_usd_30d;
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null;
return n;
});
const budgetForRange = createMemo(() => {
const budget30d = parsedBudgetUSD30d();
if (budget30d == null) return null;
const rangeDays = days();
return (budget30d * rangeDays) / 30;
});
const isOverBudget = createMemo(() => {
const budget = budgetForRange();
const usd = estimatedTotalUSD();
if (budget == null || usd == null) return false;
return usd > budget;
});
const resetHistory = async () => {
if (!confirm('Reset AI usage history? A backup will be created in the Pulse config directory.')) return;
try {
const result = await AIAPI.resetCostHistory();
if (result.backup_file) {
notificationStore.success(`AI usage history reset (backup: ${result.backup_file})`);
} else {
notificationStore.success('AI usage history reset');
}
await loadSummary(days());
} catch (err) {
logger.error('[AICostDashboard] Failed to reset AI cost history:', err);
notificationStore.error('Failed to reset AI usage history');
}
};
const downloadExport = async (format: 'csv' | 'json') => {
try {
const resp = await AIAPI.exportCostHistory(days(), format);
if (!resp.ok) {
throw new Error(`Export failed (${resp.status})`);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-ai-usage-${new Date().toISOString().split('T')[0]}-${days()}d.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
logger.error('[AICostDashboard] Failed to export cost history:', err);
notificationStore.error('Failed to export AI usage history');
}
};
const handleRangeClick = (rangeDays: number) => {
if (loading() || rangeDays === days()) return;
setDays(rangeDays);
loadSummary(rangeDays);
};
return (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<div class="bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-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-emerald-100 dark:bg-emerald-900/40 rounded-lg">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 1.343-3 3v1a3 3 0 006 0v-1c0-1.657-1.343-3-3-3zM5 12a7 7 0 0114 0v3a2 2 0 01-2 2H7a2 2 0 01-2-2v-3z" />
</svg>
</div>
<SectionHeader
title="AI Cost & Usage"
description="Token usage and estimated spend across providers"
size="sm"
class="flex-1"
/>
<Show when={loading()}>
<div class="text-xs text-gray-500 dark:text-gray-400">Loading</div>
</Show>
<div class="flex items-center gap-1">
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(1)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 1
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
1d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(7)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 7
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
7d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(30)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 30
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
30d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(90)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 90
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
90d
</button>
<button
type="button"
disabled={loading()}
onClick={() => handleRangeClick(365)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${days() === 365
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
1y
</button>
</div>
</div>
</div>
<div class="p-6 space-y-4">
<Show when={!summary() && loading()}>
<div class="text-sm text-gray-500 dark:text-gray-400">Loading usage</div>
</Show>
<Show when={summary()?.truncated}>
<div class="text-xs px-3 py-2 rounded border border-blue-200 dark:border-blue-800/60 bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-100">
Showing the last {summary()?.effective_days} days due to a {summary()?.retention_days}-day retention window.
</div>
</Show>
<Show when={isOverBudget()}>
<div class="text-xs px-3 py-2 rounded border border-red-200 dark:border-red-800/60 bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100">
Estimated spend ({formatUSD(estimatedTotalUSD() ?? 0)}) is above your budget ({formatUSD(budgetForRange() ?? 0)}).
</div>
</Show>
<Show when={loadError() && summary()}>
<div class="flex items-center justify-between gap-3 text-xs px-3 py-2 rounded border border-amber-200 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-100">
<div class="truncate">
Couldnt refresh. Showing last loaded data. {loadError()}
</div>
<button
type="button"
disabled={loading()}
onClick={() => loadSummary(days())}
class={`shrink-0 px-2 py-1 rounded border border-amber-300 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Retry
</button>
</div>
</Show>
<Show when={!summary() && !loading() && loadError()}>
<div class="text-sm text-gray-500 dark:text-gray-400">{loadError()}</div>
</Show>
<Show when={!summary() && !loading() && !loadError()}>
<div class="text-sm text-gray-500 dark:text-gray-400">No usage data yet.</div>
</Show>
<Show when={summary()}>
{(data) => (
<>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Estimated spend</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
<Show
when={estimatedTotalUSD() != null}
fallback={<span class="text-gray-500 dark:text-gray-400"></span>}
>
{formatUSD(estimatedTotalUSD() ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Total tokens</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{formatNumber(data().totals.total_tokens)}
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Model/provider pairs</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{formatNumber(data().provider_models.length)}
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Chat</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{formatNumber(useCaseMap().get('chat')?.tokens ?? 0)} tokens
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show
when={useCaseMap().get('chat')?.pricingKnown}
fallback={<span></span>}
>
{formatUSD(useCaseMap().get('chat')?.usd ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Patrol</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{formatNumber(useCaseMap().get('patrol')?.tokens ?? 0)} tokens
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show
when={useCaseMap().get('patrol')?.pricingKnown}
fallback={<span></span>}
>
{formatUSD(useCaseMap().get('patrol')?.usd ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">Budget alert (USD per 30d)</div>
<div class="text-sm font-semibold text-gray-900 dark:text-white mt-1">
<Show when={parsedBudgetUSD30d() != null} fallback={<span class="text-gray-500 dark:text-gray-400"></span>}>
{formatUSD(parsedBudgetUSD30d() ?? 0)}
</Show>
</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">
Set in AI settings. Pro-rated for {days()}d:{' '}
<Show when={budgetForRange() != null} fallback={<span></span>}>
{formatUSD(budgetForRange() ?? 0)}
</Show>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">Daily estimated USD</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show when={anyPricingKnown() && lastDailyUSD() != null} fallback={<span></span>}>
{formatUSD(lastDailyUSD() ?? 0)}
</Show>
</div>
</div>
<div class="mt-2">
<Show
when={anyPricingKnown() && dailyUSDValues().length >= 2}
fallback={<div class="text-xs text-gray-500 dark:text-gray-400">No daily USD trend yet.</div>}
>
<TinySparkline values={dailyUSDValues()} stroke="#10b981" />
</Show>
</div>
</div>
<div class="p-3 rounded-lg bg-gray-50 dark:bg-gray-800/40 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-xs text-gray-500 dark:text-gray-400">Daily total tokens</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<Show when={lastDailyTokens() != null} fallback={<span></span>}>
{formatNumber(lastDailyTokens() ?? 0)}
</Show>
</div>
</div>
<div class="mt-2">
<Show
when={dailyTokenValues().length >= 2}
fallback={<div class="text-xs text-gray-500 dark:text-gray-400">No daily token trend yet.</div>}
>
<TinySparkline values={dailyTokenValues()} stroke="#3b82f6" />
</Show>
</div>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
USD is an estimate based on public list prices. It may differ from billing.
<Show when={unpricedProviderModels().length > 0}>
<span class="ml-2">
Estimated spend is partial. Pricing is unknown for{' '}
{unpricedProviderModels()
.slice(0, 6)
.map(
(pm) =>
`${PROVIDER_NAMES[pm.provider as keyof typeof PROVIDER_NAMES] || pm.provider}/${pm.model}`,
)
.join(', ')}
<Show when={unpricedProviderModels().length > 6}>
<span> (+{unpricedProviderModels().length - 6} more)</span>
</Show>
.
</span>
</Show>
<Show when={summary()?.pricing_as_of}>
<span class="ml-2">Prices as of {summary()?.pricing_as_of}.</span>
</Show>
</div>
<div class="flex items-center justify-between gap-3">
<div class="text-xs text-gray-500 dark:text-gray-400">
History retention: {data().retention_days} days
</div>
<div class="flex items-center gap-2">
<button
type="button"
disabled={loading()}
onClick={() => downloadExport('csv')}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Export CSV
</button>
<button
type="button"
disabled={loading()}
onClick={() => downloadExport('json')}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Export JSON
</button>
<button
type="button"
disabled={loading()}
onClick={resetHistory}
class={`text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${loading() ? 'opacity-60 cursor-not-allowed' : ''}`}
>
Reset history
</button>
</div>
</div>
<Show when={(data().targets?.length ?? 0) > 0}>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 pr-4">Top targets</th>
<th class="text-right py-2 px-2">Est. USD</th>
<th class="text-right py-2 px-2">Calls</th>
<th class="text-right py-2 px-2">Tokens</th>
</tr>
</thead>
<tbody>
<For each={data().targets}>
{(t) => (
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 font-mono text-xs">
{t.target_type}:{t.target_id}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
<Show
when={t.pricing_known}
fallback={<span class="text-gray-500 dark:text-gray-500"></span>}
>
{formatUSD(t.estimated_usd ?? 0)}
</Show>
</td>
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
{formatNumber(t.calls)}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
{formatNumber(t.total_tokens)}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left py-2 pr-4">Provider</th>
<th class="text-left py-2 pr-4">Model</th>
<th class="text-right py-2 px-2">Est. USD</th>
<th class="text-right py-2 px-2">Input</th>
<th class="text-right py-2 px-2">Output</th>
<th class="text-right py-2 px-2">Total</th>
</tr>
</thead>
<tbody>
<For each={data().provider_models}>
{(pm) => (
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{PROVIDER_NAMES[pm.provider as keyof typeof PROVIDER_NAMES] || pm.provider}
</td>
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 font-mono text-xs">
{pm.model}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
<Show
when={pm.pricing_known}
fallback={<span class="text-gray-500 dark:text-gray-500"></span>}
>
{formatUSD(pm.estimated_usd ?? 0)}
</Show>
</td>
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
{formatNumber(pm.input_tokens)}
</td>
<td class="py-2 px-2 text-right text-gray-700 dark:text-gray-300">
{formatNumber(pm.output_tokens)}
</td>
<td class="py-2 px-2 text-right text-gray-900 dark:text-gray-100">
{formatNumber(pm.total_tokens)}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</>
)}
</Show>
</div>
</Card>
);
};
export default AICostDashboard;

View file

@ -0,0 +1,74 @@
/**
* AIStatusIndicator styles
* Designed to be minimal and blend with Pulse's header
*/
.ai-status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s ease;
color: var(--text-secondary, #9ca3af);
}
.ai-status-indicator:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Healthy state - very subtle */
.ai-status--healthy {
color: var(--success-color, #22c55e);
opacity: 0.7;
}
.ai-status--healthy:hover {
opacity: 1;
}
/* Watch state - noticeable but not alarming */
.ai-status--watch {
color: var(--info-color, #3b82f6);
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.2);
}
.ai-status--watch:hover {
background: rgba(59, 130, 246, 0.15);
}
/* Issues state - draws attention */
.ai-status--issues {
color: var(--warning-color, #eab308);
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.25);
}
.ai-status--issues:hover {
background: rgba(234, 179, 8, 0.15);
}
/* Icon */
.ai-status-icon {
display: flex;
align-items: center;
justify-content: center;
}
.ai-status-icon svg {
display: block;
}
/* Count badge */
.ai-status-count {
font-size: 11px;
font-weight: 600;
min-width: 16px;
text-align: center;
}

View file

@ -0,0 +1,113 @@
/**
* AIStatusIndicator - Subtle header component showing AI patrol health
*
* Design: Minimal presence when healthy, highlighted when issues detected.
* Clicking navigates to the Alerts page where AI Insights are displayed.
*/
import { createResource, Show, createMemo, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { getPatrolStatus, type PatrolStatus } from '../../api/patrol';
import './AIStatusIndicator.css';
export function AIStatusIndicator() {
const navigate = useNavigate();
// Poll patrol status every 30 seconds
const [status, { refetch }] = createResource<PatrolStatus>(
async () => {
try {
return await getPatrolStatus();
} catch {
return null as unknown as PatrolStatus;
}
},
{ initialValue: undefined }
);
// Refetch every 30 seconds with proper cleanup
const intervalId = setInterval(() => refetch(), 30000);
onCleanup(() => clearInterval(intervalId));
const hasIssues = createMemo(() => {
const s = status();
if (!s) return false;
return s.summary.critical > 0 || s.summary.warning > 0;
});
const hasWatch = createMemo(() => {
const s = status();
if (!s) return false;
return s.summary.watch > 0 && !hasIssues();
});
const totalFindings = createMemo(() => {
const s = status();
if (!s) return 0;
return s.summary.critical + s.summary.warning + s.summary.watch;
});
const tooltipText = createMemo(() => {
const s = status();
if (!s || !s.enabled) return 'AI Patrol disabled';
if (!s.running) return 'AI Patrol not running';
const parts: string[] = [];
if (s.summary.critical > 0) parts.push(`${s.summary.critical} critical`);
if (s.summary.warning > 0) parts.push(`${s.summary.warning} warning`);
if (s.summary.watch > 0) parts.push(`${s.summary.watch} watch`);
if (parts.length === 0) return 'AI: All systems healthy';
return `AI: ${parts.join(', ')}`;
});
const statusClass = createMemo(() => {
if (hasIssues()) return 'ai-status--issues';
if (hasWatch()) return 'ai-status--watch';
return 'ai-status--healthy';
});
const handleClick = () => {
// Navigate to Alerts page with AI Insights subtab selected
navigate('/alerts?subtab=ai-insights');
};
return (
<Show when={status()?.enabled}>
<button
class={`ai-status-indicator ${statusClass()}`}
onClick={handleClick}
title={tooltipText()}
>
<span class="ai-status-icon">
<Show when={hasIssues()} fallback={
<Show when={hasWatch()} fallback={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M9 12l2 2 4-4" />
</svg>
}>
{/* Watch icon - eye */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Show>
}>
{/* Issues icon - alert */}
<svg width="14" height="14" 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" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</Show>
</span>
<Show when={totalFindings() > 0}>
<span class="ai-status-count">{totalFindings()}</span>
</Show>
</button>
</Show>
);
}
export default AIStatusIndicator;

View file

@ -0,0 +1,818 @@
import { Component, createSignal, createEffect, For, Show, createMemo } from 'solid-js';
import { notificationStore } from '@/stores/notifications';
import { apiFetch } from '@/utils/apiClient';
interface Note {
id: string;
category: string;
title: string;
content: string;
created_at: string;
updated_at: string;
}
interface GuestKnowledge {
guest_id: string;
guest_name: string;
guest_type: string;
notes: Note[];
updated_at: string;
}
interface GuestNotesProps {
guestId: string;
guestName?: string;
guestType?: string;
customUrl?: string;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
}
const CATEGORY_LABELS: Record<string, string> = {
service: 'Service',
path: 'Path',
config: 'Config',
credential: 'Credential',
learning: 'Learning',
};
const CATEGORY_ICONS: Record<string, string> = {
service: '⚙️',
path: '📁',
config: '📋',
credential: '🔐',
learning: '💡',
};
const CATEGORY_COLORS: Record<string, string> = {
service: 'bg-blue-500/20 border-blue-500/30 text-blue-300',
path: 'bg-amber-500/20 border-amber-500/30 text-amber-300',
config: 'bg-purple-500/20 border-purple-500/30 text-purple-300',
credential: 'bg-red-500/20 border-red-500/30 text-red-300',
learning: 'bg-green-500/20 border-green-500/30 text-green-300',
};
const CATEGORY_OPTIONS = ['service', 'path', 'config', 'credential', 'learning'];
// Quick templates for common note types
const TEMPLATES = [
{ category: 'credential', title: 'Admin Password', placeholder: 'Enter admin password...' },
{ category: 'credential', title: 'SSH Key', placeholder: 'Paste SSH private key or fingerprint...' },
{ category: 'credential', title: 'API Key', placeholder: 'Enter API key...' },
{ category: 'path', title: 'Config Directory', placeholder: '/path/to/config' },
{ category: 'path', title: 'Data Directory', placeholder: '/path/to/data' },
{ category: 'path', title: 'Log Location', placeholder: '/var/log/service.log' },
{ category: 'service', title: 'Web Interface', placeholder: 'http://localhost:8080' },
{ category: 'service', title: 'Database', placeholder: 'PostgreSQL on port 5432' },
{ category: 'config', title: 'Port Number', placeholder: '8080' },
{ category: 'config', title: 'Environment', placeholder: 'production' },
];
// Format relative time
const formatRelativeTime = (dateStr: string): string => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
export const GuestNotes: Component<GuestNotesProps> = (props) => {
const [knowledge, setKnowledge] = createSignal<GuestKnowledge | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [isExpanded, setIsExpanded] = createSignal(false);
const [showAddForm, setShowAddForm] = createSignal(false);
const [showTemplates, setShowTemplates] = createSignal(false);
const [showActions, setShowActions] = createSignal(false);
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
const [searchQuery, setSearchQuery] = createSignal('');
const [filterCategory, setFilterCategory] = createSignal<string>('');
const [showCredentials, setShowCredentials] = createSignal<Set<string>>(new Set());
const [deleteConfirmId, setDeleteConfirmId] = createSignal<string | null>(null);
const [clearConfirm, setClearConfirm] = createSignal(false);
const [isImporting, setIsImporting] = createSignal(false);
// Form state
const [category, setCategory] = createSignal('learning');
const [title, setTitle] = createSignal('');
const [content, setContent] = createSignal('');
// Guest URL state
const [guestUrl, setGuestUrl] = createSignal(props.customUrl || '');
const [isEditingUrl, setIsEditingUrl] = createSignal(false);
const [isSavingUrl, setIsSavingUrl] = createSignal(false);
// Sync URL from props
createEffect(() => {
setGuestUrl(props.customUrl || '');
});
// File input ref for import
let fileInputRef: HTMLInputElement | undefined;
// Fetch knowledge when guestId changes
createEffect(() => {
const guestId = props.guestId;
if (guestId) {
loadKnowledge(guestId);
}
});
const loadKnowledge = async (guestId: string) => {
setIsLoading(true);
try {
// Fetch knowledge and metadata in parallel
const [knowledgeResponse, metadataResponse] = await Promise.all([
apiFetch(`/api/ai/knowledge?guest_id=${encodeURIComponent(guestId)}`),
apiFetch(`/api/guests/metadata/${encodeURIComponent(guestId)}`),
]);
if (knowledgeResponse.ok) {
const data = await knowledgeResponse.json();
setKnowledge(data);
}
// Load customUrl from metadata if not provided via props
if (metadataResponse.ok) {
const metadata = await metadataResponse.json();
if (metadata.customUrl && !props.customUrl) {
setGuestUrl(metadata.customUrl);
}
}
} catch (error) {
console.error('Failed to load guest knowledge:', error);
} finally {
setIsLoading(false);
}
};
const saveGuestUrl = async () => {
const url = guestUrl().trim();
setIsSavingUrl(true);
try {
const response = await apiFetch(`/api/guests/metadata/${encodeURIComponent(props.guestId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customUrl: url }),
});
if (response.ok) {
notificationStore.success(url ? 'Guest URL saved' : 'Guest URL cleared');
setIsEditingUrl(false);
props.onCustomUrlUpdate?.(props.guestId, url);
} else {
notificationStore.error('Failed to save guest URL');
}
} catch (error) {
console.error('Failed to save guest URL:', error);
notificationStore.error('Failed to save guest URL');
} finally {
setIsSavingUrl(false);
}
};
const saveNote = async () => {
if (!title().trim() || !content().trim()) return;
try {
const response = await apiFetch('/api/ai/knowledge/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
guest_name: props.guestName || props.guestId,
guest_type: props.guestType || 'unknown',
category: category(),
title: title().trim(),
content: content().trim(),
}),
});
if (response.ok) {
notificationStore.success('Note saved');
// Reset form
setTitle('');
setContent('');
setShowAddForm(false);
setShowTemplates(false);
setEditingNote(null);
// Reload knowledge
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to save note');
}
} catch (error) {
console.error('Failed to save note:', error);
notificationStore.error('Failed to save note');
}
};
const deleteNote = async (noteId: string) => {
try {
const response = await apiFetch('/api/ai/knowledge/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
note_id: noteId,
}),
});
if (response.ok) {
notificationStore.success('Note deleted');
setDeleteConfirmId(null);
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to delete note');
}
} catch (error) {
console.error('Failed to delete note:', error);
notificationStore.error('Failed to delete note');
}
};
const exportNotes = async () => {
try {
const response = await apiFetch(`/api/ai/knowledge/export?guest_id=${encodeURIComponent(props.guestId)}`);
if (response.ok) {
const data = await response.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-notes-${props.guestName || props.guestId}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
notificationStore.success('Notes exported');
setShowActions(false);
} else {
notificationStore.error('Failed to export notes');
}
} catch (error) {
console.error('Failed to export notes:', error);
notificationStore.error('Failed to export notes');
}
};
const handleImportFile = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
setIsImporting(true);
try {
const text = await file.text();
const data = JSON.parse(text);
// Add merge flag and ensure guest_id matches current
const importData = {
...data,
guest_id: props.guestId,
guest_name: props.guestName || data.guest_name,
guest_type: props.guestType || data.guest_type,
merge: true, // Merge with existing notes
};
const response = await apiFetch('/api/ai/knowledge/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importData),
});
if (response.ok) {
const result = await response.json();
notificationStore.success(`Imported ${result.imported} of ${result.total} notes`);
loadKnowledge(props.guestId);
setShowActions(false);
} else {
const errorText = await response.text();
notificationStore.error('Import failed: ' + errorText);
}
} catch (error) {
console.error('Failed to import notes:', error);
notificationStore.error('Failed to parse import file');
} finally {
setIsImporting(false);
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
};
const clearAllNotes = async () => {
try {
const response = await apiFetch('/api/ai/knowledge/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
confirm: true,
}),
});
if (response.ok) {
const result = await response.json();
notificationStore.success(`Cleared ${result.deleted} notes`);
setClearConfirm(false);
setShowActions(false);
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to clear notes');
}
} catch (error) {
console.error('Failed to clear notes:', error);
notificationStore.error('Failed to clear notes');
}
};
const startEdit = (note: Note) => {
setEditingNote(note);
setCategory(note.category);
setTitle(note.title);
setContent(note.content);
setShowAddForm(true);
setShowTemplates(false);
};
const useTemplate = (template: { category: string; title: string; placeholder: string }) => {
setCategory(template.category);
setTitle(template.title);
setContent('');
setShowTemplates(false);
setShowAddForm(true);
};
const cancelEdit = () => {
setEditingNote(null);
setTitle('');
setContent('');
setShowAddForm(false);
setShowTemplates(false);
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
notificationStore.success(`${label} copied to clipboard`);
} catch {
notificationStore.error('Failed to copy to clipboard');
}
};
const toggleCredentialVisibility = (noteId: string) => {
const current = showCredentials();
const updated = new Set(current);
if (updated.has(noteId)) {
updated.delete(noteId);
} else {
updated.add(noteId);
}
setShowCredentials(updated);
};
const maskCredential = (content: string): string => {
// Mask most of the content, showing only first 2 and last 2 chars
if (content.length <= 6) {
return '••••••';
}
return content.slice(0, 2) + '•'.repeat(Math.min(content.length - 4, 12)) + content.slice(-2);
};
const notes = () => knowledge()?.notes || [];
const hasNotes = () => notes().length > 0;
// Filtered notes based on search and category
const filteredNotes = createMemo(() => {
let result = notes();
// Filter by category
const catFilter = filterCategory();
if (catFilter) {
result = result.filter(n => n.category === catFilter);
}
// Filter by search query
const query = searchQuery().toLowerCase();
if (query) {
result = result.filter(n =>
n.title.toLowerCase().includes(query) ||
n.content.toLowerCase().includes(query) ||
CATEGORY_LABELS[n.category]?.toLowerCase().includes(query)
);
}
// Sort by updated_at descending
return result.sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
});
// Group notes by category for summary
const notesByCategory = createMemo(() => {
const grouped: Record<string, number> = {};
for (const note of notes()) {
grouped[note.category] = (grouped[note.category] || 0) + 1;
}
return grouped;
});
return (
<div class="border-t border-gray-700 pt-2 mt-2">
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={handleImportFile}
/>
{/* Header with expand/collapse */}
<button
onClick={() => setIsExpanded(!isExpanded())}
class="flex items-center justify-between w-full text-left px-2 py-1 text-sm hover:bg-gray-700/50 rounded transition-colors"
>
<span class="flex items-center gap-2">
<svg class={`w-3 h-3 transition-transform ${isExpanded() ? 'rotate-90' : ''}`} fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-300 font-medium">Saved Notes</span>
<Show when={hasNotes()}>
<span class="text-xs text-gray-500">({notes().length})</span>
</Show>
</span>
<Show when={isLoading()}>
<span class="text-xs text-gray-500">Loading...</span>
</Show>
</button>
{/* Expandable content */}
<Show when={isExpanded()}>
<div class="mt-2 space-y-2 px-2">
{/* Guest URL field */}
<div class="bg-gray-800/50 rounded p-2 border border-gray-700">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-400 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Guest URL
</span>
<Show when={guestUrl() && !isEditingUrl()}>
<a
href={guestUrl()}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
Open
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</Show>
</div>
<Show when={isEditingUrl()} fallback={
<div class="flex items-center gap-2">
<Show when={guestUrl()} fallback={
<span class="text-xs text-gray-500 italic">No URL set</span>
}>
<span class="text-xs text-gray-300 break-all font-mono">{guestUrl()}</span>
</Show>
<button
onClick={() => setIsEditingUrl(true)}
class="text-xs text-blue-400 hover:text-blue-300 ml-auto"
>
{guestUrl() ? 'Edit' : 'Add'}
</button>
</div>
}>
<div class="space-y-2">
<input
type="text"
value={guestUrl()}
onInput={(e) => setGuestUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveGuestUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
setGuestUrl(props.customUrl || '');
setIsEditingUrl(false);
}
}}
placeholder="https://192.168.1.100:8080"
class="w-full bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 placeholder-gray-500 font-mono"
autofocus
/>
<div class="flex gap-2 justify-end">
<button
onClick={() => {
setGuestUrl(props.customUrl || '');
setIsEditingUrl(false);
}}
class="text-xs px-2 py-1 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={saveGuestUrl}
disabled={isSavingUrl()}
class="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-50"
>
{isSavingUrl() ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Show>
</div>
{/* Search and filter bar - only show if there are notes */}
<Show when={hasNotes()}>
<div class="flex gap-2 mb-2">
<input
type="text"
placeholder="Search notes..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.target.value)}
class="flex-1 bg-gray-700 text-xs text-gray-200 rounded px-2 py-1 border border-gray-600 placeholder-gray-500"
/>
<select
value={filterCategory()}
onChange={(e) => setFilterCategory(e.target.value)}
class="bg-gray-700 text-xs text-gray-200 rounded px-2 py-1 border border-gray-600"
>
<option value="">All</option>
<For each={CATEGORY_OPTIONS}>
{(cat) => (
<Show when={notesByCategory()[cat]}>
<option value={cat}>{CATEGORY_ICONS[cat]} {CATEGORY_LABELS[cat]} ({notesByCategory()[cat]})</option>
</Show>
)}
</For>
</select>
</div>
</Show>
{/* Notes list */}
<Show when={hasNotes()} fallback={
<p class="text-xs text-gray-500 italic">No saved notes yet. Add notes to remember passwords, paths, and configs.</p>
}>
<Show when={filteredNotes().length > 0} fallback={
<p class="text-xs text-gray-500 italic">No notes match your search.</p>
}>
<div class="space-y-1.5 max-h-48 overflow-y-auto">
<For each={filteredNotes()}>
{(note) => (
<div class={`group rounded border px-2 py-1.5 text-xs ${CATEGORY_COLORS[note.category] || 'bg-gray-800/50 border-gray-700'}`}>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm" title={CATEGORY_LABELS[note.category]}>{CATEGORY_ICONS[note.category]}</span>
<span class="text-gray-200 font-medium">{note.title}</span>
<span class="text-gray-500 text-[10px]" title={new Date(note.updated_at).toLocaleString()}>
{formatRelativeTime(note.updated_at)}
</span>
</div>
<div class="flex items-center gap-1 mt-0.5">
{/* Content - mask if credential and not revealed */}
<Show when={note.category === 'credential' && !showCredentials().has(note.id)}
fallback={
<p class="text-gray-300 break-words font-mono text-[11px]">{note.content}</p>
}>
<p class="text-gray-400 break-words font-mono text-[11px]">{maskCredential(note.content)}</p>
</Show>
</div>
</div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{/* Show/hide for credentials */}
<Show when={note.category === 'credential'}>
<button
onClick={() => toggleCredentialVisibility(note.id)}
class="text-gray-400 hover:text-yellow-400 p-0.5"
title={showCredentials().has(note.id) ? 'Hide' : 'Show'}
>
<Show when={showCredentials().has(note.id)} fallback={
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
</Show>
{/* Copy button */}
<button
onClick={() => copyToClipboard(note.content, note.title)}
class="text-gray-400 hover:text-green-400 p-0.5"
title="Copy content"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
{/* Edit button */}
<button
onClick={() => startEdit(note)}
class="text-gray-400 hover:text-blue-400 p-0.5"
title="Edit"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
{/* Delete button with confirmation */}
<Show when={deleteConfirmId() === note.id} fallback={
<button
onClick={() => setDeleteConfirmId(note.id)}
class="text-gray-400 hover:text-red-400 p-0.5"
title="Delete"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
}>
<div class="flex items-center gap-1 bg-red-900/50 rounded px-1">
<span class="text-red-300 text-[10px]">Delete?</span>
<button
onClick={() => deleteNote(note.id)}
class="text-red-400 hover:text-red-300 p-0.5 font-bold"
title="Confirm delete"
>
</button>
<button
onClick={() => setDeleteConfirmId(null)}
class="text-gray-400 hover:text-gray-300 p-0.5"
title="Cancel"
>
</button>
</div>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
{/* Template picker */}
<Show when={showTemplates()}>
<div class="bg-gray-800/80 rounded p-2 border border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-300">Quick Templates</span>
<button onClick={() => setShowTemplates(false)} class="text-gray-400 hover:text-gray-200 text-xs"></button>
</div>
<div class="grid grid-cols-2 gap-1">
<For each={TEMPLATES}>
{(template) => (
<button
onClick={() => useTemplate(template)}
class={`text-left px-2 py-1 rounded text-[10px] border ${CATEGORY_COLORS[template.category]} hover:opacity-80 transition-opacity`}
>
{CATEGORY_ICONS[template.category]} {template.title}
</button>
)}
</For>
</div>
</div>
</Show>
{/* Actions menu (Export/Import/Clear) */}
<Show when={showActions()}>
<div class="bg-gray-800/80 rounded p-2 border border-gray-700 space-y-1">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-300">Actions</span>
<button onClick={() => { setShowActions(false); setClearConfirm(false); }} class="text-gray-400 hover:text-gray-200 text-xs"></button>
</div>
<button
onClick={exportNotes}
disabled={!hasNotes()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
Export Notes (JSON)
</button>
<button
onClick={() => fileInputRef?.click()}
disabled={isImporting()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-green-600/20 hover:bg-green-600/30 text-green-300 disabled:opacity-50 flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
{isImporting() ? 'Importing...' : 'Import Notes (Merge)'}
</button>
<Show when={clearConfirm()} fallback={
<button
onClick={() => setClearConfirm(true)}
disabled={!hasNotes()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-red-600/20 hover:bg-red-600/30 text-red-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
Clear All Notes
</button>
}>
<div class="bg-red-900/50 rounded p-2 flex items-center justify-between">
<span class="text-red-300 text-[10px]">Delete all {notes().length} notes?</span>
<div class="flex gap-1">
<button onClick={clearAllNotes} class="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-white rounded text-[10px]">Yes, clear all</button>
<button onClick={() => setClearConfirm(false)} class="px-2 py-0.5 bg-gray-600 hover:bg-gray-500 text-white rounded text-[10px]">Cancel</button>
</div>
</div>
</Show>
</div>
</Show>
{/* Add/Edit form */}
<Show when={showAddForm()}>
<div class="bg-gray-800/80 rounded p-2 space-y-2 border border-gray-700">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-300">
{editingNote() ? 'Edit Note' : 'Add Note'}
</span>
<Show when={editingNote()}>
<span class="text-[10px] text-gray-500">ID: {editingNote()?.id}</span>
</Show>
</div>
<div class="flex gap-2">
<select
value={category()}
onChange={(e) => setCategory(e.target.value)}
class="bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600"
>
<For each={CATEGORY_OPTIONS}>
{(cat) => <option value={cat}>{CATEGORY_ICONS[cat]} {CATEGORY_LABELS[cat]}</option>}
</For>
</select>
<input
type="text"
placeholder="Title (e.g., 'Admin Password')"
value={title()}
onInput={(e) => setTitle(e.target.value)}
class="flex-1 bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 placeholder-gray-500"
/>
</div>
<textarea
placeholder={category() === 'credential'
? "Enter credential value (stored encrypted)..."
: "Content..."}
value={content()}
onInput={(e) => setContent(e.target.value)}
class="w-full bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 resize-none placeholder-gray-500 font-mono"
rows={2}
/>
<Show when={category() === 'credential'}>
<p class="text-[10px] text-amber-400 flex items-center gap-1">
<span>🔐</span> Credentials are encrypted at rest and masked in the UI
</p>
</Show>
<div class="flex gap-2 justify-end">
<button
onClick={cancelEdit}
class="text-xs px-2 py-1 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={saveNote}
disabled={!title().trim() || !content().trim()}
class="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingNote() ? 'Update' : 'Save'}
</button>
</div>
</div>
</Show>
{/* Action buttons row */}
<Show when={!showAddForm() && !showTemplates() && !showActions()}>
<div class="flex items-center gap-2 pt-1">
<button
onClick={() => setShowAddForm(true)}
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<span>+</span> Add note
</button>
<button
onClick={() => setShowTemplates(true)}
class="text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1"
>
<span>📝</span> Templates
</button>
<button
onClick={() => setShowActions(true)}
class="text-xs text-gray-400 hover:text-gray-300 flex items-center gap-1"
>
<span></span> Actions
</button>
</div>
</Show>
</div>
</Show>
</div>
);
};

View file

@ -5,6 +5,7 @@ import type { JSX } from 'solid-js';
import type { Alert } from '@/types/api';
import type { AlertConfig, AlertThresholds, HysteresisThreshold } from '@/types/alerts';
import { showError, showSuccess } from '@/utils/toast';
import { formatAlertValue, formatAlertThreshold } from '@/utils/alertFormatters';
interface ActivationModalProps {
isOpen: boolean;
@ -85,7 +86,7 @@ const summarizeThresholds = (config: AlertConfig | null): ThresholdSummary[] =>
...nodeItems,
{
label: 'Temperature',
value: formatThreshold(extractTrigger(config.nodeDefaults?.temperature)),
value: formatAlertThreshold(extractTrigger(config.nodeDefaults?.temperature), 'temperature'),
},
];
summaries.push({ heading: 'Node thresholds', items: nodeWithTemperature });
@ -263,20 +264,18 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
<For each={violations()}>
{(alert) => (
<div
class={`border rounded-md p-3 text-sm transition-colors ${
alert.level === 'critical'
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
}`}
class={`border rounded-md p-3 text-sm transition-colors ${alert.level === 'critical'
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${
alert.level === 'critical'
? 'bg-red-600 text-white'
: 'bg-yellow-500 text-gray-900'
}`}
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${alert.level === 'critical'
? 'bg-red-600 text-white'
: 'bg-yellow-500 text-gray-900'
}`}
>
{alert.level}
</span>
@ -288,7 +287,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
</div>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-300">{alert.message}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Threshold {alert.threshold}% Current {alert.value}% Since{' '}
Threshold {formatAlertValue(alert.threshold, alert.type)} Current {formatAlertValue(alert.value, alert.type)} Since{' '}
{new Date(alert.startTime).toLocaleString()}
</p>
</div>
@ -303,11 +302,10 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
Notification channels
</h3>
<div
class={`mt-3 rounded-md border p-4 ${
channelSummary().status === 'configured'
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
}`}
class={`mt-3 rounded-md border p-4 ${channelSummary().status === 'configured'
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
}`}
>
<p class="text-sm text-gray-800 dark:text-gray-100">{channelSummary().message}</p>
<button

View file

@ -0,0 +1,189 @@
import { Show, createSignal } from 'solid-js';
import { aiChatStore } from '@/stores/aiChat';
import type { Alert } from '@/types/api';
import { formatAlertValue } from '@/utils/alertFormatters';
interface InvestigateAlertButtonProps {
alert: Alert;
resourceType?: string;
vmid?: number;
size?: 'sm' | 'md';
variant?: 'icon' | 'text' | 'full';
class?: string;
}
/**
* "Ask AI" button for one-click alert investigation.
* When clicked, opens the AI chat panel with the alert context pre-populated.
*/
export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
const [isHovered, setIsHovered] = createSignal(false);
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// Calculate how long the alert has been active
const startTime = new Date(props.alert.startTime);
const now = new Date();
const durationMs = now.getTime() - startTime.getTime();
const durationMins = Math.floor(durationMs / 60000);
const durationStr =
durationMins < 60
? `${durationMins} min${durationMins !== 1 ? 's' : ''}`
: `${Math.floor(durationMins / 60)}h ${durationMins % 60}m`;
// Format a focused prompt for investigation
const prompt = `Investigate this ${props.alert.level.toUpperCase()} alert:
**Resource:** ${props.alert.resourceName}
**Alert Type:** ${props.alert.type}
**Current Value:** ${formatAlertValue(props.alert.value, props.alert.type)}
**Threshold:** ${formatAlertValue(props.alert.threshold, props.alert.type)}
**Duration:** ${durationStr}
${props.alert.node ? `**Node:** ${props.alert.node}` : ''}
Please:
1. Identify the root cause
2. Check related metrics
3. Suggest specific remediation steps
4. Execute diagnostic commands if safe`;
// Determine target type from alert or infer from resource
let targetType = props.resourceType || 'guest';
if (props.alert.type.startsWith('node_')) {
targetType = 'node';
} else if (props.alert.type.startsWith('docker_')) {
targetType = 'docker_container';
} else if (props.alert.type.startsWith('storage_')) {
targetType = 'storage';
}
// Open AI chat with this context and prompt
aiChatStore.openWithPrompt(prompt, {
targetType,
targetId: props.alert.resourceId,
context: {
alertId: props.alert.id,
alertType: props.alert.type,
alertLevel: props.alert.level,
alertMessage: props.alert.message,
guestName: props.alert.resourceName,
node: props.alert.node,
vmid: props.vmid,
},
});
};
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
};
const baseButtonClass = `
inline-flex items-center justify-center
rounded-md transition-all duration-200
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
`;
// Icon-only variant (smallest footprint)
if (props.variant === 'icon') {
return (
<button
type="button"
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
class={`${baseButtonClass} ${sizeClasses[props.size || 'sm']}
bg-gradient-to-r from-purple-500/10 to-blue-500/10
hover:from-purple-500/20 hover:to-blue-500/20
text-purple-600 dark:text-purple-400
hover:text-purple-700 dark:hover:text-purple-300
border border-purple-200/50 dark:border-purple-700/50
hover:border-purple-300 dark:hover:border-purple-600
${props.class || ''}`}
title="Ask AI to investigate this alert"
>
<svg
class={`${props.size === 'sm' ? 'w-3.5 h-3.5' : '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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</button>
);
}
// Text variant (shows "Ask AI" on hover)
if (props.variant === 'text') {
return (
<button
type="button"
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
class={`${baseButtonClass} px-2 py-1
bg-gradient-to-r from-purple-500/10 to-blue-500/10
hover:from-purple-500/20 hover:to-blue-500/20
text-purple-600 dark:text-purple-400
hover:text-purple-700 dark:hover:text-purple-300
border border-purple-200/50 dark:border-purple-700/50
hover:border-purple-300 dark:hover:border-purple-600
gap-1.5
${props.class || ''}`}
title="Ask AI to investigate this alert"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span class="text-xs font-medium">Ask AI</span>
</button>
);
}
// Full variant (with expanded label)
return (
<button
type="button"
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
class={`${baseButtonClass} px-3 py-1.5
bg-gradient-to-r from-purple-500 to-blue-500
hover:from-purple-600 hover:to-blue-600
text-white font-medium
shadow-sm hover:shadow-md
gap-2
${props.class || ''}`}
title="Ask AI to investigate this alert"
>
<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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span>Investigate with AI</span>
<Show when={isHovered()}>
<span class="text-xs opacity-80"></span>
</Show>
</button>
);
}
export default InvestigateAlertButton;

View file

@ -23,6 +23,9 @@ interface BackupsFilterProps {
onReset?: () => void;
statusFilter?: () => 'all' | 'verified' | 'unverified';
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
// Time format toggle
useRelativeTime?: () => boolean;
setUseRelativeTime?: (value: boolean) => void;
}
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
@ -504,6 +507,25 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Time Format Toggle */}
<Show when={props.useRelativeTime && props.setUseRelativeTime}>
<button
type="button"
onClick={() => props.setUseRelativeTime!(!props.useRelativeTime!())}
title={props.useRelativeTime!() ? 'Switch to absolute time' : 'Switch to relative time'}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${props.useRelativeTime!()
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="hidden sm:inline">{props.useRelativeTime!() ? 'Relative' : 'Absolute'}</span>
</button>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Reset Button */}
<button
onClick={() => {
@ -518,14 +540,14 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all') ||
(props.statusFilter && props.statusFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all') ||
(props.statusFilter && props.statusFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg

View file

@ -134,12 +134,10 @@ const UnifiedBackups: Component = () => {
}
});
const [useRelativeTime] = createLocalStorageBooleanSignal(
const [useRelativeTime, setUseRelativeTime] = createLocalStorageBooleanSignal(
STORAGE_KEYS.BACKUPS_USE_RELATIVE_TIME,
false, // Default to absolute time
);
// TODO: Add time format toggle to BackupsFilter component
// const setUseRelativeTime = ...;
// Helper functions
const getDaySuffix = (day: number) => {
@ -1932,6 +1930,8 @@ const UnifiedBackups: Component = () => {
sortDirection={sortDirection}
setSortDirection={setSortDirection}
onReset={resetFilters}
useRelativeTime={useRelativeTime}
setUseRelativeTime={setUseRelativeTime}
/>
{/* Table */}
@ -2364,7 +2364,7 @@ const UnifiedBackups: Component = () => {
</td>
</Show>
<td
class="hidden md:table-cell p-0.5 px-1.5 cursor-help align-middle"
class="hidden md:table-cell p-0.5 px-1.5 align-middle"
onMouseEnter={(e) => {
const details = [];

View file

@ -1,7 +1,7 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow, GUEST_COLUMNS } from './GuestRow';
import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
@ -19,8 +19,10 @@ import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from
import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { getBackupInfo } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
type GuestMetadataRecord = Record<string, GuestMetadata>;
type IdleCallbackHandle = number;
@ -199,6 +201,7 @@ type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
type BackupMode = 'all' | 'needs-backup';
type GroupingMode = 'grouped' | 'flat';
type ProblemsMode = 'all' | 'problems';
export function Dashboard(props: DashboardProps) {
const navigate = useNavigate();
@ -252,6 +255,15 @@ export function Dashboard(props: DashboardProps) {
},
);
// Problems mode - show only guests with issues
const [problemsMode, setProblemsMode] = usePersistentSignal<ProblemsMode>(
'dashboardProblemsMode',
'all',
{
deserialize: (raw) => (raw === 'all' || raw === 'problems' ? raw : 'all'),
},
);
const [showFilters, setShowFilters] = usePersistentSignal<boolean>(
'dashboardShowFilters',
false,
@ -265,8 +277,18 @@ export function Dashboard(props: DashboardProps) {
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Column visibility management
// OS and IP columns are hidden by default since they require guest agent and may show dashes
const columnVisibility = useColumnVisibility(
STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS,
GUEST_COLUMNS as GuestColumnDef[],
['os', 'ip'] // Default hidden columns for cleaner first-run experience
);
const visibleColumns = columnVisibility.visibleColumns;
const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id));
// Total columns for colspan calculations
const totalColumns = GUEST_COLUMNS.length;
const totalColumns = createMemo(() => visibleColumns().length);
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
@ -434,7 +456,8 @@ export function Dashboard(props: DashboardProps) {
selectedNode() !== null ||
viewMode() !== 'all' ||
statusMode() !== 'all' ||
backupMode() !== 'all';
backupMode() !== 'all' ||
problemsMode() !== 'all';
if (hasActiveFilters) {
// Clear ALL filters including search text, tag filters, node selection, and view modes
@ -446,6 +469,7 @@ export function Dashboard(props: DashboardProps) {
setViewMode('all');
setStatusMode('all');
setBackupMode('all');
setProblemsMode('all');
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
@ -457,6 +481,12 @@ export function Dashboard(props: DashboardProps) {
}
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Check if AI chat is open - if so, focus that instead
if (aiChatStore.focusInput()) {
// AI chat input was focused, let the character be typed there
return;
}
// Otherwise, focus the search input
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
@ -473,6 +503,32 @@ export function Dashboard(props: DashboardProps) {
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Compute guests with problems - used for AI investigation regardless of filter state
const problemGuests = createMemo(() => {
return allGuests().filter((g) => {
// Skip templates
if (g.template) return false;
// Check for degraded status
const status = (g.status || '').toLowerCase();
const isDegraded = DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status) && status !== 'stopped');
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}
// Check for high resource usage (>90%)
if (g.cpu > 0.9) return true;
if (g.memory && g.memory.usage && g.memory.usage > 90) return true;
return false;
});
});
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();
@ -493,7 +549,8 @@ export function Dashboard(props: DashboardProps) {
if (viewMode() === 'vm') {
guests = guests.filter((g) => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
guests = guests.filter((g) => g.type === 'lxc');
// Include both traditional LXC and OCI containers (Proxmox 9.1+)
guests = guests.filter((g) => g.type === 'lxc' || g.type === 'oci');
}
// Filter by status
@ -522,6 +579,32 @@ export function Dashboard(props: DashboardProps) {
});
}
// Filter by problems mode - show guests that need attention
if (problemsMode() === 'problems') {
guests = guests.filter((g) => {
// Skip templates
if (g.template) return false;
// Check for degraded status
const status = (g.status || '').toLowerCase();
const isDegraded = DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status) && status !== 'stopped');
if (isDegraded) return true;
// Check for backup issues
const backupInfo = getBackupInfo(g.lastBackup);
if (backupInfo.status === 'stale' || backupInfo.status === 'critical' || backupInfo.status === 'never') {
return true;
}
// Check for high resource usage (>90%)
if (g.cpu > 0.9) return true;
if (g.memory && g.memory.usage && g.memory.usage > 90) return true;
return false;
});
}
// Apply search/filter
const searchTerm = search().trim();
if (searchTerm) {
@ -643,6 +726,7 @@ export function Dashboard(props: DashboardProps) {
guests.forEach((guest) => {
// Node.ID is formatted as "instance-nodename", so we need to match that
const nodeId = `${guest.instance}-${guest.node}`;
if (!groups[nodeId]) {
groups[nodeId] = [];
}
@ -724,7 +808,7 @@ export function Dashboard(props: DashboardProps) {
}).length;
const stopped = guests.length - running - degraded;
const vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc').length;
const containers = guests.filter((g) => g.type === 'lxc' || g.type === 'oci').length;
return {
total: guests.length,
running,
@ -737,6 +821,27 @@ export function Dashboard(props: DashboardProps) {
const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => {
logger.debug('handleNodeSelect called', { nodeId, nodeType });
// If AI sidebar is open, add node to AI context instead of filtering
if (aiChatStore.isOpen && nodeId && nodeType === 'pve') {
const node = props.nodes.find((n) => n.id === nodeId);
if (node) {
// Toggle: remove if already in context, add if not
if (aiChatStore.hasContextItem(nodeId)) {
aiChatStore.removeContextItem(nodeId);
} else {
aiChatStore.addContextItem('node', nodeId, node.name, {
nodeName: node.name,
name: node.name,
type: 'Proxmox Node',
status: node.status,
instance: node.instance,
});
}
}
return;
}
// Track selected node for filtering (independent of search)
if (nodeType === 'pve' || nodeType === null) {
setSelectedNode(nodeId);
@ -796,6 +901,43 @@ export function Dashboard(props: DashboardProps) {
}
};
// Handle row click - add guest to AI context (works even when sidebar is closed)
const handleGuestRowClick = (guest: VM | Container) => {
// Only enable if AI is configured
if (!aiChatStore.enabled) return;
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const guestType = guest.type === 'qemu' ? 'vm' : 'container';
const isOCI = guest.type === 'oci' || ('isOci' in guest && guest.isOci === true);
// Toggle: remove if already in context, add if not
if (aiChatStore.hasContextItem(guestId)) {
aiChatStore.removeContextItem(guestId);
// If no items left in context and sidebar is open, keep it open for now
} else {
// Build context with OCI-specific info when applicable
const contextData: Record<string, unknown> = {
guestName: guest.name,
name: guest.name,
type: guest.type === 'qemu' ? 'Virtual Machine' : (isOCI ? 'OCI Container' : 'LXC Container'),
vmid: guest.vmid,
node: guest.node,
status: guest.status,
};
// Add OCI image info if available
if (isOCI && 'osTemplate' in guest && guest.osTemplate) {
contextData.ociImage = guest.osTemplate;
}
aiChatStore.addContextItem(guestType, guestId, guest.name, contextData);
// Auto-open the sidebar when first item is selected
if (!aiChatStore.isOpen) {
aiChatStore.open();
}
}
};
return (
<div class="space-y-3">
<ProxmoxSectionNav current="overview" />
@ -807,7 +949,7 @@ export function Dashboard(props: DashboardProps) {
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc' || g.type === 'oci')}
searchTerm={search()}
/>
@ -822,11 +964,18 @@ export function Dashboard(props: DashboardProps) {
setStatusMode={setStatusMode}
backupMode={backupMode}
setBackupMode={setBackupMode}
problemsMode={problemsMode}
setProblemsMode={setProblemsMode}
filteredProblemGuests={problemGuests}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => (searchInputRef = el)}
availableColumns={columnVisibility.availableToggles()}
isColumnHidden={columnVisibility.isHiddenByUser}
onColumnToggle={columnVisibility.toggle}
onColumnReset={columnVisibility.resetToDefaults}
/>
{/* Loading State */}
@ -951,96 +1100,40 @@ export function Dashboard(props: DashboardProps) {
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "900px" }}>
<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-700">
{/* Name Header */}
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<For each={visibleColumns()}>
{(col) => {
const isFirst = () => col.id === visibleColumns()[0]?.id;
const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined;
const isSortable = !!sortKeyForCol;
const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol;
{/* Type */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* VMID */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Uptime */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* CPU */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Memory */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Read */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Write */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net In */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net Out */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
return (
<th
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
style={{
...(col.width ? { "min-width": col.width } : {}),
"vertical-align": 'middle'
}}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}
>
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
{col.icon ? (
<span class="flex items-center">{col.icon}</span>
) : (
col.label
)}
{isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</th>
);
}}
</For>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@ -1059,16 +1152,23 @@ export function Dashboard(props: DashboardProps) {
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
{(guest, index) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
// Get adjacent guest IDs for merged AI context borders
const prevGuest = guests[index() - 1];
const nextGuest = guests[index() + 1];
const prevGuestId = prevGuest ? (prevGuest.id || `${prevGuest.instance}-${prevGuest.vmid}`) : null;
const nextGuestId = nextGuest ? (nextGuest.id || `${nextGuest.instance}-${nextGuest.vmid}`) : null;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
@ -1080,6 +1180,10 @@ export function Dashboard(props: DashboardProps) {
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
visibleColumnIds={visibleColumnIds()}
aboveGuestId={prevGuestId}
belowGuestId={nextGuestId}
onRowClick={aiChatStore.enabled ? handleGuestRowClick : undefined}
/>
</ComponentErrorBoundary>
);

View file

@ -2,6 +2,10 @@ import { Component, Show, For, createSignal, onMount, createEffect, onCleanup }
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { InvestigateProblemsButton } from './InvestigateProblemsButton';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import type { VM, Container } from '@/types/api';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
@ -15,11 +19,20 @@ interface DashboardFilterProps {
setStatusMode: (value: 'all' | 'running' | 'degraded' | 'stopped') => void;
backupMode: () => 'all' | 'needs-backup';
setBackupMode: (value: 'all' | 'needs-backup') => void;
problemsMode: () => 'all' | 'problems';
setProblemsMode: (value: 'all' | 'problems') => void;
/** Guests that match the problems filter - used for AI investigation */
filteredProblemGuests?: () => (VM | Container)[];
groupingMode: () => 'grouped' | 'flat';
setGroupingMode: (value: 'grouped' | 'flat') => void;
setSortKey: (value: string) => void;
setSortDirection: (value: string) => void;
searchInputRef?: (el: HTMLInputElement) => void;
// Column visibility
availableColumns?: ColumnDef[];
isColumnHidden?: (id: string) => boolean;
onColumnToggle?: (id: string) => void;
onColumnReset?: () => void;
}
export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
@ -91,10 +104,10 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
return (
<Card class="dashboard-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
{/* Search Bar */}
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<div class="flex flex-col gap-3">
{/* Row 1: Search Bar */}
<div class="flex gap-2 items-center">
<div class="relative flex-1 min-w-0">
<input
ref={(el) => {
searchInputEl = el;
@ -147,27 +160,33 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
class="absolute right-[4.5rem] top-1/2 -translate-y-1/2 transform p-1 rounded-full
bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300
hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/50 dark:hover:text-red-400
transition-all duration-150 active:scale-90"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<div class="absolute inset-y-0 right-2 flex items-center gap-0.5">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
class={`flex h-6 w-6 items-center justify-center rounded-md transition-colors
${isHistoryOpen()
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400'
: 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300'
}`}
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
@ -186,13 +205,9 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{/* Dropdown chevron icon - clearer than clock */}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
<span class="sr-only">Show search history</span>
</button>
@ -287,97 +302,141 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</div>
</div>
{/* Filters */}
{/* Row 2: Filters - grouped for logical wrapping */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-2">
{/* Problems Toggle - Prominent "Show me issues" button */}
<button
type="button"
onClick={() => props.setProblemsMode(props.problemsMode() === 'problems' ? 'all' : 'problems')}
class={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg transition-all duration-150 active:scale-95 ${props.problemsMode() === 'problems'
? 'bg-gradient-to-r from-red-500 to-orange-500 text-white shadow-md shadow-red-500/25 ring-1 ring-red-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Show guests that need attention: degraded status, backup issues, or high resource usage (>90%)"
>
<svg class={`w-3.5 h-3.5 ${props.problemsMode() === 'problems' ? 'animate-pulse' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<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" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Problems
</button>
{/* AI Investigation button - appears when problems mode is active */}
<Show when={props.filteredProblemGuests}>
<InvestigateProblemsButton
problemGuests={props.filteredProblemGuests!()}
isProblemsMode={props.problemsMode() === 'problems'}
/>
</Show>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Primary Filters Group: Type + Status */}
<div class="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-blue-200 dark:ring-blue-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
VMs
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-200 dark:ring-green-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
LXCs
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setStatusMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-200 dark:ring-green-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<span class={`w-2 h-2 rounded-full ${props.statusMode() === 'running' ? 'bg-green-500 shadow-sm shadow-green-500/50' : 'bg-green-400/60'}`} />
Running
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'degraded' ? 'all' : 'degraded')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.statusMode() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-200 dark:ring-amber-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<span class={`w-2 h-2 rounded-full ${props.statusMode() === 'degraded' ? 'bg-amber-500 shadow-sm shadow-amber-500/50' : 'bg-amber-400/60'}`} />
Degraded
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm ring-1 ring-red-200 dark:ring-red-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<span class={`w-2 h-2 rounded-full ${props.statusMode() === 'stopped' ? 'bg-red-500 shadow-sm shadow-red-500/50' : 'bg-red-400/60'}`} />
Stopped
</button>
</div>
</div>
</div>
{/* End Primary Filters Group */}
{/* Secondary Controls Group: Backup, Grouping, View, Columns, Reset */}
<div class="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VMs
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXCs
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setStatusMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Running
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'degraded' ? 'all' : 'degraded')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Degraded
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Stopped
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Backup Filter */}
<button
type="button"
onClick={() => props.setBackupMode(props.backupMode() === 'needs-backup' ? 'all' : 'needs-backup')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${props.backupMode() === 'needs-backup'
? 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 shadow-sm'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
class={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-all duration-150 active:scale-95 ${props.backupMode() === 'needs-backup'
? 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 shadow-sm ring-1 ring-orange-200 dark:ring-orange-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Show guests with stale or missing backups"
>
@ -395,23 +454,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupingMode('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Group by node"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v11z" />
</svg>
Grouped
</button>
<button
type="button"
onClick={() => props.setGroupingMode(props.groupingMode() === 'flat' ? 'grouped' : 'flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Flat list view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
List
</button>
</div>
@ -423,6 +493,20 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Column Picker */}
<Show when={props.availableColumns && props.isColumnHidden && props.onColumnToggle}>
<ColumnPicker
columns={props.availableColumns!}
isHidden={props.isColumnHidden!}
onToggle={props.onColumnToggle!}
onReset={props.onColumnReset}
/>
</Show>
<Show when={props.availableColumns}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Reset Button */}
<button
onClick={() => {
@ -432,15 +516,17 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
props.setViewMode('all');
props.setStatusMode('all');
props.setBackupMode('all');
props.setProblemsMode('all');
props.setGroupingMode('grouped');
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search() ||
class={`flex items-center justify-center px-2.5 py-1.5 text-xs font-medium rounded-lg transition-all duration-150 active:scale-95 ${props.search() ||
props.viewMode() !== 'all' ||
props.statusMode() !== 'all' ||
props.backupMode() !== 'all' ||
props.problemsMode() !== 'all' ||
props.groupingMode() !== 'grouped'
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 ring-1 ring-blue-200 dark:ring-blue-800'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>

View file

@ -48,7 +48,7 @@ export function DiskList(props: DiskListProps) {
<Show
when={props.disks && props.disks.length > 0}
fallback={
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
<span class="text-gray-400 text-sm" title={getDiskStatusTooltip()}>
-
</span>
}

View file

@ -2,7 +2,7 @@ import { Show, createMemo, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { formatPercent } from '@/utils/format';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistory } from '@/stores/metricsHistory';
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
import { Sparkline } from '@/components/shared/Sparkline';
interface EnhancedCPUBarProps {
@ -35,21 +35,25 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
setShowTooltip(false);
};
const { viewMode } = useMetricsViewMode();
const { viewMode, timeRange } = useMetricsViewMode();
// Get metric history for sparkline
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
const metricHistory = createMemo(() => {
// Subscribe to version changes so we re-read when new data is seeded
getMetricsVersion();
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistory(props.resourceId);
return getMetricHistoryForRange(props.resourceId, timeRange());
});
return (
<Show
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
// Progress bar mode - full width, flex centered like stacked bars
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@ -112,19 +116,14 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
</div>
}
>
{/* Sparkline mode */}
<div class="metric-text w-full h-6 flex items-center gap-1.5">
<div class="flex-1 min-w-0">
<Sparkline
data={metricHistory()}
metric="cpu"
width={0}
height={24}
/>
</div>
<span class="text-[10px] font-medium text-gray-800 dark:text-gray-100 whitespace-nowrap flex-shrink-0 min-w-[35px]">
{formatPercent(props.usage)}
</span>
{/* Sparkline mode - full width, flex centered like stacked bars */}
<div class="metric-text w-full h-4 flex items-center justify-center min-w-0 overflow-hidden">
<Sparkline
data={metricHistory()}
metric="cpu"
width={0}
height={16}
/>
</div>
</Show>
);

View file

@ -1,7 +1,11 @@
import { Component, Show, For } from 'solid-js';
import { Component, Show, For, createSignal, createEffect } from 'solid-js';
import { VM, Container } from '@/types/api';
import { formatBytes } from '@/utils/format';
import { formatBytes, formatPercent, formatSpeed, formatUptime } from '@/utils/format';
import { DiskList } from './DiskList';
import { aiChatStore } from '@/stores/aiChat';
import { AIAPI } from '@/api/ai';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { logger } from '@/utils/logger';
type Guest = VM | Container;
@ -12,37 +16,205 @@ interface GuestDrawerProps {
}
export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
const [aiEnabled, setAiEnabled] = createSignal(false);
const [annotations, setAnnotations] = createSignal<string[]>([]);
const [newAnnotation, setNewAnnotation] = createSignal('');
const [saving, setSaving] = createSignal(false);
// Build guest ID for metadata
const guestId = () => props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
// Check if AI is enabled and load annotations on mount
createEffect(() => {
AIAPI.getSettings()
.then((settings) => setAiEnabled(settings.enabled && settings.configured))
.catch((err) => logger.debug('[GuestDrawer] AI settings check failed:', err));
// Load existing annotations
GuestMetadataAPI.getMetadata(guestId())
.then((meta) => {
if (meta.notes && Array.isArray(meta.notes)) setAnnotations(meta.notes);
})
.catch((err) => logger.debug('[GuestDrawer] Failed to load annotations:', err));
});
// Update AI context whenever guest changes or annotations are loaded
createEffect(() => {
const guestType = props.guest.type === 'qemu' ? 'vm' : 'container';
// Track annotations to re-run when they change
void annotations();
aiChatStore.setTargetContext(guestType, guestId(), {
guestName: props.guest.name,
...buildGuestContext(),
});
});
// Note: We no longer clear context on unmount - context accumulates across navigation
// Users can clear individual items or all context from the AI panel
const saveAnnotations = async (updated: string[]) => {
setSaving(true);
try {
await GuestMetadataAPI.updateMetadata(guestId(), { notes: updated });
logger.debug('[GuestDrawer] Annotations saved');
} catch (err) {
logger.error('[GuestDrawer] Failed to save annotations:', err);
} finally {
setSaving(false);
}
};
const addAnnotation = () => {
const text = newAnnotation().trim();
if (!text) return;
const updated = [...annotations(), text];
setAnnotations(updated);
setNewAnnotation('');
saveAnnotations(updated);
};
const removeAnnotation = (index: number) => {
const updated = annotations().filter((_, i) => i !== index);
setAnnotations(updated);
saveAnnotations(updated);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addAnnotation();
}
};
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
// Build comprehensive context for AI
const buildGuestContext = () => {
const guest = props.guest;
const context: Record<string, unknown> = {
name: guest.name,
type: guest.type === 'qemu' ? 'Virtual Machine' : 'LXC Container',
vmid: guest.vmid,
node: guest.node, // PVE node this guest lives on
guest_node: guest.node, // For backend agent routing
status: guest.status,
uptime: guest.uptime ? formatUptime(guest.uptime) : 'Not running',
};
// CPU info
if (guest.cpu !== undefined) {
context.cpu_usage = formatPercent(guest.cpu * 100);
}
if (guest.cpus) {
context.cpu_cores = guest.cpus;
}
// Memory info
if (guest.memory) {
context.memory_used = formatBytes(guest.memory.used || 0);
context.memory_total = formatBytes(guest.memory.total || 0);
context.memory_usage = formatPercent(guest.memory.usage || 0);
if (guest.memory.balloon && guest.memory.balloon !== guest.memory.total) {
context.memory_balloon = formatBytes(guest.memory.balloon);
}
if (guest.memory.swapTotal && guest.memory.swapTotal > 0) {
context.swap_used = formatBytes(guest.memory.swapUsed || 0);
context.swap_total = formatBytes(guest.memory.swapTotal);
}
}
// Disk info
if (guest.disk && guest.disk.total > 0) {
context.disk_used = formatBytes(guest.disk.used || 0);
context.disk_total = formatBytes(guest.disk.total || 0);
context.disk_usage = formatPercent((guest.disk.used / guest.disk.total) * 100);
}
// I/O rates
if (guest.diskRead !== undefined) {
context.disk_read_rate = formatSpeed(guest.diskRead);
}
if (guest.diskWrite !== undefined) {
context.disk_write_rate = formatSpeed(guest.diskWrite);
}
if (guest.networkIn !== undefined) {
context.network_in_rate = formatSpeed(guest.networkIn);
}
if (guest.networkOut !== undefined) {
context.network_out_rate = formatSpeed(guest.networkOut);
}
// OS info (both VMs and containers can have this)
if (guest.osName) context.os_name = guest.osName;
if (guest.osVersion) context.os_version = guest.osVersion;
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
// Tags
if (guest.tags?.length) {
context.tags = guest.tags;
}
// Backup info - Pulse already has this from PBS
if (guest.lastBackup) {
const backupDate = new Date(guest.lastBackup);
const now = new Date();
const daysSinceBackup = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
context.last_backup = backupDate.toISOString();
context.days_since_backup = daysSinceBackup;
if (daysSinceBackup > 30) {
context.backup_status = 'CRITICAL - No backup in over 30 days';
} else if (daysSinceBackup > 7) {
context.backup_status = 'Warning - Backup is over a week old';
} else {
context.backup_status = 'OK';
}
} else {
context.backup_status = 'NEVER - No backup recorded';
}
// User annotations (for AI context)
if (annotations().length > 0) {
context.user_annotations = annotations();
}
return context;
};
const handleAskAI = () => {
const guestType = props.guest.type === 'qemu' ? 'vm' : 'container';
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.vmid}`;
aiChatStore.openForTarget(guestType, guestId, {
guestName: props.guest.name,
...buildGuestContext(),
});
};
const hasOsInfo = () => {
if (!isVM(props.guest)) return false;
// Both VMs and containers can have OS info
return (props.guest.osName?.length ?? 0) > 0 || (props.guest.osVersion?.length ?? 0) > 0;
};
const osName = () => {
if (!isVM(props.guest)) return '';
return props.guest.osName || '';
};
const osVersion = () => {
if (!isVM(props.guest)) return '';
return props.guest.osVersion || '';
};
const hasAgentInfo = () => {
if (!isVM(props.guest)) return false;
return !!props.guest.agentVersion;
};
const agentVersion = () => {
if (!isVM(props.guest)) return '';
return props.guest.agentVersion || '';
};
const ipAddresses = () => {
if (!isVM(props.guest)) return [];
return props.guest.ipAddresses || [];
};
@ -70,7 +242,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
};
const networkInterfaces = () => {
if (!isVM(props.guest)) return [];
// Both VMs and containers can have network interfaces
return props.guest.networkInterfaces || [];
};
@ -79,118 +251,248 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
};
return (
<div class="flex items-start gap-4">
{/* Left Column: Guest Overview */}
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
<div class="mt-1 space-y-1">
<Show when={hasOsInfo()}>
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium" title={osName()}>{osName()}</span>
<div class="space-y-3">
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500"></span>
</Show>
<Show when={osVersion().length > 0}>
<span title={osVersion()}>{osVersion()}</span>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex flex-wrap items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<span class="uppercase tracking-wide text-[10px] text-gray-400 dark:text-gray-500">
Agent
</span>
<span title={`QEMU guest agent ${agentVersion()}`}>
QEMU guest agent {agentVersion()}
</span>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200">
<span class="truncate">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
{/* Bottom row: AI Context & Ask AI - only show when AI is enabled */}
<Show when={aiEnabled()}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">AI Context</span>
<Show when={saving()}>
<span class="text-[9px] text-gray-400">saving...</span>
</Show>
</div>
{/* Existing annotations */}
<Show when={annotations().length > 0}>
<div class="flex flex-wrap gap-1.5">
<For each={annotations()}>
{(annotation, index) => (
<span class="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded-md bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
<span class="max-w-[300px] truncate">{annotation}</span>
<button
type="button"
onClick={() => removeAnnotation(index())}
class="ml-0.5 p-0.5 rounded hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors"
title="Remove"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
</For>
</div>
</Show>
</div>
</div>
{/* Middle Column: Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Memory</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
{/* Right Column: Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Filesystems</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
{/* Add new annotation + Ask AI */}
<div class="flex items-center gap-2">
<input
type="text"
value={newAnnotation()}
onInput={(e) => setNewAnnotation(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Add context for AI (press Enter)..."
class="flex-1 px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</Show>
{/* Far Right Column: Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network Interfaces</div>
<div class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">Row charts show current rate; totals below are cumulative since boot.</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={networkInterfaces()}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="space-y-1 rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700">
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
{iface.mac}
</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1">
<For each={addresses}>
{(ip) => (
<span
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
<span>Total RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>Total TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
<button
type="button"
onClick={addAnnotation}
disabled={!newAnnotation().trim()}
class="px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button
type="button"
onClick={handleAskAI}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-[11px] font-medium shadow-sm hover:from-purple-600 hover:to-pink-600 transition-all"
title={`Ask AI about ${props.guest.name}`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-2.576.43a18.003 18.003 0 01-5.118 0l-2.576-.43c-1.717-.293-2.299-2.379-1.067-3.611L5 14.5" />
</svg>
Ask AI
</button>
</div>
</div>
</Show>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
import { Component, Show, createMemo } from 'solid-js';
import { aiChatStore } from '@/stores/aiChat';
import type { VM, Container } from '@/types/api';
import { getBackupInfo } from '@/utils/format';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
interface ProblemGuest {
guest: VM | Container;
issues: string[];
}
interface InvestigateProblemsButtonProps {
/** The filtered guests currently showing as "problems" */
problemGuests: (VM | Container)[];
/** Whether the problems filter is active */
isProblemsMode: boolean;
}
/**
* "Investigate Problems with AI" button.
* Appears when the Problems filter is active and finds results.
* Opens AI chat with rich context about all problem guests.
*/
export const InvestigateProblemsButton: Component<InvestigateProblemsButtonProps> = (props) => {
// Analyze each guest to determine what issues they have
const analyzedProblems = createMemo((): ProblemGuest[] => {
return props.problemGuests.map(guest => {
const issues: string[] = [];
// Check for degraded status
const status = (guest.status || '').toLowerCase();
const isDegraded = DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status) && status !== 'stopped');
if (isDegraded) {
issues.push(`Status: ${status || 'unknown'}`);
}
// Check for backup issues
if (!guest.template) {
const backupInfo = getBackupInfo(guest.lastBackup);
if (backupInfo.status === 'critical') {
issues.push('Backup: Critical (very overdue)');
} else if (backupInfo.status === 'stale') {
issues.push('Backup: Stale');
} else if (backupInfo.status === 'never') {
issues.push('Backup: Never backed up');
}
}
// Check for high CPU
if (guest.cpu > 0.9) {
issues.push(`CPU: ${(guest.cpu * 100).toFixed(0)}%`);
}
// Check for high memory
if (guest.memory && guest.memory.usage && guest.memory.usage > 90) {
issues.push(`Memory: ${guest.memory.usage.toFixed(0)}%`);
}
return { guest, issues };
}).filter(p => p.issues.length > 0);
});
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const problems = analyzedProblems();
if (problems.length === 0) return;
// Build a rich prompt with all problem details
const problemSummary = problems.map(({ guest, issues }) => {
const type = guest.type === 'qemu' ? 'VM' : 'LXC';
return `- **${guest.name}** (${type} ${guest.vmid} on ${guest.node}): ${issues.join(', ')}`;
}).join('\n');
// Categorize issues for better AI understanding
const issueCategories = {
backup: problems.filter(p => p.issues.some(i => i.startsWith('Backup:'))).length,
cpu: problems.filter(p => p.issues.some(i => i.startsWith('CPU:'))).length,
memory: problems.filter(p => p.issues.some(i => i.startsWith('Memory:'))).length,
status: problems.filter(p => p.issues.some(i => i.startsWith('Status:'))).length,
};
const categoryBreakdown = Object.entries(issueCategories)
.filter(([, count]) => count > 0)
.map(([type, count]) => `${count} ${type}`)
.join(', ');
const prompt = `I have ${problems.length} guest${problems.length !== 1 ? 's' : ''} that need attention (${categoryBreakdown}):
${problemSummary}
Please help me:
1. **Prioritize** - Which issues should I address first?
2. **Investigate** - For the most critical issues, check their current status
3. **Remediate** - Suggest specific steps to resolve each issue
4. **Prevent** - Recommend any configuration changes to prevent these issues
Start with the most critical problems first.`;
// Open AI chat with this rich context
aiChatStore.openWithPrompt(prompt, {
context: {
problemCount: problems.length,
issueCategories,
guests: problems.map(p => ({
id: p.guest.id,
name: p.guest.name,
vmid: p.guest.vmid,
type: p.guest.type,
node: p.guest.node,
issues: p.issues,
})),
},
});
};
// Only show if problems mode is active AND there are results
const shouldShow = createMemo(() =>
props.isProblemsMode && props.problemGuests.length > 0
);
return (
<Show when={shouldShow()}>
<button
type="button"
onClick={handleClick}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg
bg-gradient-to-r from-purple-500 to-blue-500
hover:from-purple-600 hover:to-blue-600
text-white shadow-md shadow-purple-500/25
hover:shadow-lg hover:shadow-purple-500/30
transition-all duration-150 active:scale-95
ring-1 ring-purple-400/50"
title={`Ask AI to help investigate and resolve ${props.problemGuests.length} problem${props.problemGuests.length !== 1 ? 's' : ''}`}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span>Investigate {props.problemGuests.length} with AI</span>
<svg class="w-3 h-3 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</Show>
);
};
export default InvestigateProblemsButton;

View file

@ -1,7 +1,7 @@
import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { Sparkline } from '@/components/shared/Sparkline';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistory } from '@/stores/metricsHistory';
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
interface MetricBarProps {
value: number;
@ -19,7 +19,7 @@ const estimateTextWidth = (text: string): number => {
};
export function MetricBar(props: MetricBarProps) {
const { viewMode } = useMetricsViewMode();
const { viewMode, timeRange } = useMetricsViewMode();
const width = createMemo(() => Math.min(props.value, 100));
// Track container width
@ -86,9 +86,12 @@ export function MetricBar(props: MetricBarProps) {
});
// Get metric history for sparkline
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
const metricHistory = createMemo(() => {
// Subscribe to version changes so we re-read when new data is seeded
getMetricsVersion();
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistory(props.resourceId);
return getMetricHistoryForRange(props.resourceId, timeRange());
});
// Determine which metric type to use for sparkline
@ -102,9 +105,9 @@ export function MetricBar(props: MetricBarProps) {
<Show
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
// Original progress bar mode - capped width for better appearance on wide screens
// Progress bar mode - full width, flex centered like stacked bars
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div class={`relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded ${props.class || ''}`}>
<div class={`relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded ${props.class || ''}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
@ -120,19 +123,14 @@ export function MetricBar(props: MetricBarProps) {
</div>
}
>
{/* Sparkline mode */}
<div class="metric-text w-full h-6 flex items-center gap-1.5">
<div class="flex-1 min-w-0">
<Sparkline
data={metricHistory()}
metric={sparklineMetric()}
width={0}
height={24}
/>
</div>
<span class="text-[10px] font-medium text-gray-800 dark:text-gray-100 whitespace-nowrap flex-shrink-0 min-w-[35px]">
{props.label}
</span>
{/* Sparkline mode - full width, flex centered like stacked bars */}
<div class="metric-text w-full h-4 flex items-center justify-center min-w-0 overflow-hidden">
<Sparkline
data={metricHistory()}
metric={sparklineMetric()}
width={0}
height={16}
/>
</div>
</Show>
);

View file

@ -122,9 +122,14 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
});
});
// Check if we have any disk details to show
const hasDisks = createMemo(() => {
return props.disks && props.disks.length > 0;
});
// Generate tooltip content
const tooltipContent = createMemo(() => {
if (hasMultipleDisks() && props.disks) {
if (hasDisks() && props.disks) {
return props.disks.map((disk, idx) => {
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`;
@ -139,6 +144,17 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
};
});
}
// Fallback for aggregate disk
if (props.aggregateDisk && props.aggregateDisk.total > 0) {
const percent = (props.aggregateDisk.used / props.aggregateDisk.total) * 100;
return [{
label: 'Total',
used: formatBytes(props.aggregateDisk.used, 0),
total: formatBytes(props.aggregateDisk.total, 0),
percent: formatPercent(percent),
color: getUsageColor(percent),
}];
}
return [];
});
@ -162,7 +178,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
});
const handleMouseEnter = (e: MouseEvent) => {
if (hasMultipleDisks()) {
if (tooltipContent().length > 0) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
@ -176,7 +192,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@ -227,8 +243,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</span>
</div>
{/* Tooltip for multi-disk breakdown */}
<Show when={showTooltip() && hasMultipleDisks()}>
{/* Tooltip for disk breakdown */}
<Show when={showTooltip() && tooltipContent().length > 0}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
@ -240,13 +256,13 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Disk Breakdown
{hasMultipleDisks() ? 'Disk Breakdown' : 'Disk Usage'}
</div>
<For each={tooltipContent()}>
{(item, idx) => (
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
<span
class="truncate max-w-[80px]"
class="truncate max-w-[100px]"
style={{ color: item.color }}
>
{item.label}

View file

@ -1,6 +1,9 @@
import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { formatBytes, formatPercent } from '@/utils/format';
import { Sparkline } from '@/components/shared/Sparkline';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
interface StackedMemoryBarProps {
used: number;
@ -8,6 +11,7 @@ interface StackedMemoryBarProps {
swapUsed?: number;
swapTotal?: number;
balloon?: number;
resourceId?: string; // Required for sparkline mode to fetch history
}
// Colors for memory segments
@ -18,6 +22,17 @@ const MEMORY_COLORS = {
};
export function StackedMemoryBar(props: StackedMemoryBarProps) {
const { viewMode, timeRange } = useMetricsViewMode();
// Get metric history for sparkline
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
const metricHistory = createMemo(() => {
// Subscribe to version changes so we re-read when new data is seeded
getMetricsVersion();
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistoryForRange(props.resourceId, timeRange());
});
const [containerWidth, setContainerWidth] = createSignal(100);
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
@ -121,115 +136,129 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
};
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */}
<For each={segments()}>
{(segment, idx) => {
// Calculate left offset as sum of previous segments
const leftOffset = () => {
let offset = 0;
const segs = segments();
for (let i = 0; i < idx(); i++) {
offset += segs[i].percent;
}
return offset;
};
return (
<div
class="absolute top-0 h-full transition-all duration-300"
style={{
left: `${leftOffset()}%`,
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
);
}}
</For>
{/* Swap Indicator (Thin line at bottom if swap is used) */}
{/* Swap Indicator (Thin line at bottom if swap is used) */}
<Show when={hasSwap() && (props.swapUsed || 0) > 0}>
<Show
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${Math.min(swapPercent(), 100)}%` }}
/>
</Show>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</Show>
</span>
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Memory Composition
</div>
{/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */}
<For each={segments()}>
{(segment, idx) => {
// Calculate left offset as sum of previous segments
const leftOffset = () => {
let offset = 0;
const segs = segments();
for (let i = 0; i < idx(); i++) {
offset += segs[i].percent;
}
return offset;
};
return (
<div
class="absolute top-0 h-full transition-all duration-300"
style={{
left: `${leftOffset()}%`,
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
);
}}
</For>
{/* RAM Breakdown */}
<div class="flex justify-between gap-3 py-0.5">
<span class="text-green-400">Used</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.used, 0)}
</span>
</div>
{/* Swap Indicator (Thin line at bottom if swap is used) */}
<Show when={hasSwap() && (props.swapUsed || 0) > 0}>
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${Math.min(swapPercent(), 100)}%` }}
/>
</Show>
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-yellow-400">Balloon Limit</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.balloon || 0, 0)}
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</div>
</Show>
</Show>
</span>
</span>
</div>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used, 0)}
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Memory Composition
</div>
{/* Swap Section */}
<Show when={hasSwap()}>
<div class="mt-1 pt-1 border-t border-gray-600">
{/* RAM Breakdown */}
<div class="flex justify-between gap-3 py-0.5">
<span class="text-purple-400">Swap</span>
<span class="text-green-400">Used</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)}
{formatBytes(props.used, 0)}
</span>
</div>
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-yellow-400">Balloon Limit</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.balloon || 0, 0)}
</span>
</div>
</Show>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used, 0)}
</span>
</div>
{/* Swap Section */}
<Show when={hasSwap()}>
<div class="mt-1 pt-1 border-t border-gray-600">
<div class="flex justify-between gap-3 py-0.5">
<span class="text-purple-400">Swap</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)}
</span>
</div>
</div>
</Show>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
}
>
{/* Sparkline mode - full width, flex centered like stacked bars */}
<div class="metric-text w-full h-4 flex items-center justify-center min-w-0 overflow-hidden">
<Sparkline
data={metricHistory()}
metric="memory"
width={0}
height={16}
/>
</div>
</Show>
);
}

View file

@ -12,7 +12,8 @@ interface TagBadgesProps {
}
export const TagBadges: Component<TagBadgesProps> = (props) => {
const maxVisible = () => props.maxVisible ?? 3;
// maxVisible: 0 means show all, undefined defaults to 3
const maxVisible = () => props.maxVisible === 0 ? Infinity : (props.maxVisible ?? 3);
const darkModeSignal = useDarkMode();
const isDark = () => props.isDarkMode ?? darkModeSignal();

View file

@ -107,199 +107,199 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
return (
<Card class="docker-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search containers or host:hostname"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'container-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search containers by name, image, ID, or host"
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search containers or host:hostname"
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'container-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search containers by name, image, ID, or host"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="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' },
{ code: 'host:prod', description: 'Show containers on a host' },
{ code: 'state:running', description: 'Running containers only' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="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' },
{ code: 'host:prod', description: 'Show containers on a host' },
{ code: 'state:running', description: 'Running containers only' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
<Show when={props.statusFilter && props.setStatusFilter}>
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
@ -307,11 +307,10 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
type="button"
aria-pressed={props.statusFilter?.() === 'all'}
onClick={() => props.setStatusFilter?.('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all hosts"
>
All
@ -322,11 +321,10 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
onClick={() =>
props.setStatusFilter?.(props.statusFilter?.() === 'online' ? 'all' : 'online')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show online hosts only"
>
Online
@ -339,11 +337,10 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
props.statusFilter?.() === 'degraded' ? 'all' : 'degraded',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show degraded hosts only"
>
Degraded
@ -356,11 +353,10 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
props.statusFilter?.() === 'offline' ? 'all' : 'offline',
)
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter?.() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter?.() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show offline hosts only"
>
Offline
@ -373,22 +369,20 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupingMode?.('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode?.() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode?.() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Grouped
</button>
<button
type="button"
onClick={() => props.setGroupingMode?.('flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode?.() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode?.() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
List
</button>
@ -438,6 +432,6 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
</Show>
</div>
</div>
</Card>
</Card >
);
};

File diff suppressed because it is too large Load diff

View file

@ -48,7 +48,7 @@ export function StackedContainerBar(props: StackedContainerBarProps) {
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>

View file

@ -1,6 +1,8 @@
import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
@ -13,6 +15,11 @@ interface HostsFilterProps {
onReset?: () => void;
activeHostName?: string;
onClearHost?: () => void;
// Column visibility
availableColumns?: ColumnDef[];
isColumnHidden?: (id: string) => boolean;
onColumnToggle?: (id: string) => void;
onColumnReset?: () => void;
}
export const HostsFilter: Component<HostsFilterProps> = (props) => {
@ -102,210 +109,209 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
return (
<Card class="hosts-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search hosts by hostname, platform, or OS..."
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'hosts-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search hosts by hostname, platform, or OS"
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={(el) => {
searchInputEl = el;
props.searchInputRef?.(el);
}}
type="text"
placeholder="Search hosts by hostname, platform, or OS..."
value={props.search()}
onInput={(e) => props.setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'Escape') {
props.setSearch('');
closeHistory();
e.currentTarget.blur();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}}
onBlur={(e) => {
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
const interactingWithTips =
next?.getAttribute('aria-controls') === 'hosts-search-help';
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
}
}}
class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search hosts by hostname, platform, or OS"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="hosts-search-help"
intro="Filter hosts quickly"
tips={[
{ code: 'hostname', description: 'Match hosts by hostname' },
{ code: 'linux', description: 'Find Linux hosts' },
{ code: 'darwin', description: 'Find macOS hosts' },
{ code: 'windows', description: 'Find Windows hosts' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="hosts-search-help"
intro="Filter hosts quickly"
tips={[
{ code: 'hostname', description: 'Match hosts by hostname' },
{ code: 'linux', description: 'Find Linux hosts' },
{ code: 'darwin', description: 'Find macOS hosts' },
{ code: 'windows', description: 'Find Windows hosts' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (historyMenuRef = el)}
class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
<button
type="button"
aria-pressed={props.statusFilter() === 'all'}
onClick={() => props.setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show all hosts"
>
All
@ -316,11 +322,10 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'online' ? 'all' : 'online')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'online'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show online hosts only"
>
Online
@ -331,11 +336,10 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'degraded' ? 'all' : 'degraded')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show degraded hosts only"
>
Degraded
@ -346,19 +350,16 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
onClick={() =>
props.setStatusFilter(props.statusFilter() === 'offline' ? 'all' : 'offline')
}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusFilter() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Show offline hosts only"
>
Offline
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.activeHostName}>
<div class="flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
<span>Host: {props.activeHostName}</span>
@ -373,6 +374,17 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
</div>
</Show>
{/* Column Picker */}
<Show when={props.availableColumns && props.isColumnHidden && props.onColumnToggle}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<ColumnPicker
columns={props.availableColumns!}
isHidden={props.isColumnHidden!}
onToggle={props.onColumnToggle!}
onReset={props.onColumnReset}
/>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button
@ -399,6 +411,6 @@ export const HostsFilter: Component<HostsFilterProps> = (props) => {
</Show>
</div>
</div>
</Card>
</Card >
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,478 @@
/**
* Tests for RAID status display logic
*
* These tests cover the RAID status analysis and display logic used in HostsOverview.
*/
import { describe, expect, it } from 'vitest';
// Mock types matching HostRAIDArray from api.ts
interface HostRAIDDevice {
device: string;
state: string;
slot: number;
}
interface HostRAIDArray {
device: string;
name?: string;
level: string;
state: string;
totalDevices: number;
activeDevices: number;
workingDevices: number;
failedDevices: number;
spareDevices: number;
uuid?: string;
devices: HostRAIDDevice[];
rebuildPercent: number;
rebuildSpeed?: string;
}
// Status analysis logic matching HostRAIDStatusCell
type RAIDStatusType = 'none' | 'ok' | 'degraded' | 'rebuilding';
interface RAIDStatus {
type: RAIDStatusType;
label: string;
color: string;
}
function analyzeRAIDStatus(raid: HostRAIDArray[] | undefined): RAIDStatus {
if (!raid || raid.length === 0) {
return { type: 'none', label: '-', color: 'text-gray-400' };
}
let hasDegraded = false;
let hasRebuilding = false;
let maxRebuildPercent = 0;
for (const array of raid) {
const state = array.state.toLowerCase();
if (state.includes('degraded') || array.failedDevices > 0) {
hasDegraded = true;
}
if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) {
hasRebuilding = true;
maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent);
}
}
if (hasDegraded) {
return { type: 'degraded', label: 'Degraded', color: 'text-red-600 dark:text-red-400' };
}
if (hasRebuilding) {
return {
type: 'rebuilding',
label: `${Math.round(maxRebuildPercent)}%`,
color: 'text-amber-600 dark:text-amber-400'
};
}
return { type: 'ok', label: 'OK', color: 'text-green-600 dark:text-green-400' };
}
function getDeviceStateColor(state: string): string {
const s = state.toLowerCase();
if (s.includes('active') || s.includes('sync')) return 'text-green-400';
if (s.includes('spare')) return 'text-blue-400';
if (s.includes('faulty') || s.includes('removed')) return 'text-red-400';
if (s.includes('rebuilding')) return 'text-amber-400';
return 'text-gray-400';
}
function getArrayStateColor(array: HostRAIDArray): string {
const state = array.state.toLowerCase();
if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400';
if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400';
if (state.includes('clean') || state.includes('active')) return 'text-green-400';
return 'text-gray-400';
}
describe('RAID Status Analysis', () => {
describe('analyzeRAIDStatus', () => {
it('returns none status for undefined raid', () => {
const status = analyzeRAIDStatus(undefined);
expect(status.type).toBe('none');
expect(status.label).toBe('-');
});
it('returns none status for empty raid array', () => {
const status = analyzeRAIDStatus([]);
expect(status.type).toBe('none');
});
it('returns ok status for healthy RAID1', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, active',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
],
rebuildPercent: 0,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('ok');
expect(status.label).toBe('OK');
expect(status.color).toContain('green');
});
it('returns degraded status when array has failed devices', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, degraded',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'faulty', slot: 1 },
],
rebuildPercent: 0,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded');
expect(status.label).toBe('Degraded');
expect(status.color).toContain('red');
});
it('returns degraded status when state includes degraded', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid5',
state: 'degraded, recovering',
totalDevices: 3,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0, // No failed but degraded state
spareDevices: 0,
devices: [],
rebuildPercent: 50,
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded');
});
it('returns rebuilding status with percentage', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 45.5,
rebuildSpeed: '100MB/s',
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
expect(status.label).toBe('46%'); // Rounded
expect(status.color).toContain('amber');
});
it('returns max rebuild percentage when multiple arrays rebuilding', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 30,
},
{
device: '/dev/md1',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 75,
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
expect(status.label).toBe('75%');
});
it('prioritizes degraded over rebuilding', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'clean, degraded, recovering',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1, // Failed device
spareDevices: 0,
devices: [],
rebuildPercent: 50, // Also rebuilding
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('degraded'); // Degraded takes priority
});
it('handles resync state as rebuilding', () => {
const raid: HostRAIDArray[] = [{
device: '/dev/md0',
level: 'raid1',
state: 'clean, resyncing',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0, // resync state triggers rebuilding even without percent
}];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('rebuilding');
});
it('handles multiple healthy arrays', () => {
const raid: HostRAIDArray[] = [
{
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
},
{
device: '/dev/md1',
level: 'raid5',
state: 'active',
totalDevices: 3,
activeDevices: 3,
workingDevices: 3,
failedDevices: 0,
spareDevices: 1,
devices: [],
rebuildPercent: 0,
},
];
const status = analyzeRAIDStatus(raid);
expect(status.type).toBe('ok');
});
});
describe('getDeviceStateColor', () => {
it('returns green for active devices', () => {
expect(getDeviceStateColor('active sync')).toContain('green');
expect(getDeviceStateColor('Active Sync')).toContain('green');
});
it('returns blue for spare devices', () => {
expect(getDeviceStateColor('spare')).toContain('blue');
expect(getDeviceStateColor('hot spare')).toContain('blue');
});
it('returns red for faulty devices', () => {
expect(getDeviceStateColor('faulty')).toContain('red');
expect(getDeviceStateColor('removed')).toContain('red');
});
it('returns amber for rebuilding devices', () => {
expect(getDeviceStateColor('rebuilding')).toContain('amber');
});
it('returns gray for unknown states', () => {
expect(getDeviceStateColor('')).toContain('gray');
expect(getDeviceStateColor('unknown')).toContain('gray');
});
});
describe('getArrayStateColor', () => {
it('returns green for clean/active arrays', () => {
const cleanArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(cleanArray)).toContain('green');
const activeArray: HostRAIDArray = { ...cleanArray, state: 'active' };
expect(getArrayStateColor(activeArray)).toContain('green');
});
it('returns red for degraded arrays', () => {
const degradedArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'degraded',
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(degradedArray)).toContain('red');
});
it('returns red for arrays with failed devices even if state is clean', () => {
const failedDeviceArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean', // State says clean but...
totalDevices: 2,
activeDevices: 1,
workingDevices: 1,
failedDevices: 1, // Has a failed device
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
expect(getArrayStateColor(failedDeviceArray)).toContain('red');
});
it('returns amber for recovering/resyncing arrays', () => {
const recoveringArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'recovering',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 50,
};
expect(getArrayStateColor(recoveringArray)).toContain('amber');
const resyncArray: HostRAIDArray = { ...recoveringArray, state: 'resyncing' };
expect(getArrayStateColor(resyncArray)).toContain('amber');
});
it('returns amber for arrays with rebuild percent > 0', () => {
const rebuildingArray: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean', // State is clean but...
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 25, // Rebuilding
};
expect(getArrayStateColor(rebuildingArray)).toContain('amber');
});
});
});
describe('RAID Level Support', () => {
const raidLevels = ['raid0', 'raid1', 'raid5', 'raid6', 'raid10'];
it.each(raidLevels)('supports %s level', (level) => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: level,
state: 'clean',
totalDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
activeDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
workingDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3,
failedDevices: 0,
spareDevices: 0,
devices: [],
rebuildPercent: 0,
};
const status = analyzeRAIDStatus([array]);
expect(status.type).toBe('ok');
});
});
describe('RAID Device Counts', () => {
it('displays correct device counts for RAID1 with spares', () => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: 'raid1',
state: 'clean',
totalDevices: 3,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 1,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
{ device: 'sdc1', state: 'spare', slot: -1 },
],
rebuildPercent: 0,
};
expect(array.activeDevices).toBe(2);
expect(array.workingDevices).toBe(2);
expect(array.spareDevices).toBe(1);
expect(array.failedDevices).toBe(0);
expect(array.devices).toHaveLength(3);
});
it('displays degraded state for RAID5 missing one device', () => {
const array: HostRAIDArray = {
device: '/dev/md0',
level: 'raid5',
state: 'clean, degraded',
totalDevices: 4,
activeDevices: 3,
workingDevices: 3,
failedDevices: 1,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active sync', slot: 0 },
{ device: 'sdb1', state: 'active sync', slot: 1 },
{ device: 'sdc1', state: 'active sync', slot: 2 },
{ device: 'sdd1', state: 'removed', slot: 3 },
],
rebuildPercent: 0,
};
const status = analyzeRAIDStatus([array]);
expect(status.type).toBe('degraded');
expect(array.failedDevices).toBe(1);
});
});

View file

@ -0,0 +1,982 @@
import type { Component } from 'solid-js';
import { For, Show, createMemo, createSignal, createEffect } from 'solid-js';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility';
import type {
KubernetesCluster,
KubernetesDeployment,
KubernetesNode,
KubernetesPod,
} from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { StatusDot } from '@/components/shared/StatusDot';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { formatRelativeTime, formatBytes } from '@/utils/format';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator } from '@/utils/status';
interface KubernetesClustersProps {
clusters: KubernetesCluster[];
}
type ViewMode = 'clusters' | 'nodes' | 'pods' | 'deployments';
type StatusFilter = 'all' | 'healthy' | 'unhealthy';
const normalize = (value?: string | null): string => (value || '').trim().toLowerCase();
const getStatusIndicator = (status: string | undefined | null): StatusIndicator => {
const normalized = normalize(status);
if (!normalized) return { variant: 'muted', label: 'Unknown' };
if (OFFLINE_HEALTH_STATUSES.has(normalized)) return { variant: 'danger', label: 'Offline' };
if (DEGRADED_HEALTH_STATUSES.has(normalized)) return { variant: 'warning', label: 'Degraded' };
if (normalized === 'online') return { variant: 'success', label: 'Online' };
return { variant: 'muted', label: status ?? 'Unknown' };
};
const isPodHealthy = (pod: KubernetesPod): boolean => {
const phase = normalize(pod.phase);
if (!phase) return false;
if (phase !== 'running') return false;
const containers = pod.containers ?? [];
if (containers.length === 0) return true;
return containers.every((container) => {
if (!container.ready) return false;
const state = normalize(container.state);
if (!state) return true;
return state === 'running';
});
};
const isDeploymentHealthy = (d: KubernetesDeployment): boolean => {
const desired = d.desiredReplicas ?? 0;
if (desired <= 0) return true;
const available = d.availableReplicas ?? 0;
const ready = d.readyReplicas ?? 0;
const updated = d.updatedReplicas ?? 0;
return available >= desired && ready >= desired && updated >= desired;
};
const getClusterDisplayName = (cluster: KubernetesCluster): string => {
return cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id;
};
const summarizeNodes = (nodes: KubernetesNode[] | undefined) => {
const list = nodes ?? [];
const notReady = list.filter((n) => !n.ready).length;
const unschedulable = list.filter((n) => !!n.unschedulable).length;
return { total: list.length, notReady, unschedulable };
};
const summarizePods = (pods: KubernetesPod[] | undefined) => {
const list = pods ?? [];
const unhealthy = list.filter((p) => !isPodHealthy(p)).length;
return { total: list.length, unhealthy };
};
const summarizeDeployments = (deployments: KubernetesDeployment[] | undefined) => {
const list = deployments ?? [];
const unhealthy = list.filter((d) => !isDeploymentHealthy(d)).length;
return { total: list.length, unhealthy };
};
const getPodStatusBadge = (pod: KubernetesPod) => {
if (isPodHealthy(pod)) {
return { class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', label: 'Running' };
}
const phase = normalize(pod.phase);
if (phase === 'pending') {
return { class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', label: 'Pending' };
}
if (phase === 'failed') {
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'Failed' };
}
if (phase === 'succeeded') {
return { class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', label: 'Completed' };
}
// Check for CrashLoopBackOff or other container issues
const containers = pod.containers ?? [];
const crashingContainer = containers.find((c) => c.reason?.toLowerCase().includes('crash'));
if (crashingContainer) {
return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'CrashLoop' };
}
const waitingContainer = containers.find((c) => normalize(c.state) === 'waiting');
if (waitingContainer) {
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: waitingContainer.reason || 'Waiting' };
}
return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: pod.phase || 'Unknown' };
};
// Get primary container image (first container)
const getPrimaryImage = (pod: KubernetesPod): string => {
const containers = pod.containers ?? [];
if (containers.length === 0) return '—';
const image = containers[0].image ?? '';
// Truncate long image names, show just the image:tag part
const parts = image.split('/');
return parts[parts.length - 1] || image || '—';
};
// Format age from timestamp
const formatAge = (timestamp?: number | string | null): string => {
if (!timestamp) return '—';
const ts = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp;
if (isNaN(ts)) return '—';
return formatRelativeTime(ts);
};
// Column definitions for the pods table
const POD_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Pod', priority: 'essential', toggleable: false },
{ id: 'namespace', label: 'Namespace', priority: 'essential', toggleable: true },
{ id: 'cluster', label: 'Cluster', priority: 'primary', toggleable: true },
{ id: 'status', label: 'Status', priority: 'essential', toggleable: false },
{ id: 'ready', label: 'Ready', priority: 'primary', toggleable: true },
{ id: 'restarts', label: 'Restarts', priority: 'primary', toggleable: true },
{ id: 'image', label: 'Image', priority: 'secondary', toggleable: true },
{ id: 'age', label: 'Age', priority: 'primary', toggleable: true },
];
export const KubernetesClusters: Component<KubernetesClustersProps> = (props) => {
const [search, setSearch] = createSignal('');
const [viewMode, setViewMode] = createSignal<ViewMode>('clusters');
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
const [showHidden, setShowHidden] = createSignal(false);
const [namespaceFilter, setNamespaceFilter] = createSignal<string>('all');
// Column visibility for pods table
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
// Sorting state with persistence
type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas';
type SortDir = 'asc' | 'desc';
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('k8s-sort-key', 'name');
const [sortDirection, setSortDirection] = usePersistentSignal<SortDir>('k8s-sort-dir', 'asc');
const toggleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDirection('asc');
}
};
const sortIndicator = (key: SortKey) => sortKey() === key ? (sortDirection() === 'asc' ? ' ▲' : ' ▼') : '';
// Search input ref for keyboard focus
let searchInputRef: HTMLInputElement | undefined;
// Global keyboard handler - focus search on typing
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape clears search
if (e.key === 'Escape' && searchInputRef) {
setSearch('');
searchInputRef.blur();
return;
}
// Focus search on printable character (when not in input field)
if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
searchInputRef?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Get all unique namespaces for the filter dropdown
const allNamespaces = createMemo(() => {
const namespaces = new Set<string>();
for (const cluster of props.clusters ?? []) {
if (!showHidden() && cluster.hidden) continue;
for (const pod of cluster.pods ?? []) {
if (pod.namespace) namespaces.add(pod.namespace);
}
for (const dep of cluster.deployments ?? []) {
if (dep.namespace) namespaces.add(dep.namespace);
}
}
return Array.from(namespaces).sort();
});
// Get all nodes flattened across clusters
const allNodes = createMemo(() => {
const clusters = props.clusters ?? [];
const nodes: Array<{ cluster: KubernetesCluster; node: KubernetesNode }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const node of cluster.nodes ?? []) {
nodes.push({ cluster, node });
}
}
return nodes;
});
// Get all pods flattened across clusters
const allPods = createMemo(() => {
const clusters = props.clusters ?? [];
const pods: Array<{ cluster: KubernetesCluster; pod: KubernetesPod }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const pod of cluster.pods ?? []) {
pods.push({ cluster, pod });
}
}
return pods;
});
// Get all deployments flattened across clusters
const allDeployments = createMemo(() => {
const clusters = props.clusters ?? [];
const deps: Array<{ cluster: KubernetesCluster; deployment: KubernetesDeployment }> = [];
for (const cluster of clusters) {
if (!showHidden() && cluster.hidden) continue;
for (const dep of cluster.deployments ?? []) {
deps.push({ cluster, deployment: dep });
}
}
return deps;
});
const visibleClusters = createMemo(() => {
const term = search().trim().toLowerCase();
const clusters = props.clusters ?? [];
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = clusters
.filter((cluster) => showHidden() || !cluster.hidden)
.filter((cluster) => {
if (status === 'all') return true;
const clusterStatus = normalize(cluster.status);
const isHealthy = clusterStatus === 'online';
if (status === 'healthy') return isHealthy;
if (status === 'unhealthy') return !isHealthy;
return true;
})
.filter((cluster) => {
if (!term) return true;
const haystack = [
getClusterDisplayName(cluster),
cluster.id,
cluster.server ?? '',
cluster.context ?? '',
cluster.version ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort clusters
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); break;
case 'status': cmp = (normalize(a.status) === 'online' ? 0 : 1) - (normalize(b.status) === 'online' ? 0 : 1); break;
default: cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b));
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredNodes = createMemo(() => {
const term = search().trim().toLowerCase();
const status = statusFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allNodes()
.filter(({ node }) => {
if (status === 'all') return true;
const isHealthy = node.ready && !node.unschedulable;
if (status === 'healthy') return isHealthy;
if (status === 'unhealthy') return !isHealthy;
return true;
})
.filter(({ cluster, node }) => {
if (!term) return true;
const haystack = [
node.name,
getClusterDisplayName(cluster),
node.kubeletVersion ?? '',
node.osImage ?? '',
...(node.roles ?? []),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort nodes
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'status': cmp = (a.node.ready ? 0 : 1) - (b.node.ready ? 0 : 1); break;
default: cmp = (a.node.name ?? '').localeCompare(b.node.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredPods = createMemo(() => {
const term = search().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allPods()
.filter(({ pod }) => {
if (ns !== 'all' && pod.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isPodHealthy(pod);
if (status === 'healthy') return healthy;
if (status === 'unhealthy') return !healthy;
return true;
})
.filter(({ cluster, pod }) => {
if (!term) return true;
const haystack = [
pod.name,
pod.namespace,
pod.nodeName ?? '',
pod.phase ?? '',
getClusterDisplayName(cluster),
...(pod.containers ?? []).map(c => c.image ?? ''),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); break;
case 'namespace': cmp = (a.pod.namespace ?? '').localeCompare(b.pod.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'restarts': cmp = (a.pod.restarts ?? 0) - (b.pod.restarts ?? 0); break;
case 'age': cmp = (a.pod.createdAt ?? 0) - (b.pod.createdAt ?? 0); break;
case 'status': cmp = (isPodHealthy(a.pod) ? 0 : 1) - (isPodHealthy(b.pod) ? 0 : 1); break;
default: cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const filteredDeployments = createMemo(() => {
const term = search().trim().toLowerCase();
const status = statusFilter();
const ns = namespaceFilter();
const key = sortKey();
const dir = sortDirection();
const filtered = allDeployments()
.filter(({ deployment }) => {
if (ns !== 'all' && deployment.namespace !== ns) return false;
if (status === 'all') return true;
const healthy = isDeploymentHealthy(deployment);
if (status === 'healthy') return healthy;
if (status === 'unhealthy') return !healthy;
return true;
})
.filter(({ cluster, deployment }) => {
if (!term) return true;
const haystack = [
deployment.name,
deployment.namespace,
getClusterDisplayName(cluster),
]
.join(' ')
.toLowerCase();
return haystack.includes(term);
});
// Sort
return filtered.sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); break;
case 'namespace': cmp = (a.deployment.namespace ?? '').localeCompare(b.deployment.namespace ?? ''); break;
case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break;
case 'replicas': cmp = (a.deployment.desiredReplicas ?? 0) - (b.deployment.desiredReplicas ?? 0); break;
case 'ready': cmp = (a.deployment.readyReplicas ?? 0) - (b.deployment.readyReplicas ?? 0); break;
case 'status': cmp = (isDeploymentHealthy(a.deployment) ? 0 : 1) - (isDeploymentHealthy(b.deployment) ? 0 : 1); break;
default: cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? '');
}
return dir === 'desc' ? -cmp : cmp;
});
});
const isEmpty = createMemo(() => (props.clusters?.length ?? 0) === 0);
const hasActiveFilters = createMemo(
() => search().trim() !== '' || statusFilter() !== 'all' || showHidden() || namespaceFilter() !== 'all',
);
const handleReset = () => {
setSearch('');
setStatusFilter('all');
setShowHidden(false);
setNamespaceFilter('all');
setViewMode('clusters');
};
return (
<div class="space-y-4">
{/* Filter Bar */}
<Card padding="sm">
<div class="flex flex-col gap-3">
{/* Search - full width on its own row */}
<div class="relative">
<input
ref={searchInputRef}
type="text"
placeholder="Search clusters, nodes, pods..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearch('');
e.currentTarget.blur();
}
}}
class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={search()}>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => setSearch('')}
aria-label="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
{/* Filters - second row */}
<div class="flex flex-wrap items-center gap-2">
{/* View Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('clusters')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'clusters'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Clusters
</button>
<button
type="button"
onClick={() => setViewMode('nodes')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'nodes'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Nodes
</button>
<button
type="button"
onClick={() => setViewMode('pods')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'pods'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Pods
</button>
<button
type="button"
onClick={() => setViewMode('deployments')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'deployments'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Deployments
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => setStatusFilter(statusFilter() === 'healthy' ? 'all' : 'healthy')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'healthy'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-200 dark:ring-green-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'healthy' ? 'bg-green-500' : 'bg-green-400/60'}`} />
Healthy
</button>
<button
type="button"
onClick={() => setStatusFilter(statusFilter() === 'unhealthy' ? 'all' : 'unhealthy')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${statusFilter() === 'unhealthy'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-200 dark:ring-amber-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
<span class={`w-2 h-2 rounded-full ${statusFilter() === 'unhealthy' ? 'bg-amber-500' : 'bg-amber-400/60'}`} />
Unhealthy
</button>
</div>
{/* Namespace Filter - only show for pods/deployments */}
<Show when={(viewMode() === 'pods' || viewMode() === 'deployments') && allNamespaces().length > 1}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<select
value={namespaceFilter()}
onChange={(e) => setNamespaceFilter(e.currentTarget.value)}
class="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value="all">All namespaces</option>
<For each={allNamespaces()}>
{(ns) => <option value={ns}>{ns}</option>}
</For>
</select>
</Show>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
{/* Show Hidden Toggle */}
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={showHidden()}
onChange={(e) => setShowHidden(e.currentTarget.checked)}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500/20"
/>
Show hidden
</label>
{/* Column Picker - only show for pods view */}
<Show when={viewMode() === 'pods'}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<ColumnPicker
columns={podColumns.availableToggles()}
isHidden={podColumns.isHiddenByUser}
onToggle={podColumns.toggle}
onReset={podColumns.resetToDefaults}
/>
</Show>
{/* Reset Button */}
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" />
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card>
{/* Main Table */}
<Card padding="none" class="overflow-hidden">
<Show
when={!isEmpty()}
fallback={
<div class="p-10">
<EmptyState
title="No Kubernetes clusters reporting yet"
description="Enable Kubernetes monitoring on a unified agent to start reporting cluster health."
/>
</div>
}
>
{/* Clusters View */}
<Show when={viewMode() === 'clusters'}>
<ScrollableTable minWidth="900px" persistKey="kubernetes-clusters-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Cluster{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nodes</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deployments</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">Last Seen</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={visibleClusters()} fallback={
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No clusters match the current filters.</td></tr>
}>
{(cluster) => {
const indicator = () => getStatusIndicator(cluster.status);
const nodes = () => summarizeNodes(cluster.nodes);
const pods = () => summarizePods(cluster.pods);
const deployments = () => summarizeDeployments(cluster.deployments);
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="flex items-center gap-2">
<span class="font-medium">{getClusterDisplayName(cluster)}</span>
<Show when={cluster.pendingUninstall}>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
Pending uninstall
</span>
</Show>
<Show when={cluster.hidden}>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
Hidden
</span>
</Show>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs font-mono">
{cluster.server || '—'}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<div class="flex items-center gap-2">
<StatusDot variant={indicator().variant} size="sm" />
<span>{indicator().label}</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={nodes().notReady > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{nodes().total - nodes().notReady}</span>
<span class="text-gray-400">/{nodes().total}</span>
<Show when={nodes().notReady > 0}>
<span class="ml-1 text-xs text-gray-400">ready</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={pods().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{pods().total - pods().unhealthy}</span>
<span class="text-gray-400">/{pods().total}</span>
<Show when={pods().unhealthy > 0}>
<span class="ml-1 text-xs text-gray-400">healthy</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class={deployments().unhealthy > 0 ? 'text-amber-600 dark:text-amber-400' : ''}>{deployments().total - deployments().unhealthy}</span>
<span class="text-gray-400">/{deployments().total}</span>
<Show when={deployments().unhealthy > 0}>
<span class="ml-1 text-xs text-gray-400">ok</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{cluster.version || '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Nodes View */}
<Show when={viewMode() === 'nodes'}>
<ScrollableTable minWidth="1000px" persistKey="kubernetes-nodes-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Node{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Roles</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CPU</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Memory</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Pods</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>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredNodes()} fallback={
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No nodes match the current filters.</td></tr>
}>
{({ cluster, node }) => {
const isHealthy = () => node.ready && !node.unschedulable;
const roles = () => (node.roles ?? []).join(', ') || 'worker';
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium">{node.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">
{node.osImage || '—'}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
<td class="px-4 py-3 text-sm">
<Show when={isHealthy()} fallback={
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
{!node.ready ? 'NotReady' : 'Unschedulable'}
</span>
}>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
Ready
</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">
{roles()}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatableCpuCores ?? node.capacityCpuCores ?? '—'} cores
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatableMemoryBytes ? formatBytes(node.allocatableMemoryBytes) : node.capacityMemoryBytes ? formatBytes(node.capacityMemoryBytes) : '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.allocatablePods ?? node.capacityPods ?? '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono">
{node.kubeletVersion || '—'}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Pods View */}
<Show when={viewMode() === 'pods'}>
<ScrollableTable minWidth="800px" persistKey="kubernetes-pods-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Pod{sortIndicator('name')}</th>
<Show when={podColumns.isColumnVisible('namespace')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
</Show>
<Show when={podColumns.isColumnVisible('cluster')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
</Show>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<Show when={podColumns.isColumnVisible('ready')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('ready')}</th>
</Show>
<Show when={podColumns.isColumnVisible('restarts')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('restarts')}>Restarts{sortIndicator('restarts')}</th>
</Show>
<Show when={podColumns.isColumnVisible('image')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Image</th>
</Show>
<Show when={podColumns.isColumnVisible('age')}>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('age')}>Age{sortIndicator('age')}</th>
</Show>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredPods()} fallback={
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No pods match the current filters.</td></tr>
}>
{({ cluster, pod }) => {
const statusBadge = () => getPodStatusBadge(pod);
const containers = () => pod.containers ?? [];
const readyContainers = () => containers().filter(c => c.ready).length;
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium truncate max-w-[200px]" title={pod.name}>{pod.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{pod.nodeName || 'unscheduled'}
</div>
</td>
<Show when={podColumns.isColumnVisible('namespace')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
{pod.namespace}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('cluster')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
</Show>
<td class="px-4 py-3 text-sm">
<span class={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge().class}`}>
{statusBadge().label}
</span>
</td>
<Show when={podColumns.isColumnVisible('ready')}>
<td class="px-4 py-3 text-sm">
<span class={readyContainers() === containers().length ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{readyContainers()}/{containers().length}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('restarts')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<Show when={(pod.restarts ?? 0) > 0} fallback={<span class="text-gray-400">0</span>}>
<span class="text-amber-600 dark:text-amber-400 font-medium">{pod.restarts}</span>
</Show>
</td>
</Show>
<Show when={podColumns.isColumnVisible('image')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="font-mono text-xs truncate max-w-[150px] block" title={(pod.containers ?? [])[0]?.image}>
{getPrimaryImage(pod)}
</span>
</td>
</Show>
<Show when={podColumns.isColumnVisible('age')}>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatAge(pod.createdAt)}
</td>
</Show>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
{/* Deployments View */}
<Show when={viewMode() === 'deployments'}>
<ScrollableTable minWidth="800px" persistKey="kubernetes-deployments-table">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('name')}>Deployment{sortIndicator('name')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('namespace')}>Namespace{sortIndicator('namespace')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('cluster')}>Cluster{sortIndicator('cluster')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('status')}>Status{sortIndicator('status')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('replicas')}>Replicas{sortIndicator('replicas')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" onClick={() => toggleSort('ready')}>Ready{sortIndicator('ready')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Up-to-date</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredDeployments()} fallback={
<tr><td colSpan={7} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No deployments match the current filters.</td></tr>
}>
{({ cluster, deployment }) => {
const healthy = () => isDeploymentHealthy(deployment);
const desired = () => deployment.desiredReplicas ?? 0;
const ready = () => deployment.readyReplicas ?? 0;
const updated = () => deployment.updatedReplicas ?? 0;
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/20">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div class="font-medium">{deployment.name}</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<span class="px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs font-mono">
{deployment.namespace}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{getClusterDisplayName(cluster)}
</td>
<td class="px-4 py-3 text-sm">
<Show when={healthy()} fallback={
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500" />
Progressing
</span>
}>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<span class="w-1.5 h-1.5 rounded-full bg-green-500" />
Available
</span>
</Show>
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-medium">
{desired()}
</td>
<td class="px-4 py-3 text-sm">
<span class={ready() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{ready()}/{desired()}
</span>
</td>
<td class="px-4 py-3 text-sm">
<span class={updated() >= desired() ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}>
{updated()}/{desired()}
</span>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Show>
</Show>
</Card>
</div>
);
};

View file

@ -2,12 +2,13 @@ import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js
import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage';
const FirstRunSetup = lazy(() =>
import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })),
const SetupWizard = lazy(() =>
import('./SetupWizard').then((m) => ({ default: m.SetupWizard })),
);
interface LoginProps {
onLogin: () => void;
hasAuth?: boolean; // If true, auth is configured (passed from App.tsx to skip redundant check)
}
interface SecurityStatus {
@ -30,7 +31,8 @@ export const Login: Component<LoginProps> = (props) => {
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [authStatus, setAuthStatus] = createSignal<SecurityStatus | null>(null);
const [loadingAuth, setLoadingAuth] = createSignal(true);
// If hasAuth is passed from App.tsx, we already know auth status - skip the loading state
const [loadingAuth, setLoadingAuth] = createSignal(props.hasAuth === undefined);
const [oidcLoading, setOidcLoading] = createSignal(false);
const [oidcError, setOidcError] = createSignal('');
const [oidcMessage, setOidcMessage] = createSignal('');
@ -96,6 +98,15 @@ export const Login: Component<LoginProps> = (props) => {
window.history.replaceState({}, document.title, newUrl);
}
// If hasAuth was passed from App.tsx, use it directly without making another API call
// This eliminates the flicker between "Checking authentication..." and the login form
if (props.hasAuth !== undefined) {
logger.debug('[Login] Using hasAuth from App.tsx, skipping redundant auth check');
setAuthStatus({ hasAuthentication: props.hasAuth });
setLoadingAuth(false);
return;
}
logger.debug('[Login] Starting auth check...');
try {
const response = await fetch('/api/security/status');
@ -328,9 +339,8 @@ export const Login: Component<LoginProps> = (props) => {
</div>
}
>
<FirstRunSetup
force={legacyDisableAuth()}
showLegacyBanner={legacyDisableAuth()}
<SetupWizard
onComplete={() => window.location.reload()}
/>
</Suspense>
</Show>

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ import { createMemo, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App';
type ProxmoxSection = 'overview' | 'storage' | 'replication' | 'backups' | 'mail';
type ProxmoxSection = 'overview' | 'storage' | 'ceph' | 'replication' | 'backups' | 'mail';
interface ProxmoxSectionNavProps {
current: ProxmoxSection;
@ -15,41 +15,55 @@ const allSections: Array<{
label: string;
path: string;
}> = [
{
id: 'overview',
label: 'Overview',
path: '/proxmox/overview',
},
{
id: 'storage',
label: 'Storage',
path: '/proxmox/storage',
},
{
id: 'replication',
label: 'Replication',
path: '/proxmox/replication',
},
{
id: 'mail',
label: 'Mail Gateway',
path: '/proxmox/mail',
},
{
id: 'backups',
label: 'Backups',
path: '/proxmox/backups',
},
];
{
id: 'overview',
label: 'Overview',
path: '/proxmox/overview',
},
{
id: 'storage',
label: 'Storage',
path: '/proxmox/storage',
},
{
id: 'ceph',
label: 'Ceph',
path: '/proxmox/ceph',
},
{
id: 'replication',
label: 'Replication',
path: '/proxmox/replication',
},
{
id: 'mail',
label: 'Mail Gateway',
path: '/proxmox/mail',
},
{
id: 'backups',
label: 'Backups',
path: '/proxmox/backups',
},
];
export const ProxmoxSectionNav: Component<ProxmoxSectionNavProps> = (props) => {
const navigate = useNavigate();
const { state } = useWebSocket();
// Only show Mail Gateway tab if PMG instances are configured
// Only show tabs if the corresponding feature has data:
// - Mail Gateway: requires PMG instances
// - Ceph: requires Ceph clusters (from agent or Proxmox API)
// - Replication: requires replication jobs
const sections = createMemo(() => {
const hasPMG = state.pmg && state.pmg.length > 0;
return allSections.filter((section) => section.id !== 'mail' || hasPMG);
const hasCeph = state.cephClusters && state.cephClusters.length > 0;
const hasReplication = state.replicationJobs && state.replicationJobs.length > 0;
return allSections.filter((section) =>
(section.id !== 'mail' || hasPMG) &&
(section.id !== 'ceph' || hasCeph) &&
(section.id !== 'replication' || hasReplication)
);
});
const baseClasses =

View file

@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Show, For, createMemo } from 'solid-js';
import { Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
@ -7,33 +8,139 @@ import { EmptyState } from '@/components/shared/EmptyState';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { StatusDot } from '@/components/shared/StatusDot';
import { getReplicationJobStatusIndicator } from '@/utils/status';
import type { ReplicationJob } from '@/types/api';
type StatusFilter = 'all' | 'healthy' | 'warning' | 'error';
function formatDuration(durationSeconds?: number, durationHuman?: string): string {
if (durationHuman && durationHuman.trim()) return durationHuman;
if (!durationSeconds || durationSeconds <= 0) return '';
const hours = Math.floor(durationSeconds / 3600)
.toString()
.padStart(2, '0');
const minutes = Math.floor((durationSeconds % 3600) / 60)
.toString()
.padStart(2, '0');
const seconds = Math.floor(durationSeconds % 60)
.toString()
.padStart(2, '0');
const hours = Math.floor(durationSeconds / 3600).toString().padStart(2, '0');
const minutes = Math.floor((durationSeconds % 3600) / 60).toString().padStart(2, '0');
const seconds = Math.floor(durationSeconds % 60).toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
function formatRate(limit?: number): string {
if (!limit || limit <= 0) return '—';
return `${limit.toFixed(0)} MB/s`;
function coerceTimestamp(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function normalizeTimestampMs(value: number): number {
return value > 1e12 ? value : value * 1000;
}
// Countdown timer for next sync
function getTimeUntil(timestamp?: number): { text: string; isOverdue: boolean; isImminent: boolean } {
if (!timestamp) return { text: '—', isOverdue: false, isImminent: false };
const now = Date.now();
// Handle both Unix timestamp (seconds) and JS timestamp (milliseconds)
const target = timestamp > 1e12 ? timestamp : timestamp * 1000;
const diff = target - now;
if (diff < 0) {
// Overdue
const overdueMinutes = Math.abs(Math.floor(diff / 60000));
if (overdueMinutes < 60) {
return { text: `${overdueMinutes}m overdue`, isOverdue: true, isImminent: false };
}
const overdueHours = Math.floor(overdueMinutes / 60);
return { text: `${overdueHours}h overdue`, isOverdue: true, isImminent: false };
}
const minutes = Math.floor(diff / 60000);
if (minutes < 60) {
return { text: `in ${minutes}m`, isOverdue: false, isImminent: minutes < 5 };
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return { text: `in ${hours}h ${remainingMinutes}m`, isOverdue: false, isImminent: false };
}
// Error tooltip component
const ErrorTooltip: Component<{ error: string }> = (props) => {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
const handleMouseEnter = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
};
return (
<>
<div
class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-1 cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
{props.error}
</div>
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-md shadow-lg px-3 py-2 max-w-[400px] border border-gray-700">
<div class="font-medium text-red-400 mb-1">Error Details</div>
<div class="text-gray-300 whitespace-pre-wrap">{props.error}</div>
</div>
</div>
</Portal>
</Show>
</>
);
};
const Replication: Component = () => {
const { state, connected, reconnecting, reconnect } = useWebSocket();
const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket();
const [searchTerm, setSearchTerm] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
let searchInputRef: HTMLInputElement | undefined;
// Keyboard handler for type-to-search
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isInputField =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
if (e.key === 'Escape') {
if (searchTerm().trim() || statusFilter() !== 'all') {
setSearchTerm('');
setStatusFilter('all');
searchInputRef?.blur();
}
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (searchInputRef) {
searchInputRef.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
const replicationJobs = createMemo(() => {
const jobs = state.replicationJobs ?? [];
@ -46,11 +153,93 @@ const Replication: Component = () => {
});
});
// Get job status category for filtering
const getJobStatusCategory = (job: ReplicationJob): StatusFilter => {
const indicator = getReplicationJobStatusIndicator(job);
if (indicator.variant === 'success') return 'healthy';
if (indicator.variant === 'warning') return 'warning';
if (indicator.variant === 'danger') return 'error';
return 'healthy';
};
// Filtered jobs based on search and status
const filteredJobs = createMemo(() => {
let jobs = replicationJobs();
// Apply status filter
if (statusFilter() !== 'all') {
jobs = jobs.filter(job => getJobStatusCategory(job) === statusFilter());
}
// Apply search
const term = searchTerm().toLowerCase().trim();
if (term) {
jobs = jobs.filter(job =>
(job.guestName || '').toLowerCase().includes(term) ||
(job.jobId || '').toLowerCase().includes(term) ||
(job.sourceNode || '').toLowerCase().includes(term) ||
(job.targetNode || '').toLowerCase().includes(term) ||
(job.instance || '').toLowerCase().includes(term) ||
String(job.guestId || '').includes(term)
);
}
return jobs;
});
// Summary stats
const stats = createMemo(() => {
const jobs = replicationJobs();
let healthy = 0;
let warning = 0;
let error = 0;
let nextSync: { job: ReplicationJob | null; time: number | undefined } = { job: null, time: undefined };
for (const job of jobs) {
const category = getJobStatusCategory(job);
if (category === 'healthy') healthy++;
else if (category === 'warning') warning++;
else error++;
// Track the soonest next sync
const nextSyncRaw = coerceTimestamp(job.nextSyncTime ?? job.nextSyncUnix);
if (typeof nextSyncRaw === 'number') {
const jobSyncMs = normalizeTimestampMs(nextSyncRaw);
const currentSyncMs =
typeof nextSync.time === 'number' ? normalizeTimestampMs(nextSync.time) : Infinity;
if (jobSyncMs < currentSyncMs) nextSync = { job, time: nextSyncRaw };
}
}
return { total: jobs.length, healthy, warning, error, nextSync };
});
const isLoading = createMemo(() => connected() && !initialDataReceived());
const thClass = "px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400";
return (
<div class="space-y-3">
<div class="space-y-4">
<ProxmoxSectionNav current="replication" />
<Show when={connected()} fallback={
{/* Loading State */}
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
}
title="Loading replication data..."
description="Connecting to the monitoring service."
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected() && !isLoading()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
@ -59,11 +248,7 @@ const Replication: Component = () => {
</svg>
}
title="Connection lost"
description={
reconnecting()
? 'Attempting to reconnect…'
: 'Unable to connect to the backend server'
}
description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'}
tone="danger"
actions={
!reconnecting() ? (
@ -77,126 +262,343 @@ const Replication: Component = () => {
}
/>
</Card>
}>
<Show
when={replicationJobs().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 4h16M4 10h16M4 16h16" />
</Show>
<Show when={connected() && initialDataReceived()}>
{/* No Jobs Empty State */}
<Show when={replicationJobs().length === 0}>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
}
title="No replication jobs detected"
description="Replication jobs will appear here once configured in Proxmox. Replication keeps your VMs synchronized across nodes for high availability."
/>
</Card>
</Show>
{/* Has Jobs - Show Content */}
<Show when={replicationJobs().length > 0}>
{/* Summary Cards */}
<div class="grid gap-3 grid-cols-2 lg:grid-cols-4">
{/* Total Jobs */}
<Card padding="sm" tone="glass">
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Jobs</div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{stats().total}
</div>
</div>
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
}
title="No replication jobs detected"
description="Replication jobs will appear here once configured in Proxmox."
/>
</div>
</div>
</Card>
}
>
<Card padding="none" tone="glass">
<div class="overflow-x-auto">
<table class="min-w-[1000px] w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
<th class="px-4 py-3">Guest</th>
<th class="px-4 py-3">Job</th>
<th class="px-4 py-3">Source Target</th>
<th class="px-4 py-3">Last Sync</th>
<th class="px-4 py-3">Next Sync</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Failures</th>
<th class="px-4 py-3">Rate</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 text-sm text-gray-700 dark:text-gray-200">
<For each={replicationJobs()}>
{(job) => {
const indicator = getReplicationJobStatusIndicator(job);
{/* Healthy */}
<Card padding="sm" tone="glass">
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Healthy</div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">
{stats().healthy}
</div>
</div>
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</Card>
{/* Warning/Error Combined */}
<Card padding="sm" tone={stats().error > 0 ? 'danger' : stats().warning > 0 ? 'warning' : 'glass'}>
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Issues</div>
<div class={`text-2xl font-bold mt-1 ${stats().error > 0
? 'text-red-600 dark:text-red-400'
: stats().warning > 0
? 'text-yellow-600 dark:text-yellow-400'
: 'text-gray-400'
}`}>
{stats().error + stats().warning}
</div>
</div>
<div class={`p-2 rounded-lg ${stats().error > 0
? 'bg-red-100 dark:bg-red-900/30'
: stats().warning > 0
? 'bg-yellow-100 dark:bg-yellow-900/30'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<Show when={stats().error > 0 || stats().warning > 0} fallback={
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}>
<svg class={`w-5 h-5 ${stats().error > 0 ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</Show>
</div>
</div>
</Card>
{/* Next Sync */}
<Card padding="sm" tone="glass">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Next Sync</div>
<Show when={stats().nextSync.job} fallback={
<div class="text-sm text-gray-400 mt-1"></div>
}>
{(() => {
const timeInfo = getTimeUntil(stats().nextSync.time);
return (
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{job.guestName || `VM ${job.guestId ?? ''}`}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.instance} · ID {job.guestId ?? '—'} · {job.guestNode || job.sourceNode || 'Unknown node'}
</div>
</td>
<td class="px-4 py-3">
<div class="font-medium">{job.jobId || '—'}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.type ? `${job.type.toUpperCase()} · ` : ''}{job.schedule || '*/15'}
</div>
</td>
<td class="px-4 py-3">
<div class="text-sm">
{job.sourceNode || '—'}
<span class="mx-1 text-gray-400"></span>
{job.targetNode || '—'}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.sourceStorage || 'local'} {job.targetStorage || 'remote'}
</div>
</td>
<td class="px-4 py-3">
<Show when={job.lastSyncTime}>
<div class="font-medium">{formatAbsoluteTime(job.lastSyncTime!)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatRelativeTime(job.lastSyncTime!)}
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
<span class="mx-1">·</span>
{formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)}
</Show>
</div>
</Show>
<Show when={!job.lastSyncTime}>
<span class="text-gray-400">Never</span>
</Show>
</td>
<td class="px-4 py-3">
<Show when={job.nextSyncTime}>
<div class="font-medium">{formatAbsoluteTime(job.nextSyncTime!)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatRelativeTime(job.nextSyncTime!)}
</div>
</Show>
<Show when={!job.nextSyncTime}>
<span class="text-gray-400"></span>
</Show>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<StatusDot
variant={indicator.variant}
title={indicator.label}
ariaLabel={indicator.label}
size="sm"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{indicator.label}
</span>
</div>
<Show when={job.error}>
<div class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-2">
{job.error}
</div>
</Show>
</td>
<td class="px-4 py-3">
<span class="text-sm font-medium">{job.failCount ?? 0}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm">{formatRate(job.rateLimitMbps)}</span>
</td>
</tr>
<>
<div class={`text-lg font-bold mt-1 ${timeInfo.isOverdue
? 'text-red-600 dark:text-red-400'
: timeInfo.isImminent
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}>
{timeInfo.text}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{stats().nextSync.job?.guestName || `VM ${stats().nextSync.job?.guestId}`}
</div>
</>
);
}}
</For>
</tbody>
</table>
})()}
</Show>
</div>
<div class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</Card>
</div>
{/* Filter Bar */}
<Card padding="sm" tone="glass">
<div class="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
{/* Search */}
<div class="relative flex-1 max-w-md">
<input
ref={(el) => (searchInputRef = el)}
type="text"
placeholder="Search by guest, job, or node..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg class="absolute left-3 top-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<Show when={searchTerm()}>
<button
onClick={() => setSearchTerm('')}
class="absolute right-2.5 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
{/* Status Filter Buttons */}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500 dark:text-gray-400 mr-1">Status:</span>
<button
onClick={() => setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'all'
? 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('healthy')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'healthy'
? 'bg-green-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
Healthy
</button>
<button
onClick={() => setStatusFilter('warning')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'warning'
? 'bg-yellow-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
Warning
</button>
<button
onClick={() => setStatusFilter('error')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'error'
? 'bg-red-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
Error
</button>
</div>
</div>
</Card>
{/* Jobs Table */}
<Card padding="none" tone="glass" class="overflow-hidden">
<Show
when={filteredJobs().length > 0}
fallback={
<div class="p-8 text-center">
<svg class="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<div class="text-gray-500 dark:text-gray-400 text-sm">
No jobs match your search
</div>
<button
onClick={() => { setSearchTerm(''); setStatusFilter('all'); }}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filters
</button>
</div>
}
>
<div class="overflow-x-auto">
<table class="w-full min-w-[900px]">
<thead class="bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700">
<tr>
<th class={`${thClass} pl-4`}>Guest</th>
<th class={thClass}>Job</th>
<th class={thClass}>Source Target</th>
<th class={thClass}>Last Sync</th>
<th class={thClass}>Next Sync</th>
<th class={thClass}>Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50">
<For each={filteredJobs()}>
{(job) => {
const indicator = getReplicationJobStatusIndicator(job);
const nextSyncInfo = getTimeUntil(job.nextSyncTime);
const statusCategory = getJobStatusCategory(job);
return (
<tr class={`transition-colors ${statusCategory === 'error'
? 'bg-red-50/50 dark:bg-red-900/10 hover:bg-red-50 dark:hover:bg-red-900/20'
: statusCategory === 'warning'
? 'bg-yellow-50/30 dark:bg-yellow-900/10 hover:bg-yellow-50/50 dark:hover:bg-yellow-900/20'
: 'hover:bg-gray-50/80 dark:hover:bg-gray-800/50'
}`}>
<td class="px-3 py-3 pl-4">
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
{job.guestName || `VM ${job.guestId ?? ''}`}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 font-mono">
{job.instance}
</span>
<span>· ID {job.guestId ?? '—'}</span>
</div>
</td>
<td class="px-3 py-3">
<div class="font-mono text-sm text-gray-700 dark:text-gray-300">{job.jobId || '—'}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{job.schedule || '*/15'}
</div>
</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-700 dark:text-gray-300">{job.sourceNode || '—'}</span>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<span class="text-gray-700 dark:text-gray-300">{job.targetNode || '—'}</span>
</div>
</td>
<td class="px-3 py-3">
<Show when={job.lastSyncTime} fallback={
<span class="text-gray-400 text-sm">Never</span>
}>
<div class="text-sm text-gray-900 dark:text-gray-100">
{formatRelativeTime(job.lastSyncTime!)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatAbsoluteTime(job.lastSyncTime!)}
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
<span class="ml-1">({formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)})</span>
</Show>
</div>
</Show>
</td>
<td class="px-3 py-3">
<div class={`text-sm font-medium ${nextSyncInfo.isOverdue
? 'text-red-600 dark:text-red-400'
: nextSyncInfo.isImminent
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}>
{nextSyncInfo.text}
</div>
<Show when={job.nextSyncTime}>
<div class="text-xs text-gray-500 dark:text-gray-400">
{formatAbsoluteTime(job.nextSyncTime!)}
</div>
</Show>
</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<StatusDot
variant={indicator.variant}
title={indicator.label}
ariaLabel={indicator.label}
size="sm"
/>
<span class={`text-sm font-medium ${statusCategory === 'error'
? 'text-red-700 dark:text-red-400'
: statusCategory === 'warning'
? 'text-yellow-700 dark:text-yellow-400'
: 'text-gray-700 dark:text-gray-300'
}`}>
{indicator.label}
</span>
</div>
<Show when={job.error}>
<ErrorTooltip error={job.error!} />
</Show>
<Show when={(job.failCount ?? 0) > 0}>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.failCount} failure{(job.failCount ?? 0) > 1 ? 's' : ''}
</div>
</Show>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Show>
</Card>
</Show>
</Show>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
import { Component } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import APITokenManager from './APITokenManager';
import BadgeCheck from 'lucide-solid/icons/badge-check';
interface APIAccessPanelProps {
currentTokenHint?: string;
onTokensChanged: () => void;
refreshing: boolean;
}
export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<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">
<BadgeCheck class="w-5 h-5 text-blue-600 dark:text-blue-300" />
</div>
<SectionHeader
title="API Access"
description="Generate scoped tokens for agents and automation"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate scoped tokens for Docker agents, host agents, and automation pipelines. Tokens
are shown oncestore them securely and rotate when infrastructure changes.
</p>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes"
target="_blank"
rel="noreferrer"
class="inline-flex w-fit items-center gap-2 rounded-md 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-700 dark:bg-blue-900/30 dark:text-blue-200"
>
View scope reference
</a>
</div>
</Card>
<APITokenManager
currentTokenHint={props.currentTokenHint}
onTokensChanged={props.onTokensChanged}
refreshing={props.refreshing}
/>
</div>
);
};

View file

@ -802,7 +802,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</Show>
<Card tone="muted" padding="sm" class="text-xs text-gray-600 dark:text-gray-400">
💡 Separate tokens per integration Rotate regularly {' '}
Separate tokens per integration Rotate regularly {' '}
<a
class="font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
href={SCOPES_DOC_URL}

View file

@ -0,0 +1,430 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import Clock from 'lucide-solid/icons/clock';
const BACKUP_INTERVAL_OPTIONS = [
{ value: 0, label: 'Default (~90s)' },
{ value: 300, label: '5 minutes' },
{ value: 900, label: '15 minutes' },
{ value: 1800, label: '30 minutes' },
{ value: 3600, label: '1 hour' },
{ value: 21600, label: '6 hours' },
{ value: 86400, label: '24 hours' },
];
const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days
interface BackupsSettingsPanelProps {
backupPollingEnabled: Accessor<boolean>;
setBackupPollingEnabled: Setter<boolean>;
backupPollingInterval: Accessor<number>;
setBackupPollingInterval: Setter<number>;
backupPollingCustomMinutes: Accessor<number>;
setBackupPollingCustomMinutes: Setter<number>;
backupPollingUseCustom: Accessor<boolean>;
setBackupPollingUseCustom: Setter<boolean>;
backupPollingEnvLocked: () => boolean;
backupIntervalSelectValue: () => string;
backupIntervalSummary: () => string;
setHasUnsavedChanges: Setter<boolean>;
// Export/Import handlers
showExportDialog: Accessor<boolean>;
setShowExportDialog: Setter<boolean>;
showImportDialog: Accessor<boolean>;
setShowImportDialog: Setter<boolean>;
setUseCustomPassphrase: Setter<boolean>;
securityStatus: Accessor<{ hasAuthentication: boolean } | null>;
}
export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<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">
<Clock class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Backups"
description="Backup polling and configuration management"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-8">
{/* Backup Polling Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="9" stroke-width="2" />
<path
d="M12 7v5l3 3"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Backup polling
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Control how often Pulse queries Proxmox backup tasks, datastore contents, and guest
snapshots. Longer intervals reduce disk activity and API load.
</p>
<div class="space-y-3">
{/* Enable toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Enable backup polling
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Required for dashboard backup status, storage snapshots, and alerting.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
checked={props.backupPollingEnabled()}
disabled={props.backupPollingEnvLocked()}
onChange={(e) => {
props.setBackupPollingEnabled(e.currentTarget.checked);
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Polling interval options */}
<Show when={props.backupPollingEnabled()}>
<div class="space-y-3 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Polling interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{props.backupIntervalSummary()}
</p>
</div>
<select
value={props.backupIntervalSelectValue()}
disabled={props.backupPollingEnvLocked()}
onChange={(e) => {
const value = e.currentTarget.value;
if (value === 'custom') {
props.setBackupPollingUseCustom(true);
const minutes = Math.max(1, props.backupPollingCustomMinutes());
props.setBackupPollingInterval(minutes * 60);
} else {
props.setBackupPollingUseCustom(false);
const seconds = parseInt(value, 10);
if (!Number.isNaN(seconds)) {
props.setBackupPollingInterval(seconds);
if (seconds > 0) {
props.setBackupPollingCustomMinutes(
Math.max(1, Math.round(seconds / 60))
);
}
}
}
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<For each={BACKUP_INTERVAL_OPTIONS}>
{(option) => <option value={String(option.value)}>{option.label}</option>}
</For>
<option value="custom">Custom interval...</option>
</select>
</div>
{/* Custom interval input */}
<Show when={props.backupIntervalSelectValue() === 'custom'}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Custom interval (minutes)
</label>
<div class="flex items-center gap-3">
<input
type="number"
min="1"
max={BACKUP_INTERVAL_MAX_MINUTES}
value={props.backupPollingCustomMinutes()}
disabled={props.backupPollingEnvLocked()}
onInput={(e) => {
const value = Number(e.currentTarget.value);
if (Number.isNaN(value)) {
return;
}
const clamped = Math.max(
1,
Math.min(BACKUP_INTERVAL_MAX_MINUTES, Math.floor(value))
);
props.setBackupPollingCustomMinutes(clamped);
props.setBackupPollingInterval(clamped * 60);
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
1 - {BACKUP_INTERVAL_MAX_MINUTES} minutes (~7 days max)
</span>
</div>
</div>
</Show>
</div>
</Show>
{/* Env override warning */}
<Show when={props.backupPollingEnvLocked()}>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20 p-3 text-xs text-amber-700 dark:text-amber-200">
<svg
class="w-4 h-4 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
<div>
<p class="font-medium">Environment override detected</p>
<p class="mt-1">
The <code class="font-mono">ENABLE_BACKUP_POLLING</code> or{' '}
<code class="font-mono">BACKUP_POLLING_INTERVAL</code> environment variables
are set. Remove them and restart Pulse to manage backup polling here.
</p>
</div>
</div>
</Show>
</div>
</section>
{/* Backup & Restore Section */}
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 dark:from-blue-900/10 dark:to-indigo-900/10 hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20">
{/* Archive/Download Box Icon */}
<svg
class="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Export Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Create an encrypted backup package
</p>
{/* Feature list */}
<ul class="space-y-1 mb-4">
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>All node connections & credentials</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>Alert thresholds & overrides</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>AES-256 encryption</span>
</li>
</ul>
<button
type="button"
onClick={() => {
// Default to custom passphrase if no auth is configured
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
props.setShowExportDialog(true);
}}
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-sm font-medium rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Create Backup
</button>
</div>
</div>
</div>
{/* Import Section */}
<div class="group border border-gray-200 dark:border-gray-700 rounded-xl p-5 bg-gradient-to-br from-gray-50/50 to-slate-50/30 dark:from-gray-800/30 dark:to-slate-800/20 hover:border-gray-400 dark:hover:border-gray-600 transition-all duration-200">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-gray-500 to-slate-600 rounded-xl flex items-center justify-center shadow-lg shadow-gray-500/20">
{/* Upload/Restore Icon */}
<svg
class="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
Restore Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Import from an encrypted backup file
</p>
{/* Feature list */}
<ul class="space-y-1 mb-4">
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Merge or replace existing config</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Validates backup before applying</span>
</li>
<li class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3.5 h-3.5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Requires original passphrase</span>
</li>
</ul>
<button
type="button"
onClick={() => props.setShowImportDialog(true)}
class="w-full sm:w-auto px-4 py-2 bg-gradient-to-r from-gray-600 to-slate-600 text-white text-sm font-medium rounded-lg hover:from-gray-700 hover:to-slate-700 transition-all duration-200 inline-flex items-center justify-center gap-2 shadow-md hover:shadow-lg"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Restore Backup
</button>
</div>
</div>
</div>
</div>
{/* Security Tips */}
<div class="mt-6 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/10 rounded-xl border border-amber-200 dark:border-amber-800/50">
<div class="flex gap-3">
{/* Shield Icon */}
<div class="flex-shrink-0 w-10 h-10 bg-amber-100 dark:bg-amber-900/40 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Security Tips</p>
<ul class="space-y-2">
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
<span>Backups contain encrypted credentials and sensitive data</span>
</li>
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd" />
</svg>
<span>Use a strong passphrase (12+ characters, mix of letters, numbers, symbols)</span>
</li>
<li class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2V7a5 5 0 00-5-5zm3 7V7a3 3 0 10-6 0v2h6z" />
</svg>
<span>Store backup files securely and never share the passphrase</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</Card>
</div>
);
};

View file

@ -20,9 +20,16 @@ type TemperatureSocketCooldownInfo = {
lastError?: string;
};
// Host agent info passed from state
interface HostAgentInfo {
hostname: string;
status: string;
}
interface PveNodesTableProps {
nodes: NodeConfigWithStatus[];
stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
stateHosts?: HostAgentInfo[];
globalTemperatureMonitoringEnabled?: boolean;
temperatureTransports?: TemperatureTransportInfo | null;
onTestConnection: (nodeId: string) => void;
@ -104,6 +111,7 @@ const resolveTemperatureTransport = (
node: NodeConfigWithStatus,
info: TemperatureTransportInfo | null | undefined,
globalEnabled: boolean,
hostAgent?: HostAgentInfo,
): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
@ -125,6 +133,15 @@ const resolveTemperatureTransport = (
};
}
// If a host agent is connected and online, it provides temperatures directly
if (hostAgent?.status === 'online') {
return {
label: 'Via agent',
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
description: 'Temperature data from Pulse agent',
};
}
const key = nodeKey;
const httpEntry = key ? info?.httpMap?.[key] : undefined;
const socketStatus = info?.socketStatus;
@ -185,9 +202,9 @@ const resolveTemperatureTransport = (
return buildSocketBadge();
case 'ssh-blocked':
return {
label: 'Proxy required',
label: 'Agent required',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Containerized Pulse requires pulse-sensor-proxy',
description: 'Install agent on node for temperature monitoring',
};
case 'ssh':
return {
@ -271,6 +288,16 @@ const resolvePveStatusMeta = (
}
};
// Helper to find matching host agent for a node by hostname matching
const findMatchingHostAgent = (
nodeName: string,
hosts: HostAgentInfo[] | undefined,
): HostAgentInfo | undefined => {
if (!hosts || hosts.length === 0) return undefined;
const nodeNameLower = nodeName.toLowerCase().trim();
return hosts.find((h) => h.hostname.toLowerCase().trim() === nodeNameLower);
};
export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
@ -304,11 +331,13 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
const clusterName = createMemo(() =>
'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown',
);
const hostAgent = createMemo(() => findMatchingHostAgent(node.name, props.stateHosts));
const transportMeta = createMemo(() =>
resolveTemperatureTransport(
node,
props.temperatureTransports,
props.globalTemperatureMonitoringEnabled ?? true,
hostAgent(),
),
);
return (
@ -410,9 +439,22 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
@ -603,9 +645,22 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
@ -786,9 +841,22 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
</div>
</td>
<td class="align-top px-3 py-3">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600 dark:text-gray-400">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
<Show when={node.source === 'agent'}>
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded w-fit">
<span class="h-1.5 w-1.5 rounded-full bg-purple-500"></span>
Agent
</span>
</Show>
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
<span class="text-[0.65rem] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded w-fit">
API only
</span>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">

View file

@ -0,0 +1,674 @@
import { Component, Show, For, createSignal } from 'solid-js';
import { apiFetchJSON } from '@/utils/apiClient';
import { showSuccess, showError } from '@/utils/toast';
import { Card } from '@/components/shared/Card';
import Activity from 'lucide-solid/icons/activity';
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Database from 'lucide-solid/icons/database';
import Network from 'lucide-solid/icons/network';
import Shield from 'lucide-solid/icons/shield';
import Cpu from 'lucide-solid/icons/cpu';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import Download from 'lucide-solid/icons/download';
import CheckCircle from 'lucide-solid/icons/check-circle';
import XCircle from 'lucide-solid/icons/x-circle';
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
// Type definitions
interface DiagnosticsNode {
id: string;
name: string;
host: string;
type: string;
authMethod: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
lastPoll?: string;
clusterInfo?: Record<string, unknown>;
}
interface DiagnosticsPBS {
id: string;
name: string;
host: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
}
interface SystemDiagnostic {
os: string;
arch: string;
goVersion: string;
numCPU: number;
numGoroutine: number;
memoryMB: number;
}
interface DiscoveryDiagnostic {
enabled: boolean;
configuredSubnet?: string;
activeSubnet?: string;
environmentOverride?: string;
subnetAllowlist?: string[];
subnetBlocklist?: string[];
scanning?: boolean;
scanInterval?: string;
lastScanStartedAt?: string;
lastResultTimestamp?: string;
lastResultServers?: number;
lastResultErrors?: number;
}
interface TemperatureProxyDiagnostic {
legacySSHDetected: boolean;
recommendProxyUpgrade: boolean;
socketFound: boolean;
socketPath?: string;
socketPermissions?: string;
socketOwner?: string;
socketGroup?: string;
proxyReachable?: boolean;
proxyVersion?: string;
notes?: string[];
}
interface APITokenDiagnostic {
enabled: boolean;
tokenCount: number;
hasEnvTokens: boolean;
hasLegacyToken: boolean;
recommendTokenSetup: boolean;
recommendTokenRotation: boolean;
legacyDockerHostCount?: number;
unusedTokenCount?: number;
notes?: string[];
}
interface DockerAgentDiagnostic {
hostsTotal: number;
hostsOnline: number;
hostsReportingVersion: number;
hostsWithTokenBinding: number;
hostsWithoutTokenBinding: number;
hostsWithoutVersion?: number;
hostsOutdatedVersion?: number;
hostsWithStaleCommand?: number;
hostsPendingUninstall?: number;
hostsNeedingAttention: number;
recommendedAgentVersion?: string;
notes?: string[];
}
interface AlertsDiagnostic {
legacyThresholdsDetected: boolean;
legacyThresholdSources?: string[];
legacyScheduleSettings?: string[];
missingCooldown: boolean;
missingGroupingWindow: boolean;
notes?: string[];
}
interface DiagnosticsData {
version: string;
runtime: string;
uptime: number;
nodes: DiagnosticsNode[];
pbs: DiagnosticsPBS[];
system: SystemDiagnostic;
temperatureProxy?: TemperatureProxyDiagnostic | null;
apiTokens?: APITokenDiagnostic | null;
dockerAgents?: DockerAgentDiagnostic | null;
alerts?: AlertsDiagnostic | null;
discovery?: DiscoveryDiagnostic | null;
errors: string[];
}
// Utility functions
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours < 24) return `${hours}h ${minutes}m`;
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
function formatRelativeTime(timestamp?: string | number): string {
if (!timestamp) return 'Never';
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
// DiagnosticCard - a mini card component for individual diagnostic items
const DiagnosticCard: Component<{
title: string;
icon: Component<{ class?: string }>;
status?: 'success' | 'warning' | 'error' | 'info';
children: any;
}> = (props) => {
const statusColors = {
success: 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-900/20',
warning: 'border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/20',
error: 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20',
info: 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20',
};
const iconColors = {
success: 'text-green-600 dark:text-green-400',
warning: 'text-amber-600 dark:text-amber-400',
error: 'text-red-600 dark:text-red-400',
info: 'text-blue-600 dark:text-blue-400',
};
return (
<div class={`rounded-xl border p-4 transition-all hover:shadow-md ${statusColors[props.status || 'info']}`}>
<div class="flex items-center gap-3 mb-3">
<div class={`p-2 rounded-lg bg-white/60 dark:bg-gray-800/60 ${iconColors[props.status || 'info']}`}>
<props.icon class="w-4 h-4" />
</div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{props.title}</h4>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1.5">
{props.children}
</div>
</div>
);
};
// StatusBadge component
const StatusBadge: Component<{
status: 'online' | 'offline' | 'warning' | 'unknown';
label?: string;
}> = (props) => {
const colors = {
online: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
offline: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
unknown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
};
return (
<span class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${colors[props.status]}`}>
<span class={`w-1.5 h-1.5 rounded-full ${props.status === 'online' ? 'bg-green-500' : props.status === 'offline' ? 'bg-red-500' : props.status === 'warning' ? 'bg-amber-500' : 'bg-gray-400'}`} />
{props.label || props.status}
</span>
);
};
// MetricRow component for displaying key-value pairs
const MetricRow: Component<{
label: string;
value: string | number | undefined;
mono?: boolean;
}> = (props) => (
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<span class="text-gray-500 dark:text-gray-400">{props.label}</span>
<span class={`text-gray-900 dark:text-gray-100 ${props.mono ? 'font-mono text-[11px]' : 'font-medium'}`}>
{props.value ?? 'Unknown'}
</span>
</div>
);
export const DiagnosticsPanel: Component = () => {
const [loading, setLoading] = createSignal(false);
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
const [exportLoading, setExportLoading] = createSignal(false);
const runDiagnostics = async () => {
setLoading(true);
try {
const data = await apiFetchJSON('/api/diagnostics') as DiagnosticsData;
setDiagnosticsData(data);
showSuccess('Diagnostics completed');
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to run diagnostics');
} finally {
setLoading(false);
}
};
const exportDiagnostics = async (sanitize: boolean) => {
setExportLoading(true);
try {
const data = diagnosticsData();
if (!data) {
showError('Run diagnostics first');
return;
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const type = sanitize ? 'sanitized' : 'full';
a.download = `pulse-diagnostics-${type}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(`Diagnostics exported (${type})`);
} finally {
setExportLoading(false);
}
};
// Calculate overall system health
const systemHealth = () => {
const data = diagnosticsData();
if (!data) return 'unknown';
const issues: string[] = [];
// Check node connectivity
const disconnectedNodes = data.nodes?.filter(n => !n.connected).length || 0;
if (disconnectedNodes > 0) issues.push('nodes');
// Check PBS connectivity
const disconnectedPbs = data.pbs?.filter(p => !p.connected).length || 0;
if (disconnectedPbs > 0) issues.push('pbs');
// Check for errors
if (data.errors?.length > 0) issues.push('errors');
// Check temperature proxy
if (data.temperatureProxy?.legacySSHDetected) issues.push('temperature');
// Check alerts config
if (data.alerts?.legacyThresholdsDetected || data.alerts?.missingCooldown) issues.push('alerts');
if (issues.length === 0) return 'healthy';
if (issues.length <= 2) return 'warning';
return 'critical';
};
const healthColor = () => {
const health = systemHealth();
if (health === 'healthy') return 'from-green-500 to-emerald-600';
if (health === 'warning') return 'from-amber-500 to-orange-600';
if (health === 'critical') return 'from-red-500 to-rose-600';
return 'from-gray-400 to-gray-500';
};
return (
<div class="space-y-6">
{/* Header Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class={`bg-gradient-to-r ${healthColor()} px-6 py-5`}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<Activity class="w-6 h-6 text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-white">System Diagnostics</h2>
<p class="text-sm text-white/80">
Connection health, configuration status, and troubleshooting tools
</p>
</div>
</div>
<div class="flex items-center gap-3">
<Show when={diagnosticsData()}>
<div class="text-right text-white/80 text-xs">
<div>Version {diagnosticsData()?.version}</div>
<div>Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}</div>
</div>
</Show>
<button
type="button"
onClick={runDiagnostics}
disabled={loading()}
class="flex items-center gap-2 px-4 py-2.5 bg-white/20 hover:bg-white/30 text-white rounded-lg font-medium text-sm transition-all disabled:opacity-50 backdrop-blur-sm"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
{loading() ? 'Running...' : 'Run Diagnostics'}
</button>
</div>
</div>
</div>
{/* Quick Actions */}
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-xs text-gray-500 dark:text-gray-400">
Test all connections and inspect runtime configuration
</p>
<Show when={diagnosticsData()}>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => exportDiagnostics(false)}
disabled={exportLoading()}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<Download class="w-3.5 h-3.5" />
Export Full
</button>
<button
type="button"
onClick={() => exportDiagnostics(true)}
disabled={exportLoading()}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors"
>
<Download class="w-3.5 h-3.5" />
Export for GitHub
</button>
</div>
</Show>
</div>
</Card>
{/* Diagnostics Content */}
<Show when={diagnosticsData()} fallback={
<Card padding="lg" class="text-center">
<div class="py-12">
<Activity class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No diagnostics data yet
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Click "Run Diagnostics" above to test connections and inspect system status
</p>
<button
type="button"
onClick={runDiagnostics}
disabled={loading()}
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium text-sm transition-colors disabled:opacity-50"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
Run Diagnostics
</button>
</div>
</Card>
}>
{/* Summary Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{/* System Info Card */}
<DiagnosticCard
title="System Runtime"
icon={Cpu}
status="info"
>
<MetricRow label="OS / Arch" value={`${diagnosticsData()?.system?.os || '?'} / ${diagnosticsData()?.system?.arch || '?'}`} />
<MetricRow label="Go Runtime" value={diagnosticsData()?.system?.goVersion} mono />
<MetricRow label="CPU Cores" value={diagnosticsData()?.system?.numCPU} />
<MetricRow label="Goroutines" value={diagnosticsData()?.system?.numGoroutine} />
<MetricRow label="Memory" value={`${diagnosticsData()?.system?.memoryMB || 0} MB`} />
</DiagnosticCard>
{/* Nodes Status Card */}
<DiagnosticCard
title="PVE Nodes"
icon={Server}
status={diagnosticsData()?.nodes?.every(n => n.connected) ? 'success' : 'warning'}
>
<div class="flex items-center justify-between mb-2">
<span>Total Nodes</span>
<span class="font-bold text-lg text-gray-900 dark:text-gray-100">
{diagnosticsData()?.nodes?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={diagnosticsData()?.nodes || []}>
{(node) => (
<div class="flex items-center justify-between py-1 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<span class="truncate max-w-[120px]" title={node.host}>{node.name}</span>
<StatusBadge status={node.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</DiagnosticCard>
{/* PBS Status Card */}
<DiagnosticCard
title="PBS Instances"
icon={HardDrive}
status={diagnosticsData()?.pbs?.every(p => p.connected) ? 'success' : (diagnosticsData()?.pbs?.length ? 'warning' : 'info')}
>
<Show when={(diagnosticsData()?.pbs?.length || 0) > 0} fallback={
<div class="text-center py-4 text-gray-400 dark:text-gray-500">
No PBS configured
</div>
}>
<div class="flex items-center justify-between mb-2">
<span>Total Instances</span>
<span class="font-bold text-lg text-gray-900 dark:text-gray-100">
{diagnosticsData()?.pbs?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={diagnosticsData()?.pbs || []}>
{(pbs) => (
<div class="flex items-center justify-between py-1 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<span class="truncate max-w-[120px]" title={pbs.host}>{pbs.name}</span>
<StatusBadge status={pbs.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</Show>
</DiagnosticCard>
{/* Discovery Status Card */}
<DiagnosticCard
title="Network Discovery"
icon={Network}
status={diagnosticsData()?.discovery?.enabled ? 'success' : 'info'}
>
<MetricRow
label="Status"
value={diagnosticsData()?.discovery?.enabled ? 'Enabled' : 'Disabled'}
/>
<MetricRow
label="Subnet"
value={diagnosticsData()?.discovery?.configuredSubnet || 'auto'}
mono
/>
<MetricRow
label="Scan Interval"
value={diagnosticsData()?.discovery?.scanInterval || 'default'}
/>
<MetricRow
label="Last Scan"
value={formatRelativeTime(diagnosticsData()?.discovery?.lastScanStartedAt)}
/>
<MetricRow
label="Servers Found"
value={diagnosticsData()?.discovery?.lastResultServers ?? 0}
/>
</DiagnosticCard>
</div>
{/* Detailed Status Cards */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Temperature Proxy */}
<Show when={diagnosticsData()?.temperatureProxy}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-orange-100 dark:bg-orange-900/30">
<Activity class="w-4 h-4 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Temperature Proxy</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Hardware temperature monitoring</p>
</div>
<div class="ml-auto">
<StatusBadge
status={diagnosticsData()?.temperatureProxy?.socketFound ? 'online' : 'warning'}
label={diagnosticsData()?.temperatureProxy?.socketFound ? 'Connected' : 'Not Found'}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div class="flex items-center gap-2">
{diagnosticsData()?.temperatureProxy?.socketFound ?
<CheckCircle class="w-4 h-4 text-green-500" /> :
<XCircle class="w-4 h-4 text-red-500" />
}
<span>Proxy Socket</span>
</div>
<div class="flex items-center gap-2">
{diagnosticsData()?.temperatureProxy?.proxyReachable ?
<CheckCircle class="w-4 h-4 text-green-500" /> :
<AlertTriangle class="w-4 h-4 text-amber-500" />
}
<span>Daemon</span>
</div>
</div>
<Show when={diagnosticsData()?.temperatureProxy?.proxyVersion}>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
Version: {diagnosticsData()?.temperatureProxy?.proxyVersion}
</div>
</Show>
<Show when={diagnosticsData()?.temperatureProxy?.legacySSHDetected}>
<div class="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-700 dark:text-amber-300">
Legacy SSH temperature collection detected - consider upgrading
</div>
</Show>
</Card>
</Show>
{/* API Tokens */}
<Show when={diagnosticsData()?.apiTokens}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<Shield class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">API Tokens</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Authentication status</p>
</div>
<div class="ml-auto">
<StatusBadge
status={diagnosticsData()?.apiTokens?.enabled ? 'online' : 'warning'}
label={diagnosticsData()?.apiTokens?.enabled ? 'Enabled' : 'Disabled'}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="Configured Tokens" value={diagnosticsData()?.apiTokens?.tokenCount} />
<MetricRow label="Unused Tokens" value={diagnosticsData()?.apiTokens?.unusedTokenCount ?? 0} />
<MetricRow label="Legacy Docker Hosts" value={diagnosticsData()?.apiTokens?.legacyDockerHostCount ?? 0} />
</div>
<Show when={diagnosticsData()?.apiTokens?.hasLegacyToken}>
<div class="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-700 dark:text-amber-300">
Legacy token detected - consider migrating to scoped tokens
</div>
</Show>
</Card>
</Show>
{/* Docker Agents */}
<Show when={diagnosticsData()?.dockerAgents}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<Database class="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Agents</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Container monitoring</p>
</div>
<div class="ml-auto text-right">
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{diagnosticsData()?.dockerAgents?.hostsOnline}/{diagnosticsData()?.dockerAgents?.hostsTotal}
</div>
<div class="text-[10px] text-gray-500 dark:text-gray-400">online</div>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="With Token Binding" value={diagnosticsData()?.dockerAgents?.hostsWithTokenBinding} />
<MetricRow label="Need Attention" value={diagnosticsData()?.dockerAgents?.hostsNeedingAttention} />
<MetricRow label="Outdated Version" value={diagnosticsData()?.dockerAgents?.hostsOutdatedVersion ?? 0} />
</div>
<Show when={diagnosticsData()?.dockerAgents?.recommendedAgentVersion}>
<div class="mt-3 pt-2 border-t border-gray-100 dark:border-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
Recommended version: {diagnosticsData()?.dockerAgents?.recommendedAgentVersion}
</div>
</Show>
</Card>
</Show>
{/* Alerts Configuration */}
<Show when={diagnosticsData()?.alerts}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
<AlertTriangle class="w-4 h-4 text-rose-600 dark:text-rose-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Alerts Configuration</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Alert system status</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.legacyThresholdsDetected
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
Legacy thresholds: {diagnosticsData()?.alerts?.legacyThresholdsDetected ? 'Detected' : 'Migrated'}
</span>
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.missingCooldown
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
Cooldown: {diagnosticsData()?.alerts?.missingCooldown ? 'Missing' : 'Configured'}
</span>
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.missingGroupingWindow
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
Grouping: {diagnosticsData()?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'}
</span>
</div>
<Show when={(diagnosticsData()?.alerts?.notes?.length || 0) > 0}>
<ul class="mt-3 pt-2 border-t border-gray-100 dark:border-gray-700/50 space-y-1 text-xs text-gray-500 dark:text-gray-400 list-disc pl-4">
<For each={diagnosticsData()?.alerts?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
</div>
{/* Errors Section */}
<Show when={(diagnosticsData()?.errors?.length || 0) > 0}>
<Card padding="md" class="border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20">
<div class="flex items-center gap-3 mb-3">
<XCircle class="w-5 h-5 text-red-600 dark:text-red-400" />
<h4 class="text-sm font-semibold text-red-900 dark:text-red-100">Errors Detected</h4>
</div>
<ul class="space-y-2 text-xs text-red-700 dark:text-red-300">
<For each={diagnosticsData()?.errors || []}>
{(error) => (
<li class="flex items-start gap-2 p-2 bg-red-100 dark:bg-red-900/40 rounded">
<span class="text-red-500"></span>
<span>{error}</span>
</li>
)}
</For>
</ul>
</Card>
</Show>
</Show>
</div>
);
};
export default DiagnosticsPanel;

View file

@ -0,0 +1,224 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import Sliders from 'lucide-solid/icons/sliders-horizontal';
import Activity from 'lucide-solid/icons/activity';
import Sun from 'lucide-solid/icons/sun';
import Moon from 'lucide-solid/icons/moon';
const PVE_POLLING_MIN_SECONDS = 10;
const PVE_POLLING_MAX_SECONDS = 3600;
const PVE_POLLING_PRESETS = [
{ label: 'Realtime (10s)', value: 10 },
{ label: 'Balanced (30s)', value: 30 },
{ label: 'Low (60s)', value: 60 },
{ label: 'Very low (5m)', value: 300 },
];
interface GeneralSettingsPanelProps {
darkMode: Accessor<boolean>;
toggleDarkMode: () => void;
pvePollingInterval: Accessor<number>;
setPVEPollingInterval: Setter<number>;
pvePollingSelection: Accessor<number | 'custom'>;
setPVEPollingSelection: Setter<number | 'custom'>;
pvePollingCustomSeconds: Accessor<number>;
setPVEPollingCustomSeconds: Setter<number>;
pvePollingEnvLocked: () => boolean;
setHasUnsavedChanges: Setter<boolean>;
}
export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Appearance Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<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">
<Sliders class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="General"
description="Appearance and display preferences"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
{/* Animated theme icon */}
<div class={`relative p-2.5 rounded-xl transition-all duration-300 ${props.darkMode()
? 'bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/25'
: 'bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-amber-500/25'
}`}>
<div class="relative w-5 h-5">
<Sun class={`absolute inset-0 w-5 h-5 text-white transition-all duration-300 ${props.darkMode() ? 'opacity-0 rotate-90 scale-50' : 'opacity-100 rotate-0 scale-100'
}`} strokeWidth={2} />
<Moon class={`absolute inset-0 w-5 h-5 text-white transition-all duration-300 ${props.darkMode() ? 'opacity-100 rotate-0 scale-100' : 'opacity-0 -rotate-90 scale-50'
}`} strokeWidth={2} />
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">
{props.darkMode() ? 'Dark mode' : 'Light mode'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle to match your environment. Pulse remembers this preference on each browser.
</p>
</div>
</div>
<Toggle
checked={props.darkMode()}
onChange={(event) => {
const desired = (event.currentTarget as HTMLInputElement).checked;
if (desired !== props.darkMode()) {
props.toggleDarkMode();
}
}}
/>
</div>
</div>
</Card>
{/* Monitoring Cadence Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<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">
<Activity class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Monitoring cadence"
description="Control how frequently Pulse polls Proxmox VE nodes."
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-5">
<div class="space-y-2">
<p class="text-sm text-gray-600 dark:text-gray-400">
Shorter intervals provide near-real-time updates at the cost of higher API and CPU
usage on each node. Set a longer interval to reduce load on busy clusters.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Current cadence: {props.pvePollingInterval()} seconds (
{props.pvePollingInterval() >= 60
? `${(props.pvePollingInterval() / 60).toFixed(
props.pvePollingInterval() % 60 === 0 ? 0 : 1
)} minute${props.pvePollingInterval() / 60 === 1 ? '' : 's'}`
: 'under a minute'}
).
</p>
</div>
<div class="space-y-4">
{/* Preset buttons */}
<div class="grid gap-2 sm:grid-cols-3">
<For each={PVE_POLLING_PRESETS}>
{(option) => (
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${props.pvePollingSelection() === option.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${props.pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={props.pvePollingEnvLocked()}
onClick={() => {
if (props.pvePollingEnvLocked()) return;
props.setPVEPollingSelection(option.value);
props.setPVEPollingInterval(option.value);
props.setHasUnsavedChanges(true);
}}
>
{option.label}
</button>
)}
</For>
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${props.pvePollingSelection() === 'custom'
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${props.pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={props.pvePollingEnvLocked()}
onClick={() => {
if (props.pvePollingEnvLocked()) return;
props.setPVEPollingSelection('custom');
props.setPVEPollingInterval(props.pvePollingCustomSeconds());
props.setHasUnsavedChanges(true);
}}
>
Custom interval
</button>
</div>
{/* Custom interval input */}
<Show when={props.pvePollingSelection() === 'custom'}>
<div class="space-y-2 rounded-md border border-dashed border-gray-300 p-4 dark:border-gray-600">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200">
Custom polling interval (10-3600 seconds)
</label>
<input
type="number"
min={PVE_POLLING_MIN_SECONDS}
max={PVE_POLLING_MAX_SECONDS}
value={props.pvePollingCustomSeconds()}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900/60"
disabled={props.pvePollingEnvLocked()}
onInput={(e) => {
if (props.pvePollingEnvLocked()) return;
const parsed = Math.floor(Number(e.currentTarget.value));
if (Number.isNaN(parsed)) {
return;
}
const clamped = Math.min(
PVE_POLLING_MAX_SECONDS,
Math.max(PVE_POLLING_MIN_SECONDS, parsed)
);
props.setPVEPollingCustomSeconds(clamped);
props.setPVEPollingInterval(clamped);
props.setHasUnsavedChanges(true);
}}
/>
<p class="text-[0.68rem] text-gray-500 dark:text-gray-400">
Applies to all PVE clusters and standalone nodes.
</p>
</div>
</Show>
{/* Env override warning */}
<Show when={props.pvePollingEnvLocked()}>
<div class="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<svg
class="h-4 w-4 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<circle cx="12" cy="16" r="0.5" />
</svg>
<span>Managed via environment variable PVE_POLLING_INTERVAL.</span>
</div>
</Show>
</div>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,621 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import Network from 'lucide-solid/icons/network';
const COMMON_DISCOVERY_SUBNETS = [
'192.168.1.0/24',
'192.168.0.0/24',
'10.0.0.0/24',
'172.16.0.0/24',
'192.168.10.0/24',
];
interface NetworkSettingsPanelProps {
// Discovery settings
discoveryEnabled: Accessor<boolean>;
discoveryMode: Accessor<'auto' | 'custom'>;
discoverySubnetDraft: Accessor<string>;
discoverySubnetError: Accessor<string | undefined>;
savingDiscoverySettings: Accessor<boolean>;
envOverrides: Accessor<Record<string, boolean>>;
// Network settings
allowedOrigins: Accessor<string>;
setAllowedOrigins: Setter<string>;
allowEmbedding: Accessor<boolean>;
setAllowEmbedding: Setter<boolean>;
allowedEmbedOrigins: Accessor<string>;
setAllowedEmbedOrigins: Setter<string>;
webhookAllowedPrivateCIDRs: Accessor<string>;
setWebhookAllowedPrivateCIDRs: Setter<string>;
// Handlers
handleDiscoveryEnabledChange: (enabled: boolean) => Promise<boolean>;
handleDiscoveryModeChange: (mode: 'auto' | 'custom') => Promise<void>;
setDiscoveryMode: Setter<'auto' | 'custom'>;
setDiscoverySubnetDraft: Setter<string>;
setDiscoverySubnetError: Setter<string | undefined>;
setLastCustomSubnet: Setter<string>;
commitDiscoverySubnet: (value: string) => Promise<boolean>;
setHasUnsavedChanges: Setter<boolean>;
// Utility functions
parseSubnetList: (value: string) => string[];
normalizeSubnetList: (value: string) => string;
isValidCIDR: (value: string) => boolean;
currentDraftSubnetValue: () => string;
// Ref for input
discoverySubnetInputRef?: (el: HTMLInputElement) => void;
}
export const NetworkSettingsPanel: Component<NetworkSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Info Card */}
<Card
tone="info"
padding="md"
border={false}
class="border border-blue-200 dark:border-blue-800"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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 class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<ul class="space-y-1">
<li>
Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)
</li>
<li> Changes made here are saved to system.json immediately</li>
<li> Settings persist unless overridden by env vars</li>
</ul>
</div>
</div>
</Card>
{/* Main Network Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<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">
<Network class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Network"
description="Discovery, CORS, and embedding settings"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-8">
{/* Network Discovery Section */}
<section class="space-y-5">
<SectionHeader
title="Network discovery"
description="Control how Pulse scans for Proxmox services on your network."
size="sm"
align="left"
/>
{/* Discovery Toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Automatic scanning</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.
</p>
</div>
<Toggle
checked={props.discoveryEnabled()}
onChange={async (e: ToggleChangeEvent) => {
if (props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await props.handleDiscoveryEnabledChange(e.currentTarget.checked);
if (!success) {
e.currentTarget.checked = props.discoveryEnabled();
}
}}
disabled={props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{props.discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
{/* Discovery Options (shown when enabled) */}
<Show when={props.discoveryEnabled()}>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white/40 p-3 dark:border-gray-600 dark:bg-gray-800/40">
<fieldset class="space-y-2">
<legend class="text-xs font-medium text-gray-700 dark:text-gray-300">
Scan scope
</legend>
<div class="space-y-2">
{/* Auto mode */}
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
props.discoveryMode() === 'auto'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="auto"
checked={props.discoveryMode() === 'auto'}
onChange={async () => {
if (props.discoveryMode() !== 'auto') {
await props.handleDiscoveryModeChange('auto');
}
}}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Auto (slower, full scan)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Scans container, local, and gateway networks. Large networks may time out
after two minutes.
</p>
</div>
</label>
{/* Custom mode */}
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
props.discoveryMode() === 'custom'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="custom"
checked={props.discoveryMode() === 'custom'}
onChange={() => {
if (props.discoveryMode() !== 'custom') {
props.handleDiscoveryModeChange('custom');
}
}}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Custom subnet (faster)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Limit discovery to one or more CIDR ranges to finish faster on large
networks.
</p>
</div>
</label>
{/* Common subnet presets */}
<Show when={props.discoveryMode() === 'custom'}>
<div class="flex flex-wrap items-center gap-2 pl-9 pr-2">
<span class="text-[0.68rem] uppercase tracking-wide text-gray-500 dark:text-gray-400">
Common networks:
</span>
<For each={COMMON_DISCOVERY_SUBNETS}>
{(preset) => {
const baseValue = props.currentDraftSubnetValue();
const currentSelections = props.parseSubnetList(baseValue);
const isActive = currentSelections.includes(preset);
return (
<button
type="button"
class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${
isActive
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
: 'border-gray-300 text-gray-700 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
}`}
onClick={async () => {
if (props.envOverrides().discoverySubnet) {
return;
}
let selections = [...currentSelections];
if (isActive) {
selections = selections.filter((item) => item !== preset);
} else {
selections.push(preset);
}
if (selections.length === 0) {
props.setDiscoverySubnetDraft('');
props.setLastCustomSubnet('');
props.setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'
);
return;
}
const updatedValue = props.normalizeSubnetList(
selections.join(', ')
);
props.setDiscoveryMode('custom');
props.setDiscoverySubnetDraft(updatedValue);
props.setLastCustomSubnet(updatedValue);
props.setDiscoverySubnetError(undefined);
await props.commitDiscoverySubnet(updatedValue);
}}
disabled={props.envOverrides().discoverySubnet}
classList={{
'cursor-not-allowed opacity-60':
props.envOverrides().discoverySubnet,
}}
>
{preset}
</button>
);
}}
</For>
</div>
</Show>
</div>
</fieldset>
{/* Subnet Input */}
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="discoverySubnetInput"
class="text-xs font-medium text-gray-700 dark:text-gray-300"
>
Discovery subnet
</label>
<span
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300"
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
<input
id="discoverySubnetInput"
ref={props.discoverySubnetInputRef}
type="text"
value={props.discoverySubnetDraft()}
placeholder={
props.discoveryMode() === 'auto'
? 'auto (scan every network phase)'
: '192.168.1.0/24, 10.0.0.0/24'
}
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
props.envOverrides().discoverySubnet
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
}`}
disabled={props.envOverrides().discoverySubnet}
onInput={(e) => {
if (props.envOverrides().discoverySubnet) {
return;
}
const rawValue = e.currentTarget.value;
props.setDiscoverySubnetDraft(rawValue);
if (props.discoveryMode() !== 'custom') {
props.setDiscoveryMode('custom');
}
props.setLastCustomSubnet(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
props.setDiscoverySubnetError(undefined);
return;
}
if (!props.isValidCIDR(trimmed)) {
props.setDiscoverySubnetError(
'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'
);
} else {
props.setDiscoverySubnetError(undefined);
}
}}
onBlur={async (e) => {
if (
props.envOverrides().discoverySubnet ||
props.discoveryMode() !== 'custom'
) {
return;
}
const rawValue = e.currentTarget.value;
props.setDiscoverySubnetDraft(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
props.setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'
);
return;
}
if (!props.isValidCIDR(trimmed)) {
props.setDiscoverySubnetError(
'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'
);
return;
}
props.setDiscoverySubnetError(undefined);
await props.commitDiscoverySubnet(rawValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
<Show when={props.discoverySubnetError()}>
<p class="text-xs text-red-600 dark:text-red-400">
{props.discoverySubnetError()}
</p>
</Show>
<Show when={!props.discoverySubnetError() && props.discoveryMode() === 'auto'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Auto scans every reachable network phase. Large networks may time out switch
to custom subnets to narrow the search.
</p>
</Show>
<Show when={!props.discoverySubnetError() && props.discoveryMode() === 'custom'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish
faster and avoid timeouts.
</p>
</Show>
</div>
</div>
</Show>
{/* Env override warning */}
<Show when={props.envOverrides().discoveryEnabled || props.envOverrides().discoverySubnet}>
<div class="rounded-lg border border-amber-200 bg-amber-100/80 p-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Discovery settings are locked by environment variables. Update the service
configuration and restart Pulse to change them here.
</div>
</Show>
</section>
{/* CORS Settings Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
</svg>
Network Settings
</h4>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
CORS Allowed Origins
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
For reverse proxy setups (* = allow all, empty = same-origin only)
</p>
<div class="relative">
<input
type="text"
value={props.allowedOrigins()}
onChange={(e) => {
if (!props.envOverrides().allowedOrigins) {
props.setAllowedOrigins(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}
}}
disabled={props.envOverrides().allowedOrigins}
placeholder="* or https://example.com"
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
props.envOverrides().allowedOrigins
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}
/>
{props.envOverrides().allowedOrigins && (
<div class="mt-2 p-2 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-800 dark:text-amber-200">
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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>Overridden by ALLOWED_ORIGINS environment variable</span>
</div>
<div class="mt-1 text-amber-700 dark:text-amber-300">
Remove the env var and restart to enable UI configuration
</div>
</div>
)}
</div>
</div>
</section>
{/* Embedding Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="14" rx="2"></rect>
<path d="M7 20h10"></path>
</svg>
Embedding
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)
</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allowEmbedding"
checked={props.allowEmbedding()}
onChange={(e) => {
props.setAllowEmbedding(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
Allow iframe embedding
</label>
</div>
<Show when={props.allowEmbedding()}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Allowed Embed Origins (optional)
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Comma-separated list of origins that can embed Pulse (leave empty for same-origin
only)
</p>
<input
type="text"
value={props.allowedEmbedOrigins()}
onChange={(e) => {
props.setAllowedEmbedOrigins(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
placeholder="https://my.domain, https://dashboard.example.com"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at{' '}
<code>my.domain</code>, add <code>https://my.domain</code> here.
</p>
</div>
</Show>
</div>
</section>
{/* Webhook Security Section */}
<section class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Webhook Security
</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Allowed Private IP Ranges for Webhooks
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
By default, webhooks to private IP addresses are blocked for security. Enter
trusted CIDR ranges to allow webhooks to internal services (leave empty to block
all private IPs).
</p>
<input
type="text"
value={props.webhookAllowedPrivateCIDRs()}
onChange={(e) => {
props.setWebhookAllowedPrivateCIDRs(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
placeholder="192.168.1.0/24, 10.0.0.0/8"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Example: <code>192.168.1.0/24,10.0.0.0/8</code> allows webhooks to these private
networks. Localhost and cloud metadata services remain blocked.
</p>
</div>
</div>
</section>
{/* Port Configuration Notice */}
<Card
tone="warning"
padding="sm"
border={false}
class="border border-amber-200 dark:border-amber-800"
>
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use{' '}
<code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">
systemctl edit pulse
</code>
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
[Service]
<br />
Environment="FRONTEND_PORT=8080"
<br />
<span class="text-xs text-amber-600 dark:text-amber-400">
Then restart: sudo systemctl restart pulse
</span>
</p>
</Card>
</div>
</Card>
</div>
);
};

View file

@ -80,7 +80,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
host: '',
guestURL: '',
authType: nodeType === 'pmg' ? 'password' : ('token' as 'password' | 'token'),
setupMode: 'auto' as 'auto' | 'manual',
setupMode: 'agent' as 'agent' | 'auto' | 'manual', // Default to agent install (recommended)
user: '',
password: '',
tokenName: '',
@ -101,6 +101,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [proxyInstallCommand, setProxyInstallCommand] = createSignal('');
const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false);
const [proxyCommandError, setProxyCommandError] = createSignal<string | null>(null);
const [agentInstallCommand, setAgentInstallCommand] = createSignal('');
const [loadingAgentCommand, setLoadingAgentCommand] = createSignal(false);
const [agentCommandError, setAgentCommandError] = createSignal<string | null>(null);
const showTemperatureMonitoringSection = () =>
typeof props.temperatureMonitoringEnabled === 'boolean';
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
@ -114,7 +117,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
case 'socket-proxy':
return {
tone: 'success',
message: 'Temperatures flow through the host sensor proxy mounted at /run/pulse-sensor-proxy.',
message: 'Temperatures flow through the socket proxy mounted at /run/pulse-sensor-proxy.',
};
case 'https-proxy':
return {
@ -126,7 +129,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
tone: 'danger',
disable: true,
message:
'Pulse is running in a container without the pulse-sensor-proxy bind mount. Install the proxy on the host or register an HTTPS proxy before enabling temperatures.',
'Pulse is running in a container. Install the Pulse agent on this node for temperature monitoring (Settings → Agents).',
};
case 'ssh':
return {
@ -400,8 +403,13 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (field === 'setupMode' && value !== 'auto') {
setQuickSetupCommand('');
if (field === 'setupMode') {
if (value !== 'auto') {
setQuickSetupCommand('');
}
if (value !== 'agent') {
setAgentInstallCommand('');
}
}
};
@ -764,7 +772,21 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pve'}>
<div class="space-y-3 text-xs">
{/* Tab buttons */}
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => updateField('setupMode', 'agent')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'agent'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Agent Install
<span class="ml-1.5 px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 rounded">
Recommended
</span>
</button>
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
@ -774,7 +796,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Quick Setup
API Only
</button>
<button
type="button"
@ -785,17 +807,106 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Manual Setup
Manual
</button>
</div>
{/* Quick Setup Tab */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
The command below creates the monitoring user, applies read-only
access, and adds the storage permissions Pulse needs to display
backups.
</p>
{/* Agent Install Tab (Recommended) */}
<Show when={formData().setupMode === 'agent'}>
<div class="space-y-3">
<p class="text-xs text-gray-600 dark:text-gray-400">
Install the Pulse agent on your Proxmox node. This single command sets everything up:
</p>
<ul class="text-xs text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>Creates monitoring user and API token automatically</li>
<li>Registers the node with Pulse</li>
<li>Enables temperature monitoring (no SSH required)</li>
<li>Enables AI features for managing VMs/containers</li>
</ul>
<p class="text-blue-800 dark:text-blue-200 font-medium">
Run this command on your Proxmox VE node:
</p>
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
<button
type="button"
disabled={loadingAgentCommand()}
onClick={async () => {
logger.debug('[Agent Install] Copy button clicked');
try {
setLoadingAgentCommand(true);
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/agent-install-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'pve',
enableProxmox: true,
}),
});
if (response.ok) {
const data = await response.json();
if (data.command) {
setAgentInstallCommand(data.command);
const copied = await copyToClipboard(data.command);
if (copied) {
showSuccess('Command copied! Run it on your Proxmox node.');
} else {
showError('Failed to copy to clipboard');
}
}
} else {
showError('Failed to generate install command');
}
} catch (error) {
logger.error('[Agent Install] Error:', error);
showError('Failed to generate install command');
} finally {
setLoadingAgentCommand(false);
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-gray-200 bg-gray-700 rounded-md transition-colors disabled:opacity-50"
title="Copy command"
>
<Show when={loadingAgentCommand()} fallback={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
}>
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"></path>
</svg>
</Show>
</button>
<Show
when={agentInstallCommand().length > 0}
fallback={
<code class="text-blue-400">
Click the button above to copy the install command
</code>
}
>
<code class="block text-blue-100 whitespace-pre-wrap break-words">
{agentInstallCommand()}
</code>
</Show>
</div>
<p class="text-[11px] text-gray-500 dark:text-gray-400 italic">
The node will appear in Pulse automatically after the agent starts.
</p>
</div>
</Show>
{/* API Only Tab (formerly Quick Setup) */}
<Show when={formData().setupMode === 'auto'}>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 mb-3 dark:border-amber-700 dark:bg-amber-900/20">
<p class="text-xs text-amber-800 dark:text-amber-200">
<strong>Limited functionality:</strong> API-only mode does not include temperature monitoring or AI features.
For full functionality, use the Agent Install tab instead.
</p>
</div>
<p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox VE server:
</p>
@ -1259,17 +1370,29 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
<Show when={props.nodeType === 'pbs'}>
<div class="space-y-3 text-xs">
{/* Tab buttons for PBS */}
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'auto' || !formData().setupMode
onClick={() => updateField('setupMode', 'agent')}
class={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'agent'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
Quick Setup
Agent Install
<span class="text-[10px] px-1.5 py-0.5 bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 rounded">Recommended</span>
</button>
<button
type="button"
onClick={() => updateField('setupMode', 'auto')}
class={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-transparent transition-colors ${
formData().setupMode === 'auto'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 border-gray-300 dark:border-gray-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60'
}`}
>
API Only
</button>
<button
type="button"
@ -1284,8 +1407,83 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</button>
</div>
{/* Quick Setup Tab for PBS */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
{/* Agent Install Tab for PBS */}
<Show when={formData().setupMode === 'agent'}>
<div class="space-y-3">
<p class="text-xs text-gray-600 dark:text-gray-400">
Install the Pulse agent on your Proxmox Backup Server. This is the recommended method as it provides:
</p>
<ul class="text-xs text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>One-command setup (creates API user and token automatically)</li>
<li>Built-in temperature monitoring (no SSH required)</li>
<li>AI features (execute commands via Pulse AI)</li>
<li>Automatic reconnection on network issues</li>
</ul>
<p class="text-blue-800 dark:text-blue-200 text-xs mt-3">
Run this command on your PBS node:
</p>
<div class="relative bg-gray-900 rounded-md p-3 font-mono text-xs overflow-x-auto">
<button
type="button"
onClick={async () => {
try {
setLoadingAgentCommand(true);
setAgentCommandError(null);
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/agent-install-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'pbs' }),
});
if (!response.ok) {
throw new Error(`Failed to generate command: ${response.status}`);
}
const data = await response.json();
if (data.command) {
setAgentInstallCommand(data.command);
const copied = await copyToClipboard(data.command);
if (copied) {
showSuccess('Command copied to clipboard');
}
}
} catch (error) {
logger.error('[Agent Install] Error:', error);
setAgentCommandError(error instanceof Error ? error.message : 'Failed to generate command');
showError('Failed to generate install command');
} finally {
setLoadingAgentCommand(false);
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white rounded bg-gray-800 hover:bg-gray-700 transition-colors"
title="Copy to clipboard"
disabled={loadingAgentCommand()}
>
<Show when={loadingAgentCommand()} fallback={
<svg xmlns="http://www.w3.org/2000/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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}>
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</Show>
</button>
<code class="text-green-400 whitespace-pre-wrap break-all pr-10">
{agentInstallCommand() || 'Click the copy button to generate and copy the install command'}
</code>
</div>
<Show when={agentCommandError()}>
<p class="text-xs text-red-500">{agentCommandError()}</p>
</Show>
<p class="text-xs text-gray-500 dark:text-gray-500">
The node will automatically appear in Pulse once the agent connects.
</p>
</div>
</Show>
{/* Quick Setup Tab for PBS (API Only) */}
<Show when={formData().setupMode === 'auto'}>
<p class="text-blue-800 dark:text-blue-200">
Just copy and run this one command on your Proxmox Backup Server:
</p>
@ -1915,8 +2113,11 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</Show>
<Show when={shouldOfferProxyCommand()}>
<div class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-800 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-100 space-y-2">
<div class="font-semibold">Install HTTPS proxy on this host</div>
<div>Generate a one-line installer command to run on the Proxmox host:</div>
<div class="font-semibold">Temperature proxy (legacy)</div>
<div class="text-amber-700 dark:text-amber-300 text-[11px]">
Recommended: Install the Pulse agent instead (Settings Agents) for temperatures + AI features.
</div>
<div>Or generate a one-line installer command for the standalone temperature proxy:</div>
<div class="flex flex-wrap gap-2">
<button
type="button"

View file

@ -0,0 +1,590 @@
import { Component, Show, For, Accessor, Setter, JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import {
PveNodesTable,
PbsNodesTable,
PmgNodesTable,
type TemperatureTransportInfo,
} from './ConfiguredNodeTables';
import { notificationStore } from '@/stores/notifications';
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Mail from 'lucide-solid/icons/mail';
import Loader from 'lucide-solid/icons/loader-2';
import type { NodeConfig } from '@/types/nodes';
type AgentType = 'pve' | 'pbs' | 'pmg';
interface DiscoveredServer {
ip: string;
port: number;
type: 'pve' | 'pbs' | 'pmg';
hostname?: string;
}
interface DiscoveryScanStatus {
scanning: boolean;
lastScanStartedAt?: string;
lastResultAt?: string;
errors?: string[];
}
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
hasToken?: boolean;
connectionStatus?: 'connected' | 'error' | 'pending' | 'unknown';
connectionError?: string;
status?: 'connected' | 'error' | 'pending' | 'unknown';
};
interface ProxmoxAgentNodesPanelProps {
agentType: AgentType;
// Node data
nodes: Accessor<NodeConfigWithStatus[]>;
discoveredNodes: Accessor<DiscoveredServer[]>;
// State data for tables
stateNodes?: any[];
stateHosts?: any[];
statePbs?: any[];
statePmg?: any[];
// Temperature monitoring
temperatureMonitoringEnabled: Accessor<boolean>;
temperatureTransports?: Accessor<TemperatureTransportInfo | null>;
// Discovery settings
discoveryEnabled: Accessor<boolean>;
discoveryMode: Accessor<'auto' | 'custom'>;
discoveryScanStatus: Accessor<DiscoveryScanStatus>;
envOverrides: Accessor<Record<string, boolean>>;
savingDiscoverySettings: Accessor<boolean>;
// Loading state
initialLoadComplete: Accessor<boolean>;
// Actions
handleDiscoveryEnabledChange: (enabled: boolean) => Promise<boolean>;
triggerDiscoveryScan: (opts: { quiet: boolean }) => Promise<void>;
loadDiscoveredNodes: () => Promise<void>;
testNodeConnection: (nodeId: string) => void;
requestDeleteNode: (node: NodeConfig) => void;
refreshClusterNodes?: (nodeId: string) => void;
// Modal controls
setEditingNode: Setter<NodeConfigWithStatus | null>;
setCurrentNodeType: Setter<AgentType>;
setModalResetKey: Setter<number>;
setShowNodeModal: Setter<boolean>;
// For PMG edit workaround
allNodes?: Accessor<NodeConfig[]>;
// Utility
formatRelativeTime: (date: string | undefined) => string;
}
const AGENT_CONFIG: Record<AgentType, {
title: string;
addButtonText: string;
emptyTitle: string;
emptyDescription: string;
scanningText: string;
icon: () => JSX.Element;
discoveryTooltip: string;
}> = {
pve: {
title: 'Proxmox VE nodes',
addButtonText: 'Add PVE Node',
emptyTitle: 'No PVE nodes configured',
emptyDescription: 'Add a Proxmox VE node to start monitoring your infrastructure',
scanningText: 'Scanning your network for Proxmox VE servers…',
icon: () => <Server class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of Proxmox servers on your network',
},
pbs: {
title: 'Proxmox Backup Server nodes',
addButtonText: 'Add PBS Node',
emptyTitle: 'No PBS nodes configured',
emptyDescription: 'Add a Proxmox Backup Server to monitor your backup infrastructure',
scanningText: 'Scanning your network for Proxmox Backup Servers…',
icon: () => <HardDrive class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of PBS servers on your network',
},
pmg: {
title: 'Proxmox Mail Gateway nodes',
addButtonText: 'Add PMG Node',
emptyTitle: 'No PMG nodes configured',
emptyDescription: 'Add a Proxmox Mail Gateway to monitor mail queue and quarantine metrics',
scanningText: 'Scanning network...',
icon: () => <Mail class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of PMG servers on your network',
},
};
export const ProxmoxAgentNodesPanel: Component<ProxmoxAgentNodesPanelProps> = (props) => {
const config = () => AGENT_CONFIG[props.agentType];
const filteredDiscoveredNodes = () => props.discoveredNodes().filter((n) => n.type === props.agentType);
const handleAddNode = () => {
props.setEditingNode(null);
props.setCurrentNodeType(props.agentType);
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
};
const handleRefreshDiscovery = async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await props.triggerDiscoveryScan({ quiet: true });
} finally {
await props.loadDiscoveredNodes();
}
};
const handleDiscoveredNodeClick = (server: DiscoveredServer) => {
if (props.agentType === 'pmg') {
// PMG uses a different approach - sets up modal then fills input
props.setEditingNode(null);
props.setCurrentNodeType('pmg');
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
setTimeout(() => {
const hostInput = document.querySelector(
'input[placeholder*="192.168"]',
) as HTMLInputElement;
if (hostInput) {
hostInput.value = server.ip;
hostInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 50);
} else {
// PVE and PBS pre-fill the node object
const baseNode = {
id: '',
type: props.agentType,
name: server.hostname || `${props.agentType}-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
status: 'pending' as const,
} as NodeConfigWithStatus;
if (props.agentType === 'pve') {
Object.assign(baseNode, {
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
monitorPhysicalDisks: false,
});
} else if (props.agentType === 'pbs') {
Object.assign(baseNode, {
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: true,
});
}
props.setEditingNode(baseNode);
props.setCurrentNodeType(props.agentType);
props.setShowNodeModal(true);
}
};
const renderNodesTable = () => {
if (props.agentType === 'pve') {
return (
<PveNodesTable
nodes={props.nodes() as any}
stateNodes={props.stateNodes ?? []}
stateHosts={props.stateHosts ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
temperatureTransports={props.temperatureTransports?.() ?? null}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(node as NodeConfigWithStatus);
props.setCurrentNodeType('pve');
props.setShowNodeModal(true);
}}
onDelete={(node) => props.requestDeleteNode(node)}
onRefreshCluster={props.refreshClusterNodes}
/>
);
} else if (props.agentType === 'pbs') {
return (
<PbsNodesTable
nodes={props.nodes() as any}
statePbs={props.statePbs ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(node as NodeConfigWithStatus);
props.setCurrentNodeType('pbs');
props.setShowNodeModal(true);
}}
onDelete={(node) => props.requestDeleteNode(node)}
/>
);
} else {
return (
<PmgNodesTable
nodes={props.nodes() as any}
statePmg={props.statePmg ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(props.allNodes?.().find((n) => n.id === node.id) as NodeConfigWithStatus ?? null);
props.setCurrentNodeType('pmg');
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
}}
onDelete={(node) => props.requestDeleteNode(node)}
/>
);
}
};
return (
<div class="space-y-6 mt-6">
<div class="space-y-4">
{/* Loading state */}
<Show when={!props.initialLoadComplete()}>
<div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
Loading configuration...
</div>
</Show>
{/* Main content */}
<Show when={props.initialLoadComplete()}>
<Card padding="lg">
<div class="space-y-4">
{/* Header with actions */}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
{config().title}
</h4>
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
{/* Discovery toggle */}
<div
class="flex items-center gap-2 sm:gap-3"
title={config().discoveryTooltip}
>
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
Discovery
</span>
<Toggle
checked={props.discoveryEnabled()}
onChange={async (e: ToggleChangeEvent) => {
if (
props.envOverrides().discoveryEnabled ||
props.savingDiscoverySettings()
) {
e.preventDefault();
return;
}
const success = await props.handleDiscoveryEnabledChange(
e.currentTarget.checked,
);
if (!success) {
e.currentTarget.checked = props.discoveryEnabled();
}
}}
disabled={
props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()
}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{props.discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
{/* Refresh button */}
<Show when={props.discoveryEnabled()}>
<button
type="button"
onClick={handleRefreshDiscovery}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<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 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
{/* Add button */}
<button
type="button"
onClick={handleAddNode}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
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>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">{config().addButtonText}</span>
</button>
</div>
</div>
{/* Nodes table */}
<Show when={props.nodes().length > 0}>
{renderNodesTable()}
</Show>
{/* Empty state */}
<Show
when={
props.nodes().length === 0 &&
filteredDiscoveredNodes().length === 0
}
>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
{config().icon()}
</div>
<p class="text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
{config().emptyTitle}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{config().emptyDescription}
</p>
</div>
</Show>
</div>
</Card>
</Show>
{/* Discovery section */}
<Show when={props.discoveryEnabled()}>
<div class="space-y-3">
{/* Scan status */}
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={props.discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<Loader class="h-4 w-4 animate-spin" />
<span>{config().scanningText}</span>
</span>
</Show>
<Show
when={
!props.discoveryScanStatus().scanning &&
(props.discoveryScanStatus().lastResultAt ||
props.discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{props.formatRelativeTime(
props.discoveryScanStatus().lastResultAt ??
props.discoveryScanStatus().lastScanStartedAt,
)}
</span>
</Show>
</div>
{/* Discovery errors */}
<Show
when={
props.discoveryScanStatus().errors && props.discoveryScanStatus().errors!.length
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={props.discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
<Show
when={
props.discoveryMode() === 'auto' &&
(props.discoveryScanStatus().errors || []).some((err) =>
/timed out|timeout/i.test(err),
)
}
>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet
for faster, targeted scans.
</p>
</Show>
</div>
</Show>
{/* Scanning placeholder */}
<Show
when={
props.discoveryScanStatus().scanning &&
filteredDiscoveredNodes().length === 0
}
>
<Show when={props.agentType === 'pmg'}>
<div class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<svg
class="h-8 w-8 mx-auto mb-2 animate-pulse text-purple-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<p class="text-sm">Scanning for PMG servers...</p>
</div>
</Show>
<Show when={props.agentType !== 'pmg'}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<Loader class="h-4 w-4 animate-spin" />
<span>
Waiting for responses this can take up to a minute depending on your
network size.
</span>
</div>
</Show>
</Show>
{/* Discovered nodes list */}
<For each={filteredDiscoveredNodes()}>
{(server) => (
<Show when={props.agentType === 'pmg'}>
{/* PMG style */}
<div
class="bg-gradient-to-r from-purple-50 to-transparent dark:from-purple-900/20 dark:to-transparent border-l-4 border-purple-500 rounded-lg p-4 cursor-pointer hover:shadow-md transition-all"
onClick={() => handleDiscoveredNodeClick(server)}
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 min-w-0">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-purple-500 flex-shrink-0 mt-0.5"
>
<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>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{server.hostname || `PMG at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="text-gray-400 mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</div>
</Show>
)}
</For>
<For each={filteredDiscoveredNodes()}>
{(server) => (
<Show when={props.agentType !== 'pmg'}>
{/* PVE/PBS style */}
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => handleDiscoveredNodeClick(server)}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `${props.agentType === 'pve' ? 'Proxmox VE' : 'Backup Server'} at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="text-gray-400 mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</div>
</Show>
)}
</For>
</div>
</Show>
</div>
</div>
);
};

View file

@ -0,0 +1,320 @@
import { Component, Show, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import Lock from 'lucide-solid/icons/lock';
import type { VersionInfo } from '@/api/updates';
interface SecurityStatusInfo {
hasAuthentication: boolean;
apiTokenConfigured: boolean;
authUsername?: string;
configuredButPendingRestart?: boolean;
hasProxyAuth?: boolean;
proxyAuthUsername?: string;
proxyAuthIsAdmin?: boolean;
proxyAuthLogoutURL?: string;
deprecatedDisableAuth?: boolean;
}
interface SecurityAuthPanelProps {
securityStatus: Accessor<SecurityStatusInfo | null>;
securityStatusLoading: Accessor<boolean>;
versionInfo: Accessor<VersionInfo | null>;
authDisabledByEnv: Accessor<boolean>;
showQuickSecuritySetup: Accessor<boolean>;
setShowQuickSecuritySetup: Setter<boolean>;
showQuickSecurityWizard: Accessor<boolean>;
setShowQuickSecurityWizard: Setter<boolean>;
showPasswordModal: Accessor<boolean>;
setShowPasswordModal: Setter<boolean>;
hideLocalLogin: Accessor<boolean>;
hideLocalLoginLocked: () => boolean;
savingHideLocalLogin: Accessor<boolean>;
handleHideLocalLoginChange: (enabled: boolean) => Promise<void>;
loadSecurityStatus: () => Promise<void>;
}
export const SecurityAuthPanel: Component<SecurityAuthPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Show message when auth is disabled */}
<Show when={!props.securityStatus()?.hasAuthentication}>
<Card
padding="none"
class="overflow-hidden border border-amber-200 dark:border-amber-800"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-amber-50 to-amber-50 dark:from-amber-900/20 dark:to-amber-900/20 px-6 py-4 border-b border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-amber-100 dark:bg-amber-900/50 rounded-lg">
<svg
class="w-5 h-5 text-amber-600 dark:text-amber-400"
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>
</div>
<SectionHeader title="Authentication disabled" size="sm" class="flex-1" />
<Show
when={!props.authDisabledByEnv()}
fallback={
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-amber-300 text-amber-800 bg-amber-100/60 dark:border-amber-700 dark:text-amber-100 dark:bg-amber-900/40 whitespace-nowrap">
Controlled by DISABLE_AUTH
</span>
}
>
<button
type="button"
onClick={() => props.setShowQuickSecuritySetup(!props.showQuickSecuritySetup())}
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-800 bg-amber-100/50 hover:bg-amber-100 transition-colors dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/40 whitespace-nowrap"
>
Setup
</button>
</Show>
</div>
</div>
{/* Content */}
<div class="p-6">
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">
<Show
when={props.authDisabledByEnv()}
fallback={
<>
Authentication is currently disabled. Set up password authentication to protect
your Pulse instance.
</>
}
>
Authentication settings are locked by the legacy{' '}
<code class="font-mono text-xs text-amber-800 dark:text-amber-200">
DISABLE_AUTH
</code>{' '}
environment variable. Remove it from your deployment and restart Pulse before
enabling security from this page.
</Show>
</p>
<Show when={props.showQuickSecuritySetup() && !props.authDisabledByEnv()}>
<QuickSecuritySetup
onConfigured={() => {
props.setShowQuickSecuritySetup(false);
props.loadSecurityStatus();
}}
/>
</Show>
</div>
</Card>
</Show>
{/* Authentication */}
<Show
when={
!props.securityStatusLoading() &&
(props.securityStatus()?.hasAuthentication || props.securityStatus()?.apiTokenConfigured)
}
>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<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">
<Lock class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Authentication"
description="Password management and credential rotation"
size="sm"
class="flex-1"
/>
</div>
</div>
{/* Content */}
<div class="p-6">
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.setShowPasswordModal(true);
}}
class="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Change password
</button>
<Show
when={!props.authDisabledByEnv()}
fallback={
<span class="px-4 py-2 text-sm font-semibold border border-amber-300 text-amber-800 bg-amber-50 dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 rounded-lg">
Remove DISABLE_AUTH to rotate credentials
</span>
}
>
<button
type="button"
onClick={() => props.setShowQuickSecurityWizard(!props.showQuickSecurityWizard())}
class="px-4 py-2 text-sm font-medium 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-800 transition-colors"
>
Rotate credentials
</button>
</Show>
<div class="flex-1"></div>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">User:</span>{' '}
{props.securityStatus()?.authUsername || 'Not configured'}
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Hide local login form"
description="Hide the username/password form on the login page. Users will only see SSO options unless ?show_local=true is used."
checked={props.hideLocalLogin()}
onChange={(e: ToggleChangeEvent) =>
props.handleHideLocalLoginChange(e.currentTarget.checked)
}
disabled={props.hideLocalLoginLocked() || props.savingHideLocalLogin()}
locked={props.hideLocalLoginLocked()}
lockedMessage="This setting is managed by the PULSE_AUTH_HIDE_LOCAL_LOGIN environment variable"
/>
</div>
<Show when={!props.authDisabledByEnv() && props.showQuickSecurityWizard()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<QuickSecuritySetup
mode="rotate"
defaultUsername={props.securityStatus()?.authUsername || 'admin'}
onConfigured={() => {
props.setShowQuickSecurityWizard(false);
props.loadSecurityStatus();
}}
/>
</div>
</Show>
</div>
</Card>
</Show>
{/* Show pending restart message if configured but not loaded */}
<Show when={props.securityStatus()?.configuredButPendingRestart}>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-amber-600 dark:text-amber-400"
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>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-100">
Security Configured - Restart Required
</h4>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Security settings have been configured but the service needs to be restarted to
activate them.
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
After restarting, you'll need to log in with your saved credentials.
</p>
<div class="mt-4 bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">
How to restart Pulse:
</p>
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
<div class="space-y-2">
<p class="text-xs text-gray-700 dark:text-gray-300">
Type{' '}
<code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">update</code>{' '}
in your ProxmoxVE console
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 italic">
Or restart manually with: <code class="text-xs">systemctl restart pulse</code>
</p>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart your Docker container:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
docker restart pulse
</code>
</div>
</Show>
<Show
when={
props.versionInfo()?.deploymentType === 'systemd' ||
props.versionInfo()?.deploymentType === 'manual'
}
>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart the service:</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse
</code>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart the development server:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse-hot-dev
</code>
</div>
</Show>
<Show when={!props.versionInfo()?.deploymentType}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart Pulse using your deployment method
</p>
</div>
</Show>
</div>
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-xs text-green-700 dark:text-green-300">
<strong>Tip:</strong> Make sure you've saved your credentials before restarting!
</p>
</div>
</div>
</div>
</div>
</Show>
</div>
);
};

View file

@ -0,0 +1,157 @@
import { Component, Show, Accessor } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { SecurityPostureSummary } from './SecurityPostureSummary';
import Shield from 'lucide-solid/icons/shield';
import Info from 'lucide-solid/icons/info';
interface SecurityStatusInfo {
hasAuthentication: boolean;
oidcEnabled?: boolean;
hasProxyAuth?: boolean;
apiTokenConfigured: boolean;
exportProtected: boolean;
unprotectedExportAllowed?: boolean;
hasHTTPS?: boolean;
hasAuditLogging: boolean;
requiresAuth: boolean;
publicAccess?: boolean;
isPrivateNetwork?: boolean;
clientIP?: string;
proxyAuthUsername?: string;
proxyAuthIsAdmin?: boolean;
proxyAuthLogoutURL?: string;
}
interface SecurityOverviewPanelProps {
securityStatus: Accessor<SecurityStatusInfo | null>;
securityStatusLoading: Accessor<boolean>;
}
export const SecurityOverviewPanel: Component<SecurityOverviewPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Loading State */}
<Show when={props.securityStatusLoading()}>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-700 px-6 py-5 animate-pulse">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gray-300 dark:bg-gray-600 rounded-xl"></div>
<div class="flex-1 space-y-2">
<div class="h-5 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
</div>
<div class="text-right space-y-2">
<div class="h-8 bg-gray-300 dark:bg-gray-600 rounded w-16 ml-auto"></div>
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-12 ml-auto"></div>
</div>
</div>
</div>
<div class="p-6">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map(() => (
<div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3 mb-2"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
</div>
))}
</div>
</div>
</Card>
</Show>
{/* Security Summary */}
<Show when={!props.securityStatusLoading() && props.securityStatus()}>
<SecurityPostureSummary status={props.securityStatus()!} />
</Show>
{/* Proxy Auth Notice */}
<Show when={!props.securityStatusLoading() && props.securityStatus()?.hasProxyAuth}>
<Card
padding="none"
class="overflow-hidden border border-blue-200 dark:border-blue-800"
border={false}
>
<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-blue-200 dark:border-blue-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<Shield class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Proxy Authentication Active"
description="Requests are validated by an upstream proxy"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-4 text-sm text-blue-800 dark:text-blue-200 space-y-2">
<p>
The current proxied user is
<span class="font-semibold">
{props.securityStatus()?.proxyAuthUsername
? ` ${props.securityStatus()?.proxyAuthUsername}`
: ' available once a request is received'}
</span>
.
{props.securityStatus()?.proxyAuthIsAdmin && (
<span class="ml-1 inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-800 dark:text-blue-200">
Admin
</span>
)}
</p>
<div class="flex flex-wrap items-center gap-3 pt-2">
<Show when={props.securityStatus()?.proxyAuthLogoutURL}>
<a
href={props.securityStatus()?.proxyAuthLogoutURL}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-blue-300 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Proxy Logout
</a>
</Show>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/PROXY_AUTH.md"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg text-blue-600 dark:text-blue-300 hover:underline"
>
Read proxy auth guide
</a>
</div>
</div>
</Card>
</Show>
{/* Security Tips Card */}
<Show when={!props.securityStatusLoading() && props.securityStatus()}>
<Card
padding="md"
class="border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30"
border={false}
>
<div class="flex items-start gap-3">
<div class="p-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg flex-shrink-0">
<Info class="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-300 mb-1">Security Best Practices</p>
<ul class="space-y-0.5 list-disc list-inside">
<li>Enable HTTPS via a reverse proxy for encrypted connections</li>
<li>Use strong, unique passwords and rotate credentials regularly</li>
<li>Consider SSO/OIDC for enterprise authentication needs</li>
<li>Review API token scopes and remove unused tokens</li>
</ul>
</div>
</div>
</Card>
</Show>
</div>
);
};

View file

@ -1,6 +1,10 @@
import { Component, For, Show } from 'solid-js';
import { Component, For, Show, createMemo } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import Shield from 'lucide-solid/icons/shield';
import ShieldCheck from 'lucide-solid/icons/shield-check';
import ShieldAlert from 'lucide-solid/icons/shield-alert';
import CheckCircle from 'lucide-solid/icons/check-circle';
import XCircle from 'lucide-solid/icons/x-circle';
interface SecurityPostureSummaryProps {
status: {
@ -26,24 +30,28 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
label: 'Password login',
enabled: props.status.hasAuthentication,
description: props.status.hasAuthentication ? 'Active' : 'Not configured',
critical: true, // Critical security feature
},
{
key: 'oidc',
label: 'Single sign-on',
enabled: Boolean(props.status.oidcEnabled),
description: props.status.oidcEnabled ? 'OIDC configured' : 'Not configured',
critical: false,
},
{
key: 'proxy',
label: 'Proxy auth',
enabled: Boolean(props.status.hasProxyAuth),
description: props.status.hasProxyAuth ? 'Active' : 'Not configured',
critical: false,
},
{
key: 'token',
label: 'API token',
enabled: props.status.apiTokenConfigured,
description: props.status.apiTokenConfigured ? 'Active' : 'Not configured',
critical: false,
},
{
key: 'export',
@ -52,79 +60,138 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
description: props.status.unprotectedExportAllowed
? 'Unprotected'
: 'Token + passphrase required',
critical: true,
},
{
key: 'https',
label: 'HTTPS',
enabled: Boolean(props.status.hasHTTPS),
description: props.status.hasHTTPS ? 'Encrypted' : 'HTTP only',
critical: true,
},
{
key: 'audit',
label: 'Audit log',
enabled: props.status.hasAuditLogging,
description: props.status.hasAuditLogging ? 'Active' : 'Not enabled',
critical: false,
},
];
const badgeClasses = (enabled: boolean) =>
enabled
? 'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-200';
// Calculate security score
const securityScore = createMemo(() => {
const list = items();
const criticalItems = list.filter(i => i.critical);
const enabledCritical = criticalItems.filter(i => i.enabled).length;
const allItems = list.filter(i => i.enabled).length;
// Weight critical items more heavily
const criticalWeight = 0.7;
const optionalWeight = 0.3;
const criticalScore = criticalItems.length > 0 ? (enabledCritical / criticalItems.length) * criticalWeight : 0;
const optionalScore = list.length > 0 ? (allItems / list.length) * optionalWeight : 0;
return Math.round((criticalScore + optionalScore) * 100);
});
const scoreColor = () => {
const score = securityScore();
if (score >= 80) return 'from-green-500 to-emerald-600';
if (score >= 50) return 'from-amber-500 to-orange-600';
return 'from-red-500 to-rose-600';
};
const scoreLabel = () => {
const score = securityScore();
if (score >= 80) return 'Strong';
if (score >= 50) return 'Moderate';
return 'Weak';
};
const ScoreIcon = () => {
const score = securityScore();
if (score >= 80) return ShieldCheck;
if (score >= 50) return Shield;
return ShieldAlert;
};
return (
<Card padding="md" class="border border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-3">
<SectionHeader title="Security posture" size="sm" class="flex-1" />
<div class="flex items-center gap-2">
<span
class={`${
props.status.publicAccess && !props.status.isPrivateNetwork
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'
} px-3 py-1 text-xs font-semibold rounded-full`}
>
{props.status.publicAccess && !props.status.isPrivateNetwork
? 'Public network access'
: 'Private network access'}
</span>
<Show when={props.status.clientIP}>
<span class="hidden md:inline-flex items-center px-3 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
IP: {props.status.clientIP}
</span>
</Show>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header with Security Score */}
<div class={`bg-gradient-to-r ${scoreColor()} px-6 py-5`}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
{(() => {
const Icon = ScoreIcon();
return <Icon class="w-6 h-6 text-white" />;
})()}
</div>
<div>
<h2 class="text-lg font-bold text-white">Security Posture</h2>
<p class="text-sm text-white/80">
{props.status.publicAccess && !props.status.isPrivateNetwork
? 'Public network access detected'
: 'Private network access'}
</p>
</div>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">{securityScore()}%</div>
<div class="text-sm text-white/80">{scoreLabel()}</div>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{/* Security Items Grid */}
<div class="p-6">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<For each={items()}>
{(item) => (
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-800">
<div class={`rounded-xl border p-4 transition-all ${item.enabled
? 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-900/20'
: item.critical
? 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20'
: 'border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50'
}`}>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{item.label}
</span>
<span class={badgeClasses(item.enabled)}>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={item.enabled ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'}
/>
</svg>
{item.enabled ? 'On' : 'Off'}
</span>
<Show when={item.enabled} fallback={
<XCircle class={`w-5 h-5 ${item.critical ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`} />
}>
<CheckCircle class="w-5 h-5 text-green-500" />
</Show>
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-600 dark:text-gray-400">
{item.description}
</p>
<Show when={item.critical && !item.enabled}>
<span class="text-[10px] font-medium text-red-600 dark:text-red-400 uppercase">
Critical
</span>
</Show>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{item.description}
</p>
</div>
)}
</For>
</div>
{/* Client IP Badge */}
<Show when={props.status.clientIP}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end">
<span class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
Your IP: {props.status.clientIP}
</span>
</div>
</Show>
</div>
</Card>
);

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ import { notificationStore } from '@/stores/notifications';
import type { SecurityStatus } from '@/types/config';
import type { HostLookupResponse } from '@/types/api';
import type { APITokenRecord } from '@/api/security';
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE } from '@/constants/apiScopes';
import { copyToClipboard } from '@/utils/clipboard';
import { getPulseBaseUrl } from '@/utils/url';
import { logger } from '@/utils/logger';
@ -108,6 +108,8 @@ export const UnifiedAgents: Component = () => {
const [lookupError, setLookupError] = createSignal<string | null>(null);
const [lookupLoading, setLookupLoading] = createSignal(false);
const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring
const [enableKubernetes, setEnableKubernetes] = createSignal(false); // Default to false - user must opt-in for Kubernetes monitoring
const [enableProxmox, setEnableProxmox] = createSignal(false); // For Proxmox VE/PBS nodes - creates API token and auto-registers
const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
createEffect(() => {
@ -182,15 +184,15 @@ export const UnifiedAgents: Component = () => {
setIsGeneratingToken(true);
try {
const desiredName = tokenName().trim() || buildDefaultTokenName();
// Generate token with BOTH scopes
const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE];
// Generate token with unified agent reporting scopes
const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE];
const { token, record } = await SecurityAPI.createToken(desiredName, scopes);
setCurrentToken(token);
setLatestRecord(record);
setTokenName('');
setConfirmedNoToken(false);
notificationStore.success('Token generated with Host and Docker permissions.', 4000);
notificationStore.success('Token generated with Host, Docker, and Kubernetes permissions.', 4000);
} catch (err) {
logger.error('Failed to generate agent token', err);
notificationStore.error('Failed to generate agent token. Confirm you are signed in as an administrator.', 6000);
@ -236,6 +238,8 @@ export const UnifiedAgents: Component = () => {
};
const getDockerFlag = () => enableDocker() ? ' --enable-docker' : '';
const getKubernetesFlag = () => enableKubernetes() ? ' --enable-kubernetes' : '';
const getProxmoxFlag = () => enableProxmox() ? ' --enable-proxmox' : '';
const getInsecureFlag = () => insecureMode() ? ' --insecure' : '';
const getCurlInsecureFlag = () => insecureMode() ? '-k' : '';
@ -342,19 +346,32 @@ export const UnifiedAgents: Component = () => {
});
const hasRemovedDockerHosts = createMemo(() => removedDockerHosts().length > 0);
const kubernetesClusters = createMemo(() => {
const clusters = state.kubernetesClusters || [];
return clusters.slice().sort((a, b) => (a.displayName || a.name || a.id).localeCompare(b.displayName || b.name || b.id));
});
const removedKubernetesClusters = createMemo(() => {
const removed = state.removedKubernetesClusters || [];
return removed.sort((a, b) => b.removedAt - a.removedAt);
});
const hasRemovedKubernetesClusters = createMemo(() => removedKubernetesClusters().length > 0);
const getUpgradeCommand = (_hostname: string) => {
const token = resolvedToken();
return `curl ${getCurlInsecureFlag()}-fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${token}${getInsecureFlag()}`;
};
const handleRemoveAgent = async (id: string, type: 'host' | 'docker') => {
const handleRemoveAgent = async (id: string, type: 'host' | 'docker' | 'kubernetes') => {
if (!confirm('Are you sure you want to remove this agent? This will stop monitoring but will not uninstall the agent from the remote machine.')) return;
try {
if (type === 'host') {
await MonitoringAPI.deleteHostAgent(id);
} else {
} else if (type === 'docker') {
await MonitoringAPI.deleteDockerHost(id);
} else {
await MonitoringAPI.deleteKubernetesCluster(id);
}
notificationStore.success('Agent removed from Pulse');
} catch (err) {
@ -373,6 +390,16 @@ export const UnifiedAgents: Component = () => {
}
};
const handleAllowKubernetesReenroll = async (clusterId: string, name?: string) => {
try {
await MonitoringAPI.allowKubernetesClusterReenroll(clusterId);
notificationStore.success(`Re-enrollment allowed for ${name || clusterId}. Restart the agent to reconnect.`);
} catch (err) {
logger.error('Failed to allow kubernetes re-enrollment', err);
notificationStore.error('Failed to allow kubernetes re-enrollment');
}
};
return (
<div class="space-y-6">
<Card padding="lg" class="space-y-5">
@ -389,7 +416,7 @@ export const UnifiedAgents: Component = () => {
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh token scoped for both Host and Docker monitoring.
Create a fresh token scoped for Host, Docker, and Kubernetes monitoring.
</p>
</div>
@ -451,26 +478,57 @@ export const UnifiedAgents: Component = () => {
<Show when={commandsUnlocked()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableDocker()}
onChange={(e) => setEnableDocker(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Enable Docker monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="Skip TLS certificate verification (for self-signed certificates)">
<input
type="checkbox"
checked={insecureMode()}
onChange={(e) => setInsecureMode(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Skip certificate verification
</label>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableDocker()}
onChange={(e) => setEnableDocker(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Docker monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={enableKubernetes()}
onChange={(e) => setEnableKubernetes(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Kubernetes monitoring
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="For Proxmox VE/PBS nodes - auto-creates API token and registers the node">
<input
type="checkbox"
checked={enableProxmox()}
onChange={(e) => setEnableProxmox(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Proxmox setup
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer" title="Skip TLS certificate verification (for self-signed certificates)">
<input
type="checkbox"
checked={insecureMode()}
onChange={(e) => setInsecureMode(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
Skip TLS verify
</label>
</div>
</div>
<Show when={enableProxmox()}>
<div class="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<p class="font-medium">Proxmox auto-setup enabled</p>
<p class="text-xs mt-1 text-blue-700 dark:text-blue-300">
The agent will create a <code class="bg-blue-100 dark:bg-blue-900/40 px-1 rounded">pulse-monitor</code> user and API token on the Proxmox node,
then register it with Pulse automatically. Includes temperature monitoring.
</p>
</div>
</Show>
</div>
<div class="space-y-4">
@ -490,25 +548,30 @@ export const UnifiedAgents: Component = () => {
if (insecureMode() && cmd.includes('curl -fsSL')) {
cmd = cmd.replace('curl -fsSL', 'curl -kfsSL');
}
// For bash scripts (not PowerShell), append flags directly
const isBashScript = !cmd.includes('$env:') && !cmd.includes('irm');
// Append docker flag if enabled
if (enableDocker()) {
// For PowerShell, we need to handle the env var or args differently
if (cmd.includes('$env:PULSE_URL')) {
// Env var style: add $env:PULSE_ENABLE_DOCKER="true";
cmd = `$env:PULSE_ENABLE_DOCKER="true"; ` + cmd;
} else if (cmd.includes('irm')) {
// Simple irm style: no args passed to script directly in this snippet style
// Actually, the simple irm style relies on prompts, so flags don't apply directly unless we change the snippet
// But for the bash script, we append flags
if (!cmd.includes('irm')) {
cmd += getDockerFlag();
}
} else {
} else if (isBashScript) {
cmd += getDockerFlag();
}
}
// Append kubernetes flag if enabled
if (enableKubernetes()) {
if (cmd.includes('$env:PULSE_URL')) {
cmd = `$env:PULSE_ENABLE_KUBERNETES="true"; ` + cmd;
} else if (isBashScript) {
cmd += getKubernetesFlag();
}
}
// Append proxmox flag if enabled (Linux only - Proxmox doesn't run on Windows/macOS)
if (enableProxmox() && isBashScript) {
cmd += getProxmoxFlag();
}
// Append insecure flag for agent if enabled
if (insecureMode() && !cmd.includes('$env:') && !cmd.includes('irm')) {
if (insecureMode() && isBashScript) {
cmd += getInsecureFlag();
}
return cmd;
@ -779,6 +842,68 @@ export const UnifiedAgents: Component = () => {
</Card>
</Card>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Kubernetes Clusters</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Kubernetes clusters currently reporting to Pulse.
</p>
</div>
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
<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 scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Version</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last Seen</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<For each={kubernetesClusters()} fallback={
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No Kubernetes clusters reporting yet.
</td>
</tr>
}>
{(cluster) => (
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${connectedFromStatus(cluster.status)
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{cluster.status}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{cluster.version || cluster.agentVersion || '—'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
<button
onClick={() => handleRemoveAgent(cluster.id, 'kubernetes')}
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
Remove
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Card>
</Card>
<Show when={hasRemovedDockerHosts()}>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
@ -827,6 +952,55 @@ export const UnifiedAgents: Component = () => {
</Card>
</Card>
</Show>
<Show when={hasRemovedKubernetesClusters()}>
<Card padding="lg" class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Removed Kubernetes Clusters</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Kubernetes clusters that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again.
</p>
</div>
<Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
<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 scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Cluster ID</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Removed</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<For each={removedKubernetesClusters()}>
{(cluster) => (
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{cluster.displayName || cluster.name || 'Unknown'}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono text-xs">
{cluster.id.slice(0, 8)}...
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{formatRelativeTime(cluster.removedAt)}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
<button
onClick={() => handleAllowKubernetesReenroll(cluster.id, cluster.displayName || cluster.name)}
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
Allow re-enroll
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Card>
</Card>
</Show>
</div >
);
};

View file

@ -0,0 +1,654 @@
import { Component, Show, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import CheckCircle from 'lucide-solid/icons/check-circle';
import ArrowRight from 'lucide-solid/icons/arrow-right';
import Package from 'lucide-solid/icons/package';
import Download from 'lucide-solid/icons/download';
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
interface UpdatesSettingsPanelProps {
versionInfo: Accessor<VersionInfo | null>;
updateInfo: Accessor<UpdateInfo | null>;
checkingForUpdates: Accessor<boolean>;
updateChannel: Accessor<'stable' | 'rc'>;
setUpdateChannel: Setter<'stable' | 'rc'>;
autoUpdateEnabled: Accessor<boolean>;
setAutoUpdateEnabled: Setter<boolean>;
autoUpdateCheckInterval: Accessor<number>;
setAutoUpdateCheckInterval: Setter<number>;
autoUpdateTime: Accessor<string>;
setAutoUpdateTime: Setter<string>;
checkForUpdates: () => Promise<void>;
setHasUnsavedChanges: Setter<boolean>;
// Update installation props
updatePlan: Accessor<UpdatePlan | null>;
onInstallUpdate: () => void;
isInstalling: Accessor<boolean>;
}
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<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">
<RefreshCw class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Updates"
description="Version checking and automatic update configuration"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-6">
<section class="space-y-4">
<div class="space-y-4">
{/* Version Status Section */}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Version Grid */}
<div class={`grid gap-px ${props.updateInfo()?.available ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
{/* Current Version */}
<div class="bg-gray-50 dark:bg-gray-800/60 p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
<Package class="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Current Version
</p>
<p class="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100 truncate">
{props.versionInfo()?.version || 'Loading...'}
</p>
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
<Show when={props.versionInfo()?.isDevelopment}>
<span class="inline-flex items-center px-2 py-0.5 text-[10px] font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
Development
</span>
</Show>
<Show when={props.versionInfo()?.isDocker}>
<span class="inline-flex items-center px-2 py-0.5 text-[10px] font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
Docker
</span>
</Show>
<Show when={props.versionInfo()?.isSourceBuild}>
<span class="inline-flex items-center px-2 py-0.5 text-[10px] font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
Source
</span>
</Show>
</div>
</div>
</div>
</div>
{/* Update Status / Arrow */}
<Show when={props.updateInfo()?.available}>
<div class="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/30 dark:to-emerald-900/30 p-4 flex items-center justify-center">
<div class="flex flex-col items-center gap-1.5">
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<ArrowRight class="w-5 h-5" />
</div>
<span class="text-xs font-semibold text-green-700 dark:text-green-300 uppercase tracking-wide">
Update Ready
</span>
</div>
</div>
</Show>
{/* Latest Version / Status */}
<div class={`p-4 ${props.updateInfo()?.available
? 'bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20'
: 'bg-gray-50 dark:bg-gray-800/60'
}`}>
<div class="flex items-start gap-3">
<div class={`p-2 rounded-lg ${props.updateInfo()?.available
? 'bg-green-100 dark:bg-green-900/50'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
<Show when={props.updateInfo()?.available} fallback={
<CheckCircle class="w-5 h-5 text-gray-500 dark:text-gray-400" />
}>
<CheckCircle class="w-5 h-5 text-green-600 dark:text-green-400" />
</Show>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{props.updateInfo()?.available ? 'Available' : 'Status'}
</p>
<Show when={props.updateInfo()?.available} fallback={
<p class="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100">
Up to date
</p>
}>
<p class="mt-1 text-lg font-bold text-green-700 dark:text-green-300">
{props.updateInfo()?.latestVersion}
</p>
<Show when={props.updateInfo()?.releaseDate}>
<p class="mt-0.5 text-xs text-green-600 dark:text-green-400">
Released {new Date(props.updateInfo()!.releaseDate).toLocaleDateString()}
</p>
</Show>
</Show>
</div>
</div>
</div>
</div>
{/* Check for updates button */}
<div class="bg-white dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<p class="text-xs text-gray-500 dark:text-gray-400">
<Show when={props.autoUpdateEnabled()}>
Auto-check enabled
</Show>
<Show when={!props.autoUpdateEnabled()}>
Manual checks only
</Show>
</p>
<button
type="button"
onClick={props.checkForUpdates}
disabled={
props.checkingForUpdates() ||
props.versionInfo()?.isDocker ||
props.versionInfo()?.isSourceBuild
}
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${props.versionInfo()?.isDocker || props.versionInfo()?.isSourceBuild
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{props.checkingForUpdates() ? (
<>
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
Checking...
</>
) : (
<>
<RefreshCw class="w-4 h-4" />
Check Now
</>
)}
</button>
</div>
</div>
{/* Docker installation notice */}
<Show when={props.versionInfo()?.isDocker && !props.updateInfo()?.available}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Docker Installation:</strong> Updates are managed through Docker. Pull
the latest image to update.
</p>
</div>
</Show>
{/* Source build notice */}
<Show when={props.versionInfo()?.isSourceBuild}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Built from source:</strong> Pull the latest code from git and rebuild to
update.
</p>
</div>
</Show>
{/* Warning message */}
<Show when={Boolean(props.updateInfo()?.warning)}>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p class="text-xs text-amber-800 dark:text-amber-200">
{props.updateInfo()?.warning}
</p>
</div>
</Show>
{/* Update available */}
<Show when={props.updateInfo()?.available}>
<div class="rounded-xl border border-green-200 dark:border-green-700 overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/10">
{/* Header */}
<div class="px-5 py-4 border-b border-green-200 dark:border-green-800/50 bg-gradient-to-r from-green-100/80 to-emerald-100/50 dark:from-green-900/40 dark:to-emerald-900/30">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="p-2 bg-gradient-to-br from-green-500 to-emerald-600 rounded-lg shadow-lg shadow-green-500/20">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div>
<h4 class="text-base font-semibold text-green-900 dark:text-green-100">
Update Available
</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Version {props.updateInfo()?.latestVersion} is ready to install
</p>
</div>
</div>
{/* Automated Install Button */}
<Show when={props.updatePlan()?.canAutoUpdate}>
<button
type="button"
onClick={props.onInstallUpdate}
disabled={props.isInstalling()}
class={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${props.isInstalling()
? 'bg-green-400 dark:bg-green-600 text-white cursor-not-allowed'
: 'bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg shadow-green-500/25 hover:shadow-green-500/40'
}`}
>
<Show when={props.isInstalling()} fallback={
<>
<Download class="w-4 h-4" />
Install Update
</>
}>
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
Installing...
</Show>
</button>
</Show>
</div>
</div>
{/* Installation Steps */}
<div class="p-5 space-y-4">
{/* Manual Steps Header */}
<Show when={!props.updatePlan()?.canAutoUpdate}>
<div class="text-sm font-medium text-green-800 dark:text-green-200 mb-3">
Follow these steps to update manually:
</div>
</Show>
<Show when={props.updatePlan()?.canAutoUpdate}>
<div class="text-sm text-green-700 dark:text-green-300 mb-3">
Click "Install Update" above for automatic installation, or update manually:
</div>
</Show>
{/* ProxmoxVE LXC Installation */}
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Open your Pulse LXC console
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Run the update command:
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
update
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('update')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<p class="ml-8 text-xs text-green-600 dark:text-green-400">
The script will automatically download and install the latest version.
</p>
</div>
</Show>
{/* Docker Installation */}
<Show when={props.versionInfo()?.deploymentType === 'docker' || (!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker)}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Pull the latest image
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
docker pull rcourtman/pulse:latest
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('docker pull rcourtman/pulse:latest')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Restart the container
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
docker restart pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('docker restart pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<p class="ml-8 text-xs text-green-600 dark:text-green-400">
Or use Docker Compose: <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">docker-compose pull && docker-compose up -d</code>
</p>
</div>
</Show>
{/* Systemd/Manual Installation */}
<Show when={props.versionInfo()?.deploymentType === 'systemd' || props.versionInfo()?.deploymentType === 'manual'}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Stop the service
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
sudo systemctl stop pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('sudo systemctl stop pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Download and extract the new version
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700 whitespace-pre-wrap break-all">
{`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz
sudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`}
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText(`curl -LO https://github.com/rcourtman/Pulse/releases/download/${props.updateInfo()?.latestVersion}/pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz\nsudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /usr/local/bin pulse`)}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">3</span>
Start the service
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
sudo systemctl start pulse
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('sudo systemctl start pulse')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</Show>
{/* Development Installation */}
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">1</span>
Pull the latest changes
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
git pull origin main
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('git pull origin main')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<div class="flex items-center gap-2 text-sm font-medium text-green-800 dark:text-green-200">
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 text-xs font-bold text-green-700 dark:text-green-300">2</span>
Rebuild and restart
</div>
<div class="ml-8 relative group">
<code class="block p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm font-mono text-green-400 border border-gray-700">
make build && make run
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText('make build && make run')}
class="absolute top-2 right-2 p-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy to clipboard"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</Show>
</div>
{/* Release notes footer */}
<Show when={props.updateInfo()?.releaseNotes}>
<div class="px-5 py-3 border-t border-green-200 dark:border-green-800/50 bg-white/50 dark:bg-gray-900/30">
<details class="group">
<summary class="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300 cursor-pointer hover:text-green-800 dark:hover:text-green-200 transition-colors">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
View Release Notes
</summary>
<pre class="mt-3 p-4 text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 max-h-64 overflow-y-auto">
{props.updateInfo()?.releaseNotes}
</pre>
</details>
</div>
</Show>
</div>
</Show>
{/* Update settings */}
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-5">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Update Preferences
</h4>
{/* Update Channel */}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
props.setUpdateChannel('stable');
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'stable'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div class="flex items-center gap-3">
<div class={`p-2 rounded-lg ${props.updateChannel() === 'stable'
? 'bg-green-100 dark:bg-green-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<svg class={`w-5 h-5 ${props.updateChannel() === 'stable'
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class={`text-sm font-semibold ${props.updateChannel() === 'stable'
? 'text-green-900 dark:text-green-100'
: 'text-gray-900 dark:text-gray-100'
}`}>Stable</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Production-ready releases
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => {
props.setUpdateChannel('rc');
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class={`p-4 rounded-xl border-2 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed ${props.updateChannel() === 'rc'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div class="flex items-center gap-3">
<div class={`p-2 rounded-lg ${props.updateChannel() === 'rc'
? 'bg-purple-100 dark:bg-purple-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<svg class={`w-5 h-5 ${props.updateChannel() === 'rc'
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-500 dark:text-gray-400'
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div>
<p class={`text-sm font-semibold ${props.updateChannel() === 'rc'
? 'text-purple-900 dark:text-purple-100'
: 'text-gray-900 dark:text-gray-100'
}`}>Release Candidate</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Preview upcoming features
</p>
</div>
</div>
</button>
</div>
{/* Auto Update Toggle */}
<div class="p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/30">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Automatic Update Checks
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Periodically check for new versions (installation is always manual)
</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={props.autoUpdateEnabled()}
onChange={(e) => {
props.setAutoUpdateEnabled(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Auto update options (shown when enabled) */}
<Show when={props.autoUpdateEnabled()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Check Interval */}
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Check Interval
</label>
<select
value={props.autoUpdateCheckInterval()}
onChange={(e) => {
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
props.setHasUnsavedChanges(true);
}}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="6">Every 6 hours</option>
<option value="12">Every 12 hours</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
</select>
</div>
{/* Check Time */}
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Preferred Time
</label>
<input
type="time"
value={props.autoUpdateTime()}
onChange={(e) => {
props.setAutoUpdateTime(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
</div>
</Show>
</div>
</div>
</div>
</section>
</div>
</Card>
</div>
);
};

View file

@ -177,7 +177,7 @@ describe('UnifiedAgents token generation', () => {
{ interval: 0 },
);
expect(notificationSuccessMock).toHaveBeenCalledWith(
'Token generated with Host and Docker permissions.',
'Token generated with Host, Docker, and Kubernetes permissions.',
4000,
);
});
@ -379,15 +379,15 @@ describe('UnifiedAgents platform commands', () => {
const generateButton = screen.getByRole('button', { name: /Generate token/i });
fireEvent.click(generateButton);
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
await waitFor(() => {
expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Docker monitoring')).toBeInTheDocument();
});
// Docker monitoring is disabled by default
const checkbox = screen.getByRole('checkbox', { name: /Enable Docker monitoring/i });
expect(checkbox).not.toBeChecked();
// Docker monitoring is disabled by default
const checkbox = screen.getByRole('checkbox', { name: /Docker monitoring/i });
expect(checkbox).not.toBeChecked();
// Enable it
fireEvent.click(checkbox);

View file

@ -0,0 +1,145 @@
import { Component, createSignal, Show } from 'solid-js';
import { WelcomeStep } from './steps/WelcomeStep';
import { SecurityStep } from './steps/SecurityStep';
import { ConnectStep } from './steps/ConnectStep';
import { FeaturesStep } from './steps/FeaturesStep';
import { CompleteStep } from './steps/CompleteStep';
import { StepIndicator } from './StepIndicator';
export type WizardStep = 'welcome' | 'security' | 'connect' | 'features' | 'complete';
export interface WizardState {
// Security
username: string;
password: string;
apiToken: string;
// Node
nodeAdded: boolean;
nodeName: string;
// Features
aiEnabled: boolean;
autoUpdatesEnabled: boolean;
}
interface SetupWizardProps {
onComplete: () => void;
bootstrapToken?: string;
isUnlocked?: boolean;
}
export const SetupWizard: Component<SetupWizardProps> = (props) => {
const [currentStep, setCurrentStep] = createSignal<WizardStep>('welcome');
const [wizardState, setWizardState] = createSignal<WizardState>({
username: 'admin',
password: '',
apiToken: '',
nodeAdded: false,
nodeName: '',
aiEnabled: false,
autoUpdatesEnabled: true,
});
const [bootstrapToken, setBootstrapToken] = createSignal(props.bootstrapToken || '');
const [isUnlocked, setIsUnlocked] = createSignal(props.isUnlocked || false);
const steps: WizardStep[] = ['welcome', 'security', 'connect', 'features', 'complete'];
const currentStepIndex = () => steps.indexOf(currentStep());
const nextStep = () => {
const idx = currentStepIndex();
if (idx < steps.length - 1) {
setCurrentStep(steps[idx + 1]);
}
};
const prevStep = () => {
const idx = currentStepIndex();
if (idx > 0) {
setCurrentStep(steps[idx - 1]);
}
};
const updateState = (updates: Partial<WizardState>) => {
setWizardState(prev => ({ ...prev, ...updates }));
};
const skipToComplete = () => {
setCurrentStep('complete');
};
return (
<div
class="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900 flex flex-col"
role="main"
aria-label="Pulse Setup Wizard"
>
{/* Background decoration */}
<div class="fixed inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl" />
<div class="absolute top-1/2 -left-40 w-80 h-80 bg-indigo-500/20 rounded-full blur-3xl" />
<div class="absolute -bottom-40 right-1/3 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl" />
</div>
{/* Step indicator - only show after welcome */}
<Show when={currentStep() !== 'welcome' && currentStep() !== 'complete'}>
<div class="relative z-10 pt-8 px-4" role="navigation" aria-label="Setup progress">
<StepIndicator
steps={['Security', 'Connect', 'Features']}
currentStep={currentStepIndex() - 1}
/>
</div>
</Show>
{/* Main content */}
<div class="flex-1 flex items-center justify-center p-4 relative z-10">
<div class="w-full max-w-2xl" role="region" aria-live="polite">
<Show when={currentStep() === 'welcome'}>
<WelcomeStep
onNext={nextStep}
bootstrapToken={bootstrapToken()}
setBootstrapToken={setBootstrapToken}
isUnlocked={isUnlocked()}
setIsUnlocked={setIsUnlocked}
/>
</Show>
<Show when={currentStep() === 'security'}>
<SecurityStep
state={wizardState()}
updateState={updateState}
bootstrapToken={bootstrapToken()}
onNext={nextStep}
onBack={prevStep}
/>
</Show>
<Show when={currentStep() === 'connect'}>
<ConnectStep
state={wizardState()}
updateState={updateState}
onNext={nextStep}
onBack={prevStep}
onSkip={skipToComplete}
/>
</Show>
<Show when={currentStep() === 'features'}>
<FeaturesStep
state={wizardState()}
updateState={updateState}
onNext={nextStep}
onBack={prevStep}
/>
</Show>
<Show when={currentStep() === 'complete'}>
<CompleteStep
state={wizardState()}
onComplete={props.onComplete}
/>
</Show>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,37 @@
import { Component } from 'solid-js';
interface StepIndicatorProps {
steps: string[];
currentStep: number;
}
export const StepIndicator: Component<StepIndicatorProps> = (props) => {
return (
<div class="flex items-center justify-center gap-2">
{props.steps.map((step, index) => (
<div class="flex items-center">
<div class={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${index < props.currentStep
? 'bg-green-500/20 text-green-300'
: index === props.currentStep
? 'bg-blue-500/30 text-white border border-blue-400/50'
: 'bg-white/10 text-white/50'
}`}>
<span class={`w-5 h-5 flex items-center justify-center rounded-full text-xs ${index < props.currentStep
? 'bg-green-500 text-white'
: index === props.currentStep
? 'bg-blue-500 text-white'
: 'bg-white/20 text-white/50'
}`}>
{index < props.currentStep ? '✓' : index + 1}
</span>
<span class="hidden sm:inline">{step}</span>
</div>
{index < props.steps.length - 1 && (
<div class={`w-8 h-0.5 mx-1 ${index < props.currentStep ? 'bg-green-500/50' : 'bg-white/20'
}`} />
)}
</div>
))}
</div>
);
};

View file

@ -0,0 +1,3 @@
export { SetupWizard } from './SetupWizard';
export { StepIndicator } from './StepIndicator';
export type { WizardState, WizardStep } from './SetupWizard';

View file

@ -0,0 +1,155 @@
import { Component, createSignal } from 'solid-js';
import { copyToClipboard } from '@/utils/clipboard';
import { getPulseBaseUrl } from '@/utils/url';
import type { WizardState } from '../SetupWizard';
interface CompleteStepProps {
state: WizardState;
onComplete: () => void;
}
export const CompleteStep: Component<CompleteStepProps> = (props) => {
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
const handleCopy = async (type: 'password' | 'token') => {
const value = type === 'password' ? props.state.password : props.state.apiToken;
const success = await copyToClipboard(value);
if (success) {
setCopied(type);
setTimeout(() => setCopied(null), 2000);
}
};
const downloadCredentials = () => {
const baseUrl = getPulseBaseUrl();
const content = `Pulse Credentials
==================
Generated: ${new Date().toISOString()}
Web Login:
----------
URL: ${baseUrl}
Username: ${props.state.username}
Password: ${props.state.password}
API Token:
----------
${props.state.apiToken}
Example: curl -H "X-API-Token: ${props.state.apiToken}" ${baseUrl}/api/state
Keep these credentials secure!
`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-credentials-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div class="text-center">
{/* Success animation */}
<div class="mb-8">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-2xl shadow-green-500/30 mb-6">
<svg class="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-3xl font-bold text-white mb-3">
You're All Set! 🎉
</h1>
<p class="text-xl text-green-200/80">
Pulse is ready to monitor your infrastructure
</p>
</div>
{/* Credentials box */}
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 p-6 text-left mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Your Credentials</h3>
<button
onClick={downloadCredentials}
class="text-sm text-blue-300 hover:text-blue-200 flex items-center gap-1"
>
<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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download
</button>
</div>
<div class="space-y-3">
{/* Username */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">Username</div>
<div class="text-white font-mono">{props.state.username}</div>
</div>
{/* Password */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">Password</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-sm break-all">{props.state.password}</code>
<button
onClick={() => handleCopy('password')}
class="ml-2 p-1.5 hover:bg-white/10 rounded transition-all"
>
{copied() === 'password' ? '✓' : '📋'}
</button>
</div>
</div>
{/* API Token */}
<div class="bg-black/20 rounded-xl p-3">
<div class="text-xs text-white/60 mb-1">API Token</div>
<div class="flex items-center justify-between">
<code class="text-white font-mono text-xs break-all">{props.state.apiToken}</code>
<button
onClick={() => handleCopy('token')}
class="ml-2 p-1.5 hover:bg-white/10 rounded transition-all"
>
{copied() === 'token' ? '✓' : '📋'}
</button>
</div>
</div>
</div>
<div class="mt-4 p-3 bg-amber-500/20 border border-amber-400/30 rounded-xl">
<p class="text-amber-200 text-sm">
Save these now they won't be shown again!
</p>
</div>
</div>
{/* Quick links */}
<div class="grid grid-cols-3 gap-3 mb-8">
<a href="/proxmox/overview" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2">📊</div>
<div class="text-sm text-white/80">Dashboard</div>
</a>
<a href="/settings/proxmox" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2"></div>
<div class="text-sm text-white/80">Add More Nodes</div>
</a>
<a href="/settings/system-ai" class="bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl p-4 text-center transition-all">
<div class="text-2xl mb-2">🤖</div>
<div class="text-sm text-white/80">Configure AI</div>
</a>
</div>
{/* Launch button */}
<button
onClick={props.onComplete}
class="w-full py-4 px-8 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white text-lg font-medium rounded-xl transition-all shadow-lg shadow-green-500/25"
>
Launch Pulse
</button>
</div>
);
};

View file

@ -0,0 +1,293 @@
import { Component, createSignal, For, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
import type { WizardState } from '../SetupWizard';
interface ConnectStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
type Platform = 'proxmox' | 'docker' | 'kubernetes';
interface DiscoveredNode {
ip: string;
port: number;
type: string;
hostname?: string;
}
export const ConnectStep: Component<ConnectStepProps> = (props) => {
const [selectedPlatform, setSelectedPlatform] = createSignal<Platform | null>(null);
const [isScanning, setIsScanning] = createSignal(false);
const [discoveredNodes, setDiscoveredNodes] = createSignal<DiscoveredNode[]>([]);
const [showManualForm, setShowManualForm] = createSignal(false);
const [isConnecting, setIsConnecting] = createSignal(false);
// Manual form fields
const [host, setHost] = createSignal('');
const [port, _setPort] = createSignal('8006');
const [tokenId, setTokenId] = createSignal('');
const [tokenSecret, setTokenSecret] = createSignal('');
const platforms = [
{ id: 'proxmox' as Platform, name: 'Proxmox VE', icon: '🖥️', desc: 'Hypervisor & containers', ports: ['8006'] },
{ id: 'docker' as Platform, name: 'Docker', icon: '🐳', desc: 'Container hosts', ports: ['2375', '2376'] },
{ id: 'kubernetes' as Platform, name: 'Kubernetes', icon: '☸️', desc: 'Container orchestration', ports: ['6443'] },
];
const runDiscovery = async () => {
setIsScanning(true);
setDiscoveredNodes([]);
try {
const response = await fetch('/api/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subnet: 'auto' }),
});
if (!response.ok) throw new Error('Discovery failed');
// Poll for results
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 2000));
const results = await fetch('/api/discover/results');
if (results.ok) {
const data = await results.json();
if (data.nodes && data.nodes.length > 0) {
setDiscoveredNodes(data.nodes.filter((n: DiscoveredNode) =>
selectedPlatform() === 'proxmox' ? ['pve', 'pbs', 'pmg'].includes(n.type) : true
));
break;
}
}
}
if (discoveredNodes().length === 0) {
showSuccess('Scan complete - no nodes found. Try manual setup.');
}
} catch (_error) {
showError('Discovery failed. Try manual setup.');
} finally {
setIsScanning(false);
}
};
const connectNode = async (node?: DiscoveredNode) => {
setIsConnecting(true);
try {
const nodeData = node ? {
type: node.type === 'pbs' ? 'pbs' : node.type === 'pmg' ? 'pmg' : 'pve',
name: node.hostname || node.ip,
host: node.ip,
port: node.port,
} : {
type: 'pve',
name: host().replace(/:\d+$/, ''),
host: host(),
port: parseInt(port()),
tokenId: tokenId(),
tokenValue: tokenSecret(),
};
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodeData),
});
if (!response.ok) throw new Error(await response.text());
props.updateState({ nodeAdded: true, nodeName: nodeData.name });
showSuccess(`Connected to ${nodeData.name}!`);
props.onNext();
} catch (error) {
showError(`Connection failed: ${error}`);
} finally {
setIsConnecting(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Connect Your Infrastructure</h2>
<p class="text-white/70 mt-1">Add monitoring for your systems</p>
</div>
<div class="p-6">
{/* Platform selection */}
<Show when={!selectedPlatform()}>
<div class="grid grid-cols-3 gap-4 mb-6">
<For each={platforms}>
{(platform) => (
<button
onClick={() => setSelectedPlatform(platform.id)}
class="bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 rounded-xl p-4 text-center transition-all"
>
<div class="text-3xl mb-2">{platform.icon}</div>
<div class="text-white font-medium">{platform.name}</div>
<div class="text-white/50 text-xs mt-1">{platform.desc}</div>
</button>
)}
</For>
</div>
</Show>
{/* Proxmox connection */}
<Show when={selectedPlatform() === 'proxmox'}>
<div class="space-y-4">
<div class="flex items-center gap-2 mb-4">
<button
onClick={() => setSelectedPlatform(null)}
class="text-white/60 hover:text-white"
>
</button>
<span class="text-xl">🖥</span>
<span class="text-white font-medium">Proxmox VE</span>
</div>
{/* Options */}
<div class="grid grid-cols-2 gap-3">
<button
onClick={runDiscovery}
disabled={isScanning()}
class="bg-blue-500/20 hover:bg-blue-500/30 border border-blue-400/30 rounded-xl p-4 text-left transition-all"
>
<div class="text-lg mb-1">🔍 Auto-Discover</div>
<div class="text-sm text-white/60">Scan your network</div>
</button>
<button
onClick={() => setShowManualForm(true)}
class="bg-white/5 hover:bg-white/10 border border-white/20 rounded-xl p-4 text-left transition-all"
>
<div class="text-lg mb-1"> Manual Setup</div>
<div class="text-sm text-white/60">Enter details</div>
</button>
</div>
{/* Scanning indicator */}
<Show when={isScanning()}>
<div class="bg-blue-500/10 border border-blue-400/20 rounded-xl p-4 text-center">
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full mb-2" />
<p class="text-blue-200">Scanning network...</p>
</div>
</Show>
{/* Discovered nodes */}
<Show when={discoveredNodes().length > 0}>
<div class="space-y-2">
<p class="text-sm text-white/60">Found {discoveredNodes().length} node(s):</p>
<For each={discoveredNodes()}>
{(node) => (
<button
onClick={() => connectNode(node)}
disabled={isConnecting()}
class="w-full bg-green-500/10 hover:bg-green-500/20 border border-green-400/30 rounded-xl p-3 flex items-center justify-between transition-all"
>
<div class="text-left">
<div class="text-white font-medium">{node.hostname || node.ip}</div>
<div class="text-white/50 text-sm">{node.ip}:{node.port}</div>
</div>
<span class="text-green-400">Connect </span>
</button>
)}
</For>
</div>
</Show>
{/* Manual form */}
<Show when={showManualForm()}>
<div class="space-y-3 bg-white/5 rounded-xl p-4">
<input
type="text"
value={host()}
onInput={(e) => setHost(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="Host (e.g., 192.168.1.100)"
/>
<input
type="text"
value={tokenId()}
onInput={(e) => setTokenId(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="API Token ID (e.g., root@pam!pulse)"
/>
<input
type="password"
value={tokenSecret()}
onInput={(e) => setTokenSecret(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40"
placeholder="API Token Secret"
/>
<button
onClick={() => connectNode()}
disabled={isConnecting() || !host()}
class="w-full py-3 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isConnecting() ? 'Connecting...' : 'Connect'}
</button>
</div>
</Show>
</div>
</Show>
{/* Docker placeholder */}
<Show when={selectedPlatform() === 'docker'}>
<div class="text-center py-8">
<div class="text-4xl mb-4">🐳</div>
<p class="text-white/70 mb-4">
Docker monitoring requires the Pulse Agent.<br />
Install it on your Docker host after setup.
</p>
<button
onClick={props.onNext}
class="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl"
>
Continue Setup
</button>
</div>
</Show>
{/* Kubernetes placeholder */}
<Show when={selectedPlatform() === 'kubernetes'}>
<div class="text-center py-8">
<div class="text-4xl mb-4"></div>
<p class="text-white/70 mb-4">
Kubernetes monitoring requires the Pulse Agent.<br />
Deploy via Helm after setup.
</p>
<button
onClick={props.onNext}
class="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl"
>
Continue Setup
</button>
</div>
</Show>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl"
>
Back
</button>
<div class="flex-1" />
<button
onClick={props.onSkip}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white/60 rounded-xl"
>
Skip for now
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,138 @@
import { Component, createSignal } from 'solid-js';
import { showSuccess } from '@/utils/toast';
import { SettingsAPI } from '@/api/settings';
import type { WizardState } from '../SetupWizard';
interface FeaturesStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
}
export const FeaturesStep: Component<FeaturesStepProps> = (props) => {
const [aiEnabled, setAiEnabled] = createSignal(false);
const [autoUpdates, setAutoUpdates] = createSignal(true);
const [isSaving, setIsSaving] = createSignal(false);
const features = [
{
id: 'ai',
name: 'Pulse AI',
icon: '🤖',
desc: 'Intelligent monitoring assistant with auto-fix capabilities',
enabled: aiEnabled,
setEnabled: setAiEnabled,
badge: 'New in 5.0',
},
{
id: 'updates',
name: 'Automatic Updates',
icon: '🔄',
desc: 'Keep Pulse up-to-date automatically',
enabled: autoUpdates,
setEnabled: setAutoUpdates,
badge: null,
},
];
const handleContinue = async () => {
setIsSaving(true);
try {
// Only save auto-update setting through SystemConfig
// AI settings are configured separately via Settings → AI
await SettingsAPI.updateSystemSettings({
autoUpdateEnabled: autoUpdates(),
});
props.updateState({
aiEnabled: aiEnabled(),
autoUpdatesEnabled: autoUpdates(),
});
showSuccess('Preferences saved!');
props.onNext();
} catch (_error) {
// Continue anyway - settings can be changed later
props.onNext();
} finally {
setIsSaving(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Enable Features</h2>
<p class="text-white/70 mt-1">Customize your Pulse experience</p>
</div>
<div class="p-6 space-y-4">
{features.map((feature) => (
<button
onClick={() => feature.setEnabled(!feature.enabled())}
class={`w-full p-4 rounded-xl border transition-all text-left flex items-start gap-4 ${feature.enabled()
? 'bg-blue-500/20 border-blue-400/40'
: 'bg-white/5 border-white/10 hover:bg-white/10'
}`}
>
<div class="text-3xl">{feature.icon}</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-white font-medium">{feature.name}</span>
{feature.badge && (
<span class="px-2 py-0.5 bg-green-500/30 text-green-300 text-xs rounded-full">
{feature.badge}
</span>
)}
</div>
<p class="text-white/60 text-sm mt-1">{feature.desc}</p>
</div>
<div class={`w-12 h-7 rounded-full transition-all flex items-center px-1 ${feature.enabled() ? 'bg-blue-500' : 'bg-white/20'
}`}>
<div class={`w-5 h-5 rounded-full bg-white transition-transform ${feature.enabled() ? 'translate-x-5' : ''
}`} />
</div>
</button>
))}
{/* AI info box */}
<div class="bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-400/20 rounded-xl p-4">
<div class="flex items-start gap-3">
<div class="text-2xl"></div>
<div>
<p class="text-white font-medium">Pulse AI Features</p>
<p class="text-white/60 text-sm mt-1">
Chat assistant for infrastructure questions<br />
Patrol mode for proactive monitoring<br />
Auto-fix for common issues<br />
Predictive failure detection
</p>
<p class="text-white/40 text-xs mt-2">
Requires API key configuration in Settings AI after setup
</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl"
>
Back
</button>
<button
onClick={handleContinue}
disabled={isSaving()}
class="flex-1 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isSaving() ? 'Saving...' : 'Continue →'}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,191 @@
import { Component, createSignal, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
import type { WizardState } from '../SetupWizard';
interface SecurityStepProps {
state: WizardState;
updateState: (updates: Partial<WizardState>) => void;
bootstrapToken: string;
onNext: () => void;
onBack: () => void;
}
export const SecurityStep: Component<SecurityStepProps> = (props) => {
const [username, setUsername] = createSignal(props.state.username || 'admin');
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [isSettingUp, setIsSettingUp] = createSignal(false);
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
let pass = '';
for (let i = 0; i < 16; i++) {
pass += chars.charAt(Math.floor(Math.random() * chars.length));
}
return pass;
};
const generateToken = (): string => {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const handleSetup = async () => {
if (useCustomPassword()) {
if (!password() || password().length < 12) {
showError('Password must be at least 12 characters');
return;
}
if (password() !== confirmPassword()) {
showError('Passwords do not match');
return;
}
}
setIsSettingUp(true);
const finalPassword = useCustomPassword() ? password() : generatePassword();
const token = generateToken();
try {
setApiClientToken(token);
const response = await fetch('/api/security/quick-setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Setup-Token': props.bootstrapToken,
},
credentials: 'include',
body: JSON.stringify({
username: username(),
password: finalPassword,
apiToken: token,
force: false,
setupToken: props.bootstrapToken,
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
props.updateState({
username: username(),
password: finalPassword,
apiToken: token,
});
showSuccess('Security configured!');
props.onNext();
} catch (error) {
showError(`Setup failed: ${error}`);
} finally {
setIsSettingUp(false);
}
};
return (
<div class="bg-white/10 backdrop-blur-xl rounded-2xl border border-white/20 overflow-hidden">
<div class="p-6 border-b border-white/10">
<h2 class="text-2xl font-bold text-white">Secure Your Dashboard</h2>
<p class="text-white/70 mt-1">Create your admin account</p>
</div>
<div class="p-6 space-y-6">
{/* Username */}
<div>
<label class="block text-sm font-medium text-white/80 mb-2">Username</label>
<input
type="text"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="admin"
/>
</div>
{/* Password choice */}
<div>
<label class="block text-sm font-medium text-white/80 mb-3">Password</label>
<div class="grid grid-cols-2 gap-3 mb-4">
<button
type="button"
onClick={() => setUseCustomPassword(false)}
class={`py-3 px-4 rounded-xl text-sm font-medium transition-all ${!useCustomPassword()
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
}`}
>
🔐 Generate Secure
</button>
<button
type="button"
onClick={() => setUseCustomPassword(true)}
class={`py-3 px-4 rounded-xl text-sm font-medium transition-all ${useCustomPassword()
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
}`}
>
Custom Password
</button>
</div>
<Show when={useCustomPassword()}>
<div class="space-y-3">
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Password (min 12 characters)"
/>
<input
type="password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Confirm password"
/>
</div>
</Show>
<Show when={!useCustomPassword()}>
<div class="bg-blue-500/20 border border-blue-400/30 rounded-xl p-4">
<p class="text-sm text-blue-200">
A secure 16-character password will be generated and shown after setup.
</p>
</div>
</Show>
</div>
{/* Info */}
<div class="bg-white/5 rounded-xl p-4">
<p class="text-sm text-white/60">
This creates your admin account and an API token for automation.
Credentials will be displayed once - save them securely!
</p>
</div>
</div>
{/* Actions */}
<div class="p-6 bg-black/20 flex gap-3">
<button
onClick={props.onBack}
class="px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all"
>
Back
</button>
<button
onClick={handleSetup}
disabled={isSettingUp()}
class="flex-1 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 text-white font-medium rounded-xl transition-all"
>
{isSettingUp() ? 'Setting up...' : 'Create Account →'}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,157 @@
import { Component, createSignal, Show } from 'solid-js';
import { showError, showSuccess } from '@/utils/toast';
import { logger } from '@/utils/logger';
interface WelcomeStepProps {
onNext: () => void;
bootstrapToken: string;
setBootstrapToken: (token: string) => void;
isUnlocked: boolean;
setIsUnlocked: (unlocked: boolean) => void;
}
export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
const [isValidating, setIsValidating] = createSignal(false);
const [tokenPath, setTokenPath] = createSignal('');
const [isDocker, setIsDocker] = createSignal(false);
const [inContainer, setInContainer] = createSignal(false);
const [lxcCtid, setLxcCtid] = createSignal('');
// Fetch bootstrap info on mount
const fetchBootstrapInfo = async () => {
try {
const response = await fetch('/api/security/status');
if (response.ok) {
const data = await response.json();
if (data.bootstrapTokenPath) {
setTokenPath(data.bootstrapTokenPath);
setIsDocker(data.isDocker || false);
setInContainer(data.inContainer || false);
setLxcCtid(data.lxcCtid || '');
}
}
} catch (error) {
logger.error('Failed to fetch bootstrap info:', error);
}
};
// Call on component load
fetchBootstrapInfo();
const handleUnlock = async () => {
if (!props.bootstrapToken.trim()) {
showError('Please enter the bootstrap token');
return;
}
setIsValidating(true);
try {
const response = await fetch('/api/security/validate-bootstrap-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: props.bootstrapToken.trim() }),
});
if (!response.ok) {
throw new Error('Invalid bootstrap token');
}
props.setIsUnlocked(true);
showSuccess('Token verified!');
props.onNext();
} catch (_error) {
showError('Invalid bootstrap token. Please check and try again.');
} finally {
setIsValidating(false);
}
};
const getTokenCommand = () => {
const path = tokenPath() || '/etc/pulse/.bootstrap_token';
if (isDocker()) {
return `docker exec <container> cat ${path}`;
}
if (inContainer() && lxcCtid()) {
return `pct exec ${lxcCtid()} -- cat ${path}`;
}
if (inContainer()) {
return `pct exec <ctid> -- cat ${path}`;
}
return `cat ${path}`;
};
return (
<div class="text-center">
{/* Logo */}
<div class="mb-8">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 shadow-2xl shadow-blue-500/30 mb-6">
<svg width="56" height="56" viewBox="0 0 256 256" class="text-white">
<circle class="fill-current opacity-20" cx="128" cy="128" r="122" />
<circle class="fill-none stroke-current" stroke-width="14" cx="128" cy="128" r="84" opacity="0.9" />
<circle class="fill-current" cx="128" cy="128" r="26" />
</svg>
</div>
<h1 class="text-4xl font-bold text-white mb-3">
Welcome to Pulse
</h1>
<p class="text-xl text-blue-200/80">
Unified infrastructure monitoring
</p>
</div>
{/* Feature highlights */}
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2">🖥</div>
<div class="text-sm text-white/80">Proxmox</div>
</div>
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2">🐳</div>
<div class="text-sm text-white/80">Docker</div>
</div>
<div class="bg-white/5 backdrop-blur rounded-xl p-4 border border-white/10">
<div class="text-2xl mb-2"></div>
<div class="text-sm text-white/80">Kubernetes</div>
</div>
</div>
{/* Bootstrap token unlock */}
<Show when={!props.isUnlocked}>
<div class="bg-white/10 backdrop-blur-xl rounded-2xl p-6 border border-white/20 text-left">
<h3 class="text-lg font-semibold text-white mb-2">Unlock Setup</h3>
<p class="text-sm text-white/70 mb-4">
Retrieve the bootstrap token from your host:
</p>
<div class="bg-black/30 rounded-lg p-3 font-mono text-sm text-green-400 mb-4">
{getTokenCommand()}
</div>
<input
type="text"
value={props.bootstrapToken}
onInput={(e) => props.setBootstrapToken(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
placeholder="Paste your bootstrap token"
autofocus
/>
<button
onClick={handleUnlock}
disabled={isValidating() || !props.bootstrapToken.trim()}
class="w-full mt-4 py-3 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all shadow-lg shadow-blue-500/25"
>
{isValidating() ? 'Validating...' : 'Continue →'}
</button>
</div>
</Show>
<Show when={props.isUnlocked}>
<button
onClick={props.onNext}
class="py-4 px-8 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white text-lg font-medium rounded-xl transition-all shadow-lg shadow-blue-500/25"
>
Get Started
</button>
</Show>
</div>
);
};

View file

@ -57,7 +57,7 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full max-w-[150px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
class="relative w-full max-w-[150px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>

View file

@ -46,7 +46,7 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
<For each={devices()}>
{(device) => (
<div
class={`w-2.5 h-3 rounded-sm cursor-help transition-colors duration-200 ${getDeviceColor(device)} ${isResilvering(device) ? 'animate-pulse' : ''}`}
class={`w-2.5 h-3 rounded-sm transition-colors duration-200 ${getDeviceColor(device)} ${isResilvering(device) ? 'animate-pulse' : ''}`}
onMouseEnter={(e) => handleMouseEnter(e, device)}
onMouseLeave={handleMouseLeave}
/>
@ -72,8 +72,8 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
</div>
<div class="flex items-center gap-2 border-t border-gray-700/50 pt-1">
<span class={`font-semibold ${hoveredDevice()?.state === 'ONLINE' ? 'text-green-400' :
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400'
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400'
}`}>
{hoveredDevice()?.state}
</span>

View file

@ -0,0 +1,197 @@
import { Component, createSignal, createEffect, Show, For } from 'solid-js';
import { AIAPI } from '@/api/ai';
import type { FailurePrediction, ResourceCorrelation } from '@/types/aiIntelligence';
/**
* AIInsightsPanel displays AI-learned predictions and correlations
* Shows failure predictions with confidence levels and resource dependencies
*/
export const AIInsightsPanel: Component<{ resourceId?: string }> = (props) => {
const [predictions, setPredictions] = createSignal<FailurePrediction[]>([]);
const [correlations, setCorrelations] = createSignal<ResourceCorrelation[]>([]);
const [loading, setLoading] = createSignal(false);
const [expanded, setExpanded] = createSignal(false);
const loadData = async () => {
setLoading(true);
try {
const [predResp, corrResp] = await Promise.all([
AIAPI.getPredictions(props.resourceId),
AIAPI.getCorrelations(props.resourceId),
]);
setPredictions(predResp.predictions || []);
setCorrelations(corrResp.correlations || []);
} catch (e) {
console.error('Failed to load AI insights:', e);
} finally {
setLoading(false);
}
};
createEffect(() => {
loadData();
});
const totalInsights = () => predictions().length + correlations().length;
// Format days until in a human-readable way
const formatDaysUntil = (days: number) => {
if (days < 0) return 'Overdue';
if (days < 1) return 'Today';
if (days < 2) return 'Tomorrow';
return `In ${Math.round(days)} days`;
};
// Get severity color based on days until and confidence
const getPredictionColor = (pred: FailurePrediction) => {
if (pred.is_overdue || pred.days_until < 0) return 'text-red-600 dark:text-red-400';
if (pred.days_until < 3) return 'text-amber-600 dark:text-amber-400';
if (pred.days_until < 7) return 'text-yellow-600 dark:text-yellow-400';
return 'text-blue-600 dark:text-blue-400';
};
// Get event type display name
const getEventDisplayName = (eventType: string) => {
const names: Record<string, string> = {
high_memory: 'High Memory',
high_cpu: 'High CPU',
disk_full: 'Disk Full',
oom: 'Out of Memory',
restart: 'Restart',
unresponsive: 'Unresponsive',
backup_failed: 'Backup Failure',
};
return names[eventType] || eventType;
};
return (
<Show when={totalInsights() > 0 || loading()}>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 border border-purple-200 dark:border-purple-700 rounded-lg overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setExpanded(!expanded())}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-purple-100/50 dark:hover:bg-purple-800/30 transition-colors"
>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span class="font-medium text-purple-900 dark:text-purple-100">
AI Insights
</span>
<Show when={totalInsights() > 0}>
<span class="px-2 py-0.5 text-xs font-medium bg-purple-200 dark:bg-purple-700 text-purple-800 dark:text-purple-200 rounded-full">
{totalInsights()}
</span>
</Show>
</div>
<svg
class={`w-5 h-5 text-purple-600 dark:text-purple-400 transition-transform ${expanded() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content */}
<Show when={expanded()}>
<div class="px-4 pb-4 space-y-4">
{/* Loading state */}
<Show when={loading()}>
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Loading insights...
</div>
</Show>
{/* Predictions */}
<Show when={predictions().length > 0}>
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Failure Predictions
</h4>
<div class="space-y-2">
<For each={predictions()}>
{(pred) => (
<div class="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<div class={`font-medium ${getPredictionColor(pred)}`}>
{getEventDisplayName(pred.event_type)}
<span class="ml-2 text-sm font-normal">
{formatDaysUntil(pred.days_until)}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{pred.basis}
</p>
</div>
<div class="text-right shrink-0">
<div class="text-xs text-gray-500 dark:text-gray-400">
{Math.round(pred.confidence * 100)}% confidence
</div>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Correlations */}
<Show when={correlations().length > 0}>
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Resource Dependencies
</h4>
<div class="space-y-2">
<For each={correlations()}>
{(corr) => (
<div class="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-gray-800 dark:text-gray-200">
{corr.source_name || corr.source_id}
</span>
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
<span class="font-medium text-gray-800 dark:text-gray-200">
{corr.target_name || corr.target_id}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{corr.description || `${corr.event_pattern} (${corr.occurrences} observations)`}
</p>
<div class="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span>Avg delay: {corr.avg_delay}</span>
<span>Confidence: {Math.round(corr.confidence * 100)}%</span>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Empty state */}
<Show when={!loading() && totalInsights() === 0}>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-2">
No predictions or correlations detected yet. The AI will learn patterns over time.
</p>
</Show>
</div>
</Show>
</div>
</Show>
);
};

View file

@ -0,0 +1,127 @@
import { Component, Show, For, createSignal, onCleanup, createEffect } from 'solid-js';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
interface ColumnPickerProps {
/** Columns that can be toggled */
columns: ColumnDef[];
/** Check if a column is currently hidden */
isHidden: (id: string) => boolean;
/** Toggle a column's visibility */
onToggle: (id: string) => void;
/** Reset all columns to visible */
onReset?: () => void;
}
export const ColumnPicker: Component<ColumnPickerProps> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
let containerRef: HTMLDivElement | undefined;
// Close on click outside
const handleClickOutside = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node)) {
setIsOpen(false);
}
};
createEffect(() => {
if (isOpen()) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
// Count how many are hidden
const hiddenCount = () => props.columns.filter(c => props.isHidden(c.id)).length;
return (
<div ref={containerRef} class="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen())}
class={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-all
${isOpen()
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Choose which columns to display"
>
{/* Columns icon */}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4v16M15 4v16M4 9h16M4 15h16" />
</svg>
<span>Columns</span>
<Show when={hiddenCount() > 0}>
<span class="ml-0.5 px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200">
{hiddenCount()} hidden
</span>
</Show>
</button>
<Show when={isOpen()}>
<div
class="absolute right-0 mt-1 w-56 rounded-lg border border-gray-200 bg-white shadow-xl z-50
dark:border-gray-700 dark:bg-gray-800"
>
<div class="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">Show Columns</span>
<Show when={props.onReset && hiddenCount() > 0}>
<button
type="button"
onClick={() => props.onReset?.()}
class="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
>
Show all
</button>
</Show>
</div>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<For each={props.columns}>
{(col) => {
const isChecked = () => !props.isHidden(col.id);
return (
<label
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer
hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<input
type="checkbox"
checked={isChecked()}
onChange={() => props.onToggle(col.id)}
class="w-3.5 h-3.5 rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0
dark:border-gray-600 dark:bg-gray-700 dark:checked:bg-blue-600"
/>
<span class={`text-sm ${isChecked() ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}>
{col.label}
</span>
<Show when={col.priority !== 'essential'}>
<span class="ml-auto text-[10px] text-gray-400 dark:text-gray-500">
{col.priority === 'secondary' && 'md+'}
{col.priority === 'supplementary' && 'lg+'}
{col.priority === 'detailed' && 'xl+'}
</span>
</Show>
</label>
);
}}
</For>
</div>
<Show when={props.columns.length === 0}>
<div class="px-3 py-4 text-xs text-gray-500 dark:text-gray-400 text-center">
No columns available to toggle at this screen size
</div>
</Show>
</div>
</Show>
</div>
);
};

View file

@ -2,41 +2,72 @@
* Metrics View Mode Toggle
*
* Toggle button to switch between progress bars and sparkline views for metrics.
* When in sparklines mode, also shows time range selection buttons.
* This is a global setting that affects all tables throughout the app.
*/
import { Component } from 'solid-js';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { Component, Show } from 'solid-js';
import { useMetricsViewMode, TIME_RANGE_OPTIONS } from '@/stores/metricsViewMode';
import type { TimeRange } from '@/api/charts';
export const MetricsViewToggle: Component = () => {
const { viewMode, setViewMode } = useMetricsViewMode();
const { viewMode, setViewMode, timeRange, setTimeRange } = useMetricsViewMode();
return (
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('bars')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
viewMode() === 'bars'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Bar view"
>
Bars
</button>
<button
type="button"
onClick={() => setViewMode('sparklines')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
viewMode() === 'sparklines'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Sparkline view"
>
Trends
</button>
<div class="inline-flex items-center gap-2">
{/* View Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('bars')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'bars'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Bar view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="6" width="4" height="15" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
Bars
</button>
<button
type="button"
onClick={() => setViewMode('sparklines')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'sparklines'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Sparkline view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 17 9 11 13 15 21 7" />
<polyline points="17 7 21 7 21 11" />
</svg>
Trends
</button>
</div>
{/* Time Range Selector - only shown in sparklines mode, appears to the right */}
<Show when={viewMode() === 'sparklines'}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
{TIME_RANGE_OPTIONS.map((option) => (
<button
type="button"
onClick={() => setTimeRange(option.value as TimeRange)}
class={`px-2 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${timeRange() === option.value
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title={`Show last ${option.label} of data`}
>
{option.label}
</button>
))}
</div>
</Show>
</div>
);
};

View file

@ -15,6 +15,7 @@ import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
interface NodeSummaryTableProps {
nodes: Node[];
@ -34,6 +35,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const { isMobile } = useBreakpoint();
const { viewMode } = useMetricsViewMode();
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
@ -335,55 +337,60 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return sortDirection() === 'asc' ? '▲' : '▼';
};
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
const thClassBase = "px-2 py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
const thClass = `${thClassBase} text-center`;
// Cell class constants for consistency
const tdClass = "px-2 py-1 align-middle";
const metricColumnStyle = { width: "200px", "min-width": "200px", "max-width": "200px" } as const;
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "800px" }}>
<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-700">
<th
class={`${thClass} text-left pl-3`}
class={`${thClassBase} text-left pl-3`}
onClick={() => handleSort('name')}
>
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
</th>
<th class={thClass} onClick={() => handleSort('uptime')}>
<th class={thClass} style={{ "min-width": '80px' }} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} onClick={() => handleSort('cpu')}>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} onClick={() => handleSort('memory')}>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} onClick={() => handleSort('disk')}>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<Show when={hasAnyTemperatureData()}>
<th class={thClass} onClick={() => handleSort('temperature')}>
<th class={thClass} style={{ "min-width": '60px' }} onClick={() => handleSort('temperature')}>
Temp {renderSortIndicator('temperature')}
</th>
</Show>
<Show when={props.currentTab === 'dashboard'}>
<th class={thClass} onClick={() => handleSort('vmCount')}>
<th class={thClass} style={{ "min-width": '50px' }} onClick={() => handleSort('vmCount')}>
VMs {renderSortIndicator('vmCount')}
</th>
<th class={thClass} onClick={() => handleSort('containerCount')}>
<th class={thClass} style={{ "min-width": '50px' }} onClick={() => handleSort('containerCount')}>
CTs {renderSortIndicator('containerCount')}
</th>
</Show>
<Show when={props.currentTab === 'storage'}>
<th class={thClass} onClick={() => handleSort('storageCount')}>
<th class={thClass} style={{ "min-width": '70px' }} onClick={() => handleSort('storageCount')}>
Storage {renderSortIndicator('storageCount')}
</th>
<th class={thClass} onClick={() => handleSort('diskCount')}>
<th class={thClass} style={{ "min-width": '60px' }} onClick={() => handleSort('diskCount')}>
Disks {renderSortIndicator('diskCount')}
</th>
</Show>
<Show when={props.currentTab === 'backups'}>
<th class={thClass} onClick={() => handleSort('backupCount')}>
<th class={thClass} style={{ "min-width": '70px' }} onClick={() => handleSort('backupCount')}>
Backups {renderSortIndicator('backupCount')}
</th>
</Show>
@ -475,7 +482,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<tr
class={rowClass()}
style={rowStyle()}
style={{ ...rowStyle(), height: '29px', 'max-height': '29px' }}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
@ -541,7 +548,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Uptime */}
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
@ -559,13 +566,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class={tdClass} style={metricColumnStyle}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={cpuPercentValue} type="cpu" />
</div>
</Show>
<div class="hidden md:block">
<div class="hidden md:block h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={cpuPercentValue}
@ -587,13 +594,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class={tdClass} style={metricColumnStyle}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={memoryPercentValue} type="memory" />
</div>
</Show>
<div class="hidden md:block">
<div class="hidden md:block h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
@ -604,19 +611,33 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
showMobile={false}
/>
}>
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
/>
<Show
when={viewMode() === 'sparklines'}
fallback={
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
resourceId={metricsKey}
/>
}
>
<ResponsiveMetricCell
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
isRunning={online}
showMobile={false}
/>
</Show>
</Show>
</div>
</td>
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<td class={tdClass} style={metricColumnStyle}>
<ResponsiveMetricCell
value={diskPercentValue}
type="disk"
@ -629,7 +650,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Temperature */}
<Show when={hasAnyTemperatureData()}>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<Show
when={
@ -681,14 +702,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Dashboard tab: VMs and CTs */}
<Show when={props.currentTab === 'dashboard'}>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
</span>
</div>
</td>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
@ -699,14 +720,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Storage tab: Storage and Disks */}
<Show when={props.currentTab === 'storage'}>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
</span>
</div>
</td>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
@ -717,7 +738,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Backups tab: Backups */}
<Show when={props.currentTab === 'backups'}>
<td class="px-2 py-1 align-middle">
<td class={tdClass}>
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}

View file

@ -41,7 +41,7 @@ export const Sparkline: Component<SparklineProps> = (props) => {
}
return props.width || 120;
};
const height = () => props.height || 24;
const height = () => props.height || 16;
// Default thresholds based on metric type
const getDefaultThresholds = () => {
@ -95,6 +95,8 @@ export const Sparkline: Component<SparklineProps> = (props) => {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
// Always set explicit width style to prevent canvas from expanding beyond container
// When width=0, we use the calculated container width
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
@ -102,6 +104,22 @@ export const Sparkline: Component<SparklineProps> = (props) => {
// Clear canvas
ctx.clearRect(0, 0, w, h);
// Detect dark mode for reference line color
const isDark = document.documentElement.classList.contains('dark');
// Draw subtle reference lines at 25%, 50%, 75% to help understand scale
// These provide visual anchors so users can distinguish 17% from 70%
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)';
ctx.lineWidth = 1;
ctx.setLineDash([]);
[0.25, 0.5, 0.75].forEach(pct => {
const y = h - (pct * h);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
});
if (!data || data.length === 0) {
// No data - show empty state
ctx.strokeStyle = '#d1d5db'; // gray-300
@ -138,34 +156,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
points.push({ x, y });
});
// Draw threshold reference lines
const t = thresholds();
ctx.strokeStyle = '#94a3b8'; // gray-400
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.3;
ctx.setLineDash([2, 2]);
// Warning threshold line
const warningY = h - ((t.warning - minValue) / (maxValue - minValue)) * h;
ctx.beginPath();
ctx.moveTo(0, warningY);
ctx.lineTo(w, warningY);
ctx.stroke();
// Critical threshold line
const criticalY = h - ((t.critical - minValue) / (maxValue - minValue)) * h;
ctx.beginPath();
ctx.moveTo(0, criticalY);
ctx.lineTo(w, criticalY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Draw gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, `${color}40`); // 25% opacity at top
gradient.addColorStop(1, `${color}10`); // 6% opacity at bottom
gradient.addColorStop(0, `${color}5A`); // 35% opacity at top
gradient.addColorStop(1, `${color}1F`); // 12% opacity at bottom
ctx.fillStyle = gradient;
ctx.beginPath();
@ -189,19 +183,15 @@ export const Sparkline: Component<SparklineProps> = (props) => {
}
});
ctx.stroke();
// Draw current value dot with opacity
if (points.length > 0) {
const lastPoint = points[points.length - 1];
ctx.fillStyle = colorWithOpacity;
ctx.beginPath();
ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2);
ctx.fill();
}
};
// Redraw when data or dimensions change
createEffect(() => {
void props.data;
void props.metric;
void width();
void height();
// Unregister previous draw callback if it exists
if (unregister) {
unregister();
@ -249,22 +239,21 @@ export const Sparkline: Component<SparklineProps> = (props) => {
setHoveredPoint(null);
};
// Format timestamp for tooltip
// Format timestamp for tooltip (24-hour clock)
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
};
return (
<>
<div class="relative block w-full">
<div class="relative block w-full overflow-hidden" style={{ height: `${height()}px`, 'max-width': '100%' }}>
<canvas
ref={canvasRef}
class="block cursor-crosshair transition-opacity duration-150"
class="block cursor-crosshair"
style={{
width: `${width()}px`,
height: `${height()}px`,
opacity: hoveredPoint() ? '1' : '0.7',
'max-width': '100%',
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}

View file

@ -27,14 +27,15 @@ const SIZE_CLASSES: Record<StatusDotSize, string> = {
};
export function StatusDot(props: StatusDotProps): JSX.Element {
const variant = props.variant ?? 'muted';
const size = props.size ?? 'sm';
const ariaHidden = props.ariaHidden ?? !props.ariaLabel;
// Use getters to maintain reactivity - props can change over time
const variant = () => props.variant ?? 'muted';
const size = () => props.size ?? 'sm';
const ariaHidden = () => props.ariaHidden ?? !props.ariaLabel;
const className = [
const className = () => [
'inline-block rounded-full flex-shrink-0',
SIZE_CLASSES[size],
VARIANT_CLASSES[variant],
SIZE_CLASSES[size()],
VARIANT_CLASSES[variant()],
props.pulse ? 'animate-pulse' : '',
props.class ?? '',
]
@ -43,10 +44,10 @@ export function StatusDot(props: StatusDotProps): JSX.Element {
return (
<span
class={className}
class={className()}
title={props.title}
aria-label={props.ariaLabel}
aria-hidden={ariaHidden}
aria-hidden={ariaHidden()}
role={props.ariaLabel ? 'img' : undefined}
/>
);

View file

@ -78,7 +78,9 @@ export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props
const isRunning = () => props.isRunning !== false; // Default to true if not specified
const defaultFallback = (
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
<div class="h-4 flex items-center justify-center">
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
);
return (
@ -148,7 +150,9 @@ export const DualMetricCell: Component<{
const isRunning = () => props.isRunning !== false;
const defaultFallback = (
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
<div class="h-4 flex items-center justify-center">
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
);
const defaultMobileContent = (

View file

@ -8,6 +8,8 @@ export interface APIScopeOption {
export const HOST_AGENT_SCOPE = 'host-agent:report';
export const DOCKER_REPORT_SCOPE = 'docker:report';
export const DOCKER_MANAGE_SCOPE = 'docker:manage';
export const KUBERNETES_REPORT_SCOPE = 'kubernetes:report';
export const KUBERNETES_MANAGE_SCOPE = 'kubernetes:manage';
export const MONITORING_READ_SCOPE = 'monitoring:read';
export const MONITORING_WRITE_SCOPE = 'monitoring:write';
export const SETTINGS_READ_SCOPE = 'settings:read';
@ -38,6 +40,18 @@ export const API_SCOPE_OPTIONS: APIScopeOption[] = [
description: 'Enable agent-triggered runtime commands and host actions.',
group: 'Agents',
},
{
value: KUBERNETES_REPORT_SCOPE,
label: 'Kubernetes agent reporting',
description: 'Allow the Kubernetes agent to submit cluster, node, and workload telemetry.',
group: 'Agents',
},
{
value: KUBERNETES_MANAGE_SCOPE,
label: 'Kubernetes cluster management',
description: 'Allow administrative actions for Kubernetes cluster agents.',
group: 'Agents',
},
{
value: HOST_AGENT_SCOPE,
label: 'Host agent reporting',

View file

@ -0,0 +1,519 @@
/**
* Tests for useResources hook functionality
*
* Note: These tests focus on the logic and type conversions rather than
* reactive behavior, which requires a full SolidJS testing environment.
*/
import { describe, expect, it } from 'vitest';
import type { Resource, ResourceStatus } from '@/types/resource';
// Helper to create mock resources for testing conversion logic
function createMockResource(overrides: Partial<Resource> = {}): Resource {
return {
id: 'test-resource-1',
type: 'vm',
name: 'test-vm',
displayName: 'Test VM',
platformId: 'pve1',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'running',
lastSeen: Date.now(),
cpu: { current: 25.5 },
memory: { current: 50, total: 4294967296, used: 2147483648 },
disk: { current: 30, total: 107374182400, used: 32212254720 },
...overrides,
};
}
describe('useResources - Resource Filtering Logic', () => {
describe('byType filtering', () => {
const resources: Resource[] = [
createMockResource({ id: '1', type: 'vm' }),
createMockResource({ id: '2', type: 'vm' }),
createMockResource({ id: '3', type: 'container' }),
createMockResource({ id: '4', type: 'node' }),
createMockResource({ id: '5', type: 'docker-container' }),
];
it('filters resources by single type', () => {
const vms = resources.filter(r => r.type === 'vm');
expect(vms).toHaveLength(2);
});
it('returns empty array when no matches', () => {
const filtered = resources.filter(r => r.type === 'pbs');
expect(filtered).toHaveLength(0);
});
});
describe('byPlatform filtering', () => {
const resources: Resource[] = [
createMockResource({ id: '1', platformType: 'proxmox-pve' }),
createMockResource({ id: '2', platformType: 'proxmox-pve' }),
createMockResource({ id: '3', platformType: 'docker' }),
createMockResource({ id: '4', platformType: 'host-agent' }),
];
it('filters resources by platform', () => {
const pveResources = resources.filter(r => r.platformType === 'proxmox-pve');
expect(pveResources).toHaveLength(2);
});
it('filters Docker resources', () => {
const dockerResources = resources.filter(r => r.platformType === 'docker');
expect(dockerResources).toHaveLength(1);
});
});
describe('children filtering', () => {
const resources: Resource[] = [
createMockResource({ id: 'node-1', type: 'node' }),
createMockResource({ id: 'vm-1', type: 'vm', parentId: 'node-1' }),
createMockResource({ id: 'vm-2', type: 'vm', parentId: 'node-1' }),
createMockResource({ id: 'vm-3', type: 'vm', parentId: 'node-2' }),
];
it('finds children of a parent', () => {
const children = resources.filter(r => r.parentId === 'node-1');
expect(children).toHaveLength(2);
});
it('returns empty array for parent with no children', () => {
const children = resources.filter(r => r.parentId === 'node-3');
expect(children).toHaveLength(0);
});
});
describe('complex filtering', () => {
const resources: Resource[] = [
createMockResource({ id: '1', type: 'vm', status: 'running', platformType: 'proxmox-pve' }),
createMockResource({ id: '2', type: 'vm', status: 'stopped', platformType: 'proxmox-pve' }),
createMockResource({ id: '3', type: 'container', status: 'running', platformType: 'proxmox-pve' }),
createMockResource({ id: '4', type: 'docker-container', status: 'running', platformType: 'docker' }),
];
it('filters by multiple criteria', () => {
const runningPveVms = resources.filter(r =>
r.type === 'vm' &&
r.status === 'running' &&
r.platformType === 'proxmox-pve'
);
expect(runningPveVms).toHaveLength(1);
});
it('filters by search term in name', () => {
const searchTerm = 'test';
const matched = resources.filter(r =>
r.name.toLowerCase().includes(searchTerm.toLowerCase())
);
expect(matched).toHaveLength(4);
});
});
describe('status counts', () => {
const resources: Resource[] = [
createMockResource({ id: '1', status: 'running' }),
createMockResource({ id: '2', status: 'running' }),
createMockResource({ id: '3', status: 'stopped' }),
createMockResource({ id: '4', status: 'online' }),
createMockResource({ id: '5', status: 'degraded' }),
];
it('counts resources by status', () => {
const counts: Record<ResourceStatus, number> = {
online: 0,
offline: 0,
running: 0,
stopped: 0,
degraded: 0,
paused: 0,
unknown: 0,
};
for (const r of resources) {
if (r.status in counts) {
counts[r.status]++;
}
}
expect(counts.running).toBe(2);
expect(counts.stopped).toBe(1);
expect(counts.online).toBe(1);
expect(counts.degraded).toBe(1);
});
});
describe('topByCpu sorting', () => {
const resources: Resource[] = [
createMockResource({ id: '1', name: 'low', cpu: { current: 10 } }),
createMockResource({ id: '2', name: 'high', cpu: { current: 90 } }),
createMockResource({ id: '3', name: 'medium', cpu: { current: 50 } }),
createMockResource({ id: '4', name: 'no-cpu', cpu: undefined }), // Explicitly no CPU data
];
it('sorts by CPU descending and limits results', () => {
const sorted = [...resources]
.filter(r => r.cpu && r.cpu.current > 0)
.sort((a, b) => (b.cpu?.current ?? 0) - (a.cpu?.current ?? 0))
.slice(0, 2);
expect(sorted).toHaveLength(2);
expect(sorted[0].name).toBe('high');
expect(sorted[1].name).toBe('medium');
});
it('excludes resources without CPU data', () => {
const withCpu = resources.filter(r => r.cpu && r.cpu.current > 0);
expect(withCpu).toHaveLength(3);
});
});
});
describe('useResourcesAsLegacy - Legacy Format Conversion', () => {
describe('VM conversion', () => {
it('converts Resource to legacy VM format', () => {
const resource = createMockResource({
type: 'vm',
cpu: { current: 50 },
memory: { current: 60, total: 4294967296, used: 2576980378 },
disk: { current: 30, total: 107374182400, used: 32212254720 },
uptime: 86400,
network: { rxBytes: 1000000, txBytes: 500000 },
tags: ['web', 'production'],
platformData: {
vmid: 100,
node: 'pve1',
instance: 'pve1/qemu/100',
cpus: 4,
template: false,
osName: 'Ubuntu',
osVersion: '22.04',
},
});
// Simulate the conversion logic from useResourcesAsLegacy
const platformData = resource.platformData as Record<string, unknown>;
const legacyVm = {
id: resource.id,
vmid: platformData?.vmid as number ?? parseInt(resource.id.split('-').pop() ?? '0', 10),
name: resource.name,
node: platformData?.node as string ?? '',
instance: platformData?.instance as string ?? resource.platformId,
status: resource.status === 'running' ? 'running' : 'stopped',
type: 'qemu',
cpu: (resource.cpu?.current ?? 0) / 100, // Convert percentage to ratio
cpus: platformData?.cpus as number ?? 1,
memory: resource.memory ? {
total: resource.memory.total ?? 0,
used: resource.memory.used ?? 0,
free: resource.memory.free ?? 0,
usage: resource.memory.current,
} : { total: 0, used: 0, free: 0, usage: 0 },
uptime: resource.uptime ?? 0,
networkIn: resource.network?.rxBytes ?? 0,
networkOut: resource.network?.txBytes ?? 0,
tags: resource.tags ?? [],
};
expect(legacyVm.vmid).toBe(100);
expect(legacyVm.node).toBe('pve1');
expect(legacyVm.cpu).toBe(0.5); // 50% -> 0.5 ratio
expect(legacyVm.memory.total).toBe(4294967296);
expect(legacyVm.uptime).toBe(86400);
expect(legacyVm.networkIn).toBe(1000000);
expect(legacyVm.tags).toEqual(['web', 'production']);
});
it('handles missing platformData gracefully', () => {
const resource = createMockResource({
type: 'vm',
platformData: undefined,
});
const platformData = resource.platformData as Record<string, unknown> | undefined;
const vmid = platformData?.vmid as number ?? 0;
const node = platformData?.node as string ?? '';
expect(vmid).toBe(0);
expect(node).toBe('');
});
});
describe('Container conversion', () => {
it('converts container Resource to legacy Container format', () => {
const resource = createMockResource({
type: 'container',
platformData: {
vmid: 200,
node: 'pve1',
},
});
const platformData = resource.platformData as Record<string, unknown>;
const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci';
const legacyContainer = {
id: resource.id,
vmid: platformData?.vmid as number,
name: resource.name,
type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'),
isOci: isOCI,
osTemplate: platformData?.osTemplate as string | undefined,
status: resource.status === 'running' ? 'running' : 'stopped',
};
expect(legacyContainer.vmid).toBe(200);
expect(legacyContainer.type).toBe('lxc');
});
it('converts oci-container Resource to legacy Container format', () => {
const resource = createMockResource({
type: 'oci-container',
platformData: {
vmid: 300,
node: 'pve1',
type: 'oci',
isOci: true,
osTemplate: 'oci:docker.io/library/alpine:latest',
},
});
const platformData = resource.platformData as Record<string, unknown>;
const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci';
const legacyContainer = {
id: resource.id,
vmid: platformData?.vmid as number,
name: resource.name,
type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'),
isOci: isOCI,
osTemplate: platformData?.osTemplate as string | undefined,
status: resource.status === 'running' ? 'running' : 'stopped',
};
expect(legacyContainer.vmid).toBe(300);
expect(legacyContainer.type).toBe('oci');
expect(legacyContainer.isOci).toBe(true);
expect(legacyContainer.osTemplate).toBe('oci:docker.io/library/alpine:latest');
});
});
describe('Node conversion', () => {
it('converts Resource to legacy Node format with temperature', () => {
const now = Date.now();
const resource = createMockResource({
type: 'node',
name: 'pve1',
status: 'online',
temperature: 45.5,
lastSeen: now,
cpu: { current: 35 },
platformData: {
host: '192.168.1.10',
loadAverage: [1.5, 1.2, 0.9],
kernelVersion: '6.1.0-pve',
pveVersion: '8.0.3',
},
});
// Simulate temperature conversion
let temperature = undefined;
if (resource.temperature !== undefined && resource.temperature !== null && resource.temperature > 0) {
temperature = {
cpuPackage: resource.temperature,
cpuMax: resource.temperature,
available: true,
hasCPU: true,
hasGPU: false,
hasNVMe: false,
lastUpdate: new Date(resource.lastSeen).toISOString(),
};
}
expect(temperature).toBeDefined();
expect(temperature!.cpuPackage).toBe(45.5);
expect(temperature!.available).toBe(true);
});
it('handles node without temperature', () => {
const resource = createMockResource({
type: 'node',
temperature: undefined,
});
let temperature = undefined;
if (resource.temperature !== undefined && resource.temperature !== null && resource.temperature > 0) {
temperature = { cpuPackage: resource.temperature };
}
expect(temperature).toBeUndefined();
});
it('converts CPU from percentage to ratio', () => {
const resource = createMockResource({
type: 'node',
cpu: { current: 75 },
});
const legacyCpu = (resource.cpu?.current ?? 0) / 100;
expect(legacyCpu).toBe(0.75);
});
});
describe('DockerHost conversion', () => {
it('converts Resource to legacy DockerHost format with containers', () => {
const dockerHost = createMockResource({
id: 'docker-host-1',
type: 'docker-host',
name: 'docker-server',
platformType: 'docker',
cpu: { current: 25 },
memory: { current: 50, total: 8589934592, used: 4294967296 },
platformData: {
agentId: 'agent-123',
runtime: 'docker',
runtimeVersion: '24.0.0',
dockerVersion: '24.0.0',
cpus: 8,
},
});
const dockerContainers: Resource[] = [
createMockResource({
id: 'docker-host-1/container-abc',
type: 'docker-container',
name: 'nginx',
parentId: 'docker-host-1',
status: 'running',
cpu: { current: 5 },
memory: { current: 10, total: 134217728, used: 13421772 },
platformData: {
image: 'nginx:latest',
health: 'healthy',
},
}),
];
// Simulate container ID extraction for sparklines
const originalContainerId = dockerContainers[0].id.includes('/')
? dockerContainers[0].id.split('/').pop()!
: dockerContainers[0].id;
expect(originalContainerId).toBe('container-abc');
const legacyHost = {
id: dockerHost.id,
agentId: (dockerHost.platformData as any).agentId ?? dockerHost.id,
hostname: dockerHost.identity?.hostname ?? dockerHost.name,
cpuUsagePercent: dockerHost.cpu?.current,
containers: dockerContainers.filter(c => c.parentId === dockerHost.id),
};
expect(legacyHost.agentId).toBe('agent-123');
expect(legacyHost.containers).toHaveLength(1);
});
});
describe('Host conversion', () => {
it('converts Resource to legacy Host format with sensors', () => {
const resource = createMockResource({
type: 'host',
name: 'server1',
platformType: 'host-agent',
status: 'online',
platformData: {
platform: 'linux',
osName: 'Ubuntu',
osVersion: '22.04 LTS',
kernelVersion: '6.2.0',
architecture: 'x86_64',
cpuCount: 16,
loadAverage: [2.5, 1.8, 1.2],
sensors: {
temperatureCelsius: { 'CPU Package': 55.0, 'nvme0': 42.0 },
fanRpm: { 'CPU Fan': 1200 },
},
raid: [
{
device: '/dev/md0',
level: 'raid1',
state: 'active',
totalDevices: 2,
activeDevices: 2,
workingDevices: 2,
failedDevices: 0,
spareDevices: 0,
devices: [
{ device: 'sda1', state: 'active', slot: 0 },
{ device: 'sdb1', state: 'active', slot: 1 },
],
rebuildPercent: 0,
},
],
},
});
const platformData = resource.platformData as Record<string, unknown>;
expect(platformData.sensors).toBeDefined();
expect((platformData.sensors as any).temperatureCelsius['CPU Package']).toBe(55.0);
expect(platformData.raid).toHaveLength(1);
expect((platformData.raid as any[])[0].level).toBe('raid1');
});
it('maps interfaces correctly to networkInterfaces', () => {
const resource = createMockResource({
type: 'host',
platformData: {
interfaces: [
{ name: 'eth0', mac: '00:11:22:33:44:55', addresses: ['192.168.1.10'] },
{ name: 'docker0', mac: '02:42:00:00:00:01', addresses: ['172.17.0.1'] },
],
},
});
const platformData = resource.platformData as Record<string, unknown>;
const interfaces = platformData.interfaces as any[];
expect(interfaces).toHaveLength(2);
expect(interfaces[0].name).toBe('eth0');
expect(interfaces[1].addresses).toContain('172.17.0.1');
});
});
});
describe('Fallback Logic', () => {
describe('hasUnifiedResources check', () => {
it('returns true when resources array has items', () => {
const resources: Resource[] = [createMockResource()];
expect(resources.length > 0).toBe(true);
});
it('returns false when resources array is empty', () => {
const resources: Resource[] = [];
expect(resources.length > 0).toBe(false);
});
});
describe('legacy array fallback', () => {
// This tests the concept - actual implementation uses WebSocket store
it('uses legacy array when unified resources not available', () => {
const unifiedResources: Resource[] = [];
const legacyVms = [{ id: 'vm-1', name: 'Legacy VM' }];
const hasUnifiedResources = unifiedResources.length > 0;
const result = hasUnifiedResources ? unifiedResources : legacyVms;
expect(result).toEqual(legacyVms);
});
it('uses unified resources when available', () => {
const unifiedResources: Resource[] = [createMockResource({ id: 'unified-1' })];
const legacyVms = [{ id: 'vm-1', name: 'Legacy VM' }];
const hasUnifiedResources = unifiedResources.length > 0;
const result = hasUnifiedResources ? unifiedResources : legacyVms;
expect(result).toEqual(unifiedResources);
});
});
});

View file

@ -0,0 +1,147 @@
import { createMemo, Accessor } from 'solid-js';
import type { JSX } from 'solid-js';
import { usePersistentSignal } from './usePersistentSignal';
import { useBreakpoint, type ColumnPriority, PRIORITY_BREAKPOINTS, type Breakpoint } from './useBreakpoint';
export interface ColumnDef {
id: string;
label: string;
icon?: JSX.Element; // Optional icon for compact column headers
priority: ColumnPriority;
toggleable?: boolean;
width?: string; // Fixed width for consistent column sizing
minWidth?: string;
maxWidth?: string;
flex?: number;
sortKey?: string;
}
const BREAKPOINT_ORDER: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
function breakpointIndex(bp: Breakpoint): number {
return BREAKPOINT_ORDER.indexOf(bp);
}
/**
* Hook for managing column visibility with persistence and responsive behavior.
*
* Columns are shown if:
* 1. The current breakpoint supports their priority level, AND
* 2. The user hasn't explicitly hidden them (for toggleable columns)
*
* @param storageKey - localStorage key for persisting user preferences
* @param columns - Array of column definitions
* @param defaultHidden - Optional array of column IDs to hide by default (only used if no user preference exists)
*/
export function useColumnVisibility(
storageKey: string,
columns: ColumnDef[],
defaultHidden: string[] = []
) {
const { breakpoint } = useBreakpoint();
// Get list of toggleable column IDs
const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id);
// Check if user has any saved preference
const hasUserPreference = typeof window !== 'undefined' && window.localStorage.getItem(storageKey) !== null;
// Persist hidden columns to localStorage
// Use defaultHidden only if no user preference exists yet
const [hiddenColumns, setHiddenColumns] = usePersistentSignal<string[]>(
storageKey,
hasUserPreference ? [] : defaultHidden,
{
serialize: (arr) => JSON.stringify(arr),
deserialize: (str) => {
try {
const parsed = JSON.parse(str);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
},
}
);
// Check if a column is hidden by user preference
const isHiddenByUser = (id: string): boolean => {
return hiddenColumns().includes(id);
};
// Check if breakpoint supports showing this column
const hasSpaceForColumn = (col: ColumnDef): boolean => {
const minBreakpoint = PRIORITY_BREAKPOINTS[col.priority];
return breakpointIndex(breakpoint()) >= breakpointIndex(minBreakpoint);
};
// Toggle a column's visibility
const toggle = (id: string) => {
const current = hiddenColumns();
if (current.includes(id)) {
setHiddenColumns(current.filter(c => c !== id));
} else {
setHiddenColumns([...current, id]);
}
};
// Show a column (remove from hidden)
const show = (id: string) => {
setHiddenColumns(hiddenColumns().filter(c => c !== id));
};
// Hide a column (add to hidden)
const hide = (id: string) => {
if (!hiddenColumns().includes(id)) {
setHiddenColumns([...hiddenColumns(), id]);
}
};
// Reset to defaults (restore default hidden columns)
const resetToDefaults = () => {
setHiddenColumns(defaultHidden);
};
// Compute visible columns based on breakpoint and user preferences
const visibleColumns: Accessor<ColumnDef[]> = createMemo(() => {
return columns.filter(col => {
// Always show essential columns regardless of breakpoint
if (col.priority === 'essential') return true;
// Check if screen has space for this priority level
if (!hasSpaceForColumn(col)) return false;
// If toggleable, check user preference
if (col.toggleable && isHiddenByUser(col.id)) return false;
return true;
});
});
// Get columns that could be toggled at the current breakpoint
// (i.e., screen is wide enough to show them)
const availableToggles: Accessor<ColumnDef[]> = createMemo(() => {
return columns.filter(col => {
if (!col.toggleable) return false;
return hasSpaceForColumn(col);
});
});
// Check if a specific column is currently visible
const isColumnVisible = (id: string): boolean => {
return visibleColumns().some(col => col.id === id);
};
return {
visibleColumns,
availableToggles,
toggleableIds,
hiddenColumns,
isColumnVisible,
isHiddenByUser,
toggle,
show,
hide,
resetToDefaults,
};
}

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