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:
commit
21e992ac7a
272 changed files with 65403 additions and 8771 deletions
35
README.md
35
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
123
docs/AI.md
Normal 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
|
||||
71
docs/API.md
71
docs/API.md
|
|
@ -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
141
docs/AUTO_UPDATE.md
Normal 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
99
docs/MAIL_GATEWAY.md
Normal 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
87
docs/METRICS_HISTORY.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 | \
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
frontend-modern/package-lock.json
generated
BIN
frontend-modern/package-lock.json
generated
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
259
frontend-modern/src/api/__tests__/charts.test.ts
Normal file
259
frontend-modern/src/api/__tests__/charts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
417
frontend-modern/src/api/ai.ts
Normal file
417
frontend-modern/src/api/ai.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
151
frontend-modern/src/api/charts.ts
Normal file
151
frontend-modern/src/api/charts.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ export interface DockerMetadata {
|
|||
customUrl?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
notes?: string[]; // User annotations for AI context
|
||||
}
|
||||
|
||||
export class DockerMetadataAPI {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface GuestMetadata {
|
|||
customUrl?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
notes?: string[]; // User annotations for AI context
|
||||
}
|
||||
|
||||
export class GuestMetadataAPI {
|
||||
|
|
|
|||
42
frontend-modern/src/api/hostMetadata.ts
Normal file
42
frontend-modern/src/api/hostMetadata.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
379
frontend-modern/src/api/patrol.ts
Normal file
379
frontend-modern/src/api/patrol.ts
Normal 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();
|
||||
}
|
||||
1549
frontend-modern/src/components/AI/AIChat.tsx
Normal file
1549
frontend-modern/src/components/AI/AIChat.tsx
Normal file
File diff suppressed because it is too large
Load diff
605
frontend-modern/src/components/AI/AICostDashboard.tsx
Normal file
605
frontend-modern/src/components/AI/AICostDashboard.tsx
Normal 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">
|
||||
Couldn’t 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;
|
||||
74
frontend-modern/src/components/AI/AIStatusIndicator.css
Normal file
74
frontend-modern/src/components/AI/AIStatusIndicator.css
Normal 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;
|
||||
}
|
||||
113
frontend-modern/src/components/AI/AIStatusIndicator.tsx
Normal file
113
frontend-modern/src/components/AI/AIStatusIndicator.tsx
Normal 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;
|
||||
818
frontend-modern/src/components/AI/GuestNotes.tsx
Normal file
818
frontend-modern/src/components/AI/GuestNotes.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
189
frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx
Normal file
189
frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
982
frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx
Normal file
982
frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1270
frontend-modern/src/components/Settings/AISettings.tsx
Normal file
1270
frontend-modern/src/components/Settings/AISettings.tsx
Normal file
File diff suppressed because it is too large
Load diff
57
frontend-modern/src/components/Settings/APIAccessPanel.tsx
Normal file
57
frontend-modern/src/components/Settings/APIAccessPanel.tsx
Normal 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 once—store 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
430
frontend-modern/src/components/Settings/BackupsSettingsPanel.tsx
Normal file
430
frontend-modern/src/components/Settings/BackupsSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
674
frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
Normal file
674
frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
Normal 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;
|
||||
224
frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx
Normal file
224
frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
621
frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx
Normal file
621
frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
320
frontend-modern/src/components/Settings/SecurityAuthPanel.tsx
Normal file
320
frontend-modern/src/components/Settings/SecurityAuthPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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 >
|
||||
);
|
||||
};
|
||||
|
|
|
|||
654
frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx
Normal file
654
frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
145
frontend-modern/src/components/SetupWizard/SetupWizard.tsx
Normal file
145
frontend-modern/src/components/SetupWizard/SetupWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
frontend-modern/src/components/SetupWizard/StepIndicator.tsx
Normal file
37
frontend-modern/src/components/SetupWizard/StepIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
frontend-modern/src/components/SetupWizard/index.ts
Normal file
3
frontend-modern/src/components/SetupWizard/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SetupWizard } from './SetupWizard';
|
||||
export { StepIndicator } from './StepIndicator';
|
||||
export type { WizardState, WizardStep } from './SetupWizard';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
293
frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx
Normal file
293
frontend-modern/src/components/SetupWizard/steps/ConnectStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
157
frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx
Normal file
157
frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
197
frontend-modern/src/components/shared/AIInsightsPanel.tsx
Normal file
197
frontend-modern/src/components/shared/AIInsightsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
127
frontend-modern/src/components/shared/ColumnPicker.tsx
Normal file
127
frontend-modern/src/components/shared/ColumnPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') ?? '-' : '-'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
519
frontend-modern/src/hooks/__tests__/useResources.test.ts
Normal file
519
frontend-modern/src/hooks/__tests__/useResources.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
frontend-modern/src/hooks/useColumnVisibility.ts
Normal file
147
frontend-modern/src/hooks/useColumnVisibility.ts
Normal 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
Loading…
Reference in a new issue