mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-26 10:31:17 +00:00
feat: Add AI chat session sync across devices
Implements server-side persistence for AI chat sessions, allowing users to continue conversations across devices and browser sessions. Related to #1059. Backend: - Add chat session CRUD API endpoints (GET/PUT/DELETE) - Add persistence layer with per-user session storage - Support session cleanup for old sessions (90 days) - Multi-user support via auth context Frontend: - Rewrite aiChat store with server sync (debounced) - Add session management UI (new conversation, switch, delete) - Local storage as fallback/cache - Initialize sync on app startup when AI is enabled
This commit is contained in:
parent
695ced6273
commit
7db6b3e47d
25 changed files with 1552 additions and 123 deletions
|
|
@ -102,6 +102,7 @@ Community-maintained integrations and addons:
|
|||
| Alert-triggered AI analysis | — | ✅ |
|
||||
| Kubernetes AI analysis | — | ✅ |
|
||||
| Auto-fix + autonomous mode | — | ✅ |
|
||||
| Centralized agent profiles | — | ✅ |
|
||||
| Priority support | — | ✅ |
|
||||
|
||||
AI Patrol runs on your schedule (every 10 minutes to every 7 days, default 6 hours) and finds:
|
||||
|
|
@ -115,6 +116,7 @@ Technical highlights:
|
|||
- Cross-system context (nodes, VMs, backups, containers, and metrics history)
|
||||
- LLM analysis for high-impact findings + alert-triggered deep dives
|
||||
- Optional auto-fix with command safety policies and audit trail
|
||||
- Centralized agent profiles for consistent fleet settings
|
||||
|
||||
**[Try the live demo →](https://demo.pulserelay.pro)** or **[learn more at pulserelay.pro](https://pulserelay.pro)**
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ import (
|
|||
"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/remoteconfig"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rs/zerolog"
|
||||
gohost "github.com/shirou/gopsutil/v4/host"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
|
|
@ -114,6 +116,70 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// 2b. Compute Agent ID if missing (needed for remote config)
|
||||
// We replicate the logic from hostagent.New to ensure we get the same ID
|
||||
lookupHostname := strings.TrimSpace(cfg.HostnameOverride)
|
||||
if cfg.AgentID == "" {
|
||||
// Use a short timeout for host info
|
||||
hCtx, hCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
info, err := gohost.InfoWithContext(hCtx)
|
||||
hCancel()
|
||||
if err == nil {
|
||||
if lookupHostname == "" {
|
||||
lookupHostname = strings.TrimSpace(info.Hostname)
|
||||
}
|
||||
machineID := hostagent.GetReliableMachineID(info.HostID, logger)
|
||||
cfg.AgentID = machineID
|
||||
if cfg.AgentID == "" {
|
||||
// Fallback to hostname
|
||||
cfg.AgentID = lookupHostname
|
||||
}
|
||||
} else {
|
||||
logger.Warn().Err(err).Msg("Failed to fetch host info for Agent ID generation")
|
||||
}
|
||||
}
|
||||
if lookupHostname == "" {
|
||||
lookupHostname = strings.TrimSpace(cfg.HostnameOverride)
|
||||
if lookupHostname == "" {
|
||||
if name, err := os.Hostname(); err == nil {
|
||||
lookupHostname = strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. Fetch Remote Config
|
||||
// Only if we have enough info to contact server
|
||||
if cfg.PulseURL != "" && cfg.APIToken != "" && cfg.AgentID != "" {
|
||||
logger.Debug().Msg("Fetching remote configuration...")
|
||||
rc := remoteconfig.New(remoteconfig.Config{
|
||||
PulseURL: cfg.PulseURL,
|
||||
APIToken: cfg.APIToken,
|
||||
AgentID: cfg.AgentID,
|
||||
Hostname: lookupHostname,
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Use a short timeout for config fetch so we don't block startup too long
|
||||
rcCtx, rcCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
settings, commandsEnabled, err := rc.Fetch(rcCtx)
|
||||
rcCancel()
|
||||
|
||||
if err != nil {
|
||||
// Just log warning and proceed with local config
|
||||
logger.Warn().Err(err).Msg("Failed to fetch remote config - using local (or previously cached) defaults")
|
||||
} else {
|
||||
logger.Info().Msg("Successfully fetched remote configuration")
|
||||
if commandsEnabled != nil {
|
||||
cfg.EnableCommands = *commandsEnabled
|
||||
logger.Info().Bool("enabled", cfg.EnableCommands).Msg("Applied remote command execution setting")
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
applyRemoteSettings(&cfg, settings, &logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check if running as Windows service
|
||||
ranAsService, err := runAsWindowsServiceFunc(cfg, logger)
|
||||
if err != nil {
|
||||
|
|
@ -828,3 +894,60 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRemoteSettings merges remote settings into the local configuration.
|
||||
// Supported keys:
|
||||
// - enable_docker (bool)
|
||||
// - enable_kubernetes (bool)
|
||||
// - enable_proxmox (bool)
|
||||
// - proxmox_type (string)
|
||||
// - log_level (string)
|
||||
// - interval (string/duration)
|
||||
func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *zerolog.Logger) {
|
||||
for k, v := range settings {
|
||||
switch k {
|
||||
case "enable_docker":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.EnableDocker = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: enable_docker")
|
||||
}
|
||||
case "enable_kubernetes":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.EnableKubernetes = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: enable_kubernetes")
|
||||
}
|
||||
case "enable_proxmox":
|
||||
if b, ok := v.(bool); ok {
|
||||
cfg.EnableProxmox = b
|
||||
logger.Info().Bool("val", b).Msg("Remote config: enable_proxmox")
|
||||
}
|
||||
case "proxmox_type":
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.ProxmoxType = s
|
||||
logger.Info().Str("val", s).Msg("Remote config: proxmox_type")
|
||||
}
|
||||
case "log_level":
|
||||
if s, ok := v.(string); ok {
|
||||
if l, err := zerolog.ParseLevel(s); err == nil {
|
||||
cfg.LogLevel = l
|
||||
zerolog.SetGlobalLevel(l)
|
||||
// Re-create logger with new level
|
||||
newLogger := zerolog.New(os.Stdout).Level(l).With().Timestamp().Logger()
|
||||
cfg.Logger = &newLogger
|
||||
logger.Info().Str("val", s).Msg("Remote config: log_level")
|
||||
}
|
||||
}
|
||||
case "interval":
|
||||
if s, ok := v.(string); ok {
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
cfg.Interval = d
|
||||
logger.Info().Str("val", s).Msg("Remote config: interval")
|
||||
}
|
||||
} else if f, ok := v.(float64); ok {
|
||||
// JSON numbers are floats, assume seconds
|
||||
cfg.Interval = time.Duration(f) * time.Second
|
||||
logger.Info().Float64("val", f).Msg("Remote config: interval (s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
docs/API.md
30
docs/API.md
|
|
@ -420,6 +420,36 @@ Legacy install/uninstall scripts:
|
|||
`POST /api/agents/docker/report` - Docker container metrics
|
||||
`POST /api/agents/kubernetes/report` - Kubernetes cluster metrics
|
||||
|
||||
### Host Agent Management
|
||||
`GET /api/agents/host/lookup?id=<host_id>`
|
||||
`GET /api/agents/host/lookup?hostname=<hostname>`
|
||||
Looks up a host by ID or hostname/display name. Requires `host-agent:report`.
|
||||
|
||||
`POST /api/agents/host/uninstall`
|
||||
Host agent self-unregister during uninstall. Requires `host-agent:report`.
|
||||
|
||||
`POST /api/agents/host/unlink` (admin, `host-agent:manage`)
|
||||
Unlinks a host agent from a node.
|
||||
|
||||
`DELETE /api/agents/host/{host_id}` (admin, `host-agent:manage`)
|
||||
Removes a host agent from state.
|
||||
|
||||
### Agent Remote Config
|
||||
`GET /api/agents/host/{agent_id}/config`
|
||||
Returns the server-side config payload for an agent (used by remote config and debugging). Requires `host-agent:report`.
|
||||
|
||||
`PATCH /api/agents/host/{agent_id}/config` (admin, `host-agent:manage`)
|
||||
Updates server-side config for an agent (e.g., `commandsEnabled`).
|
||||
|
||||
### Agent Profiles (Pro)
|
||||
`GET /api/admin/profiles` (admin, Pro)
|
||||
`POST /api/admin/profiles` (admin, Pro)
|
||||
`PUT /api/admin/profiles/{id}` (admin, Pro)
|
||||
`DELETE /api/admin/profiles/{id}` (admin, Pro)
|
||||
`GET /api/admin/profiles/assignments` (admin, Pro)
|
||||
`POST /api/admin/profiles/assignments` (admin, Pro)
|
||||
`DELETE /api/admin/profiles/assignments/{agent_id}` (admin, Pro)
|
||||
|
||||
---
|
||||
|
||||
## 🌡️ Temperature Proxy (Legacy)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ Pulse uses a split config model:
|
|||
- **Host metadata**: `host_metadata.json`
|
||||
- **Docker metadata**: `docker_metadata.json`
|
||||
- **Guest metadata**: `guest_metadata.json`
|
||||
- **Agent profiles**: `agent_profiles.json`
|
||||
- **Agent profile assignments**: `agent_profile_assignments.json`
|
||||
- **Sessions**: `sessions.json` (persistent sessions, sensitive)
|
||||
- **Recovery tokens**: `recovery_tokens.json`
|
||||
- **Update history**: `update-history.jsonl`
|
||||
|
|
|
|||
|
|
@ -1,98 +1,199 @@
|
|||
# ☸️ Kubernetes (Helm)
|
||||
# Pulse on Kubernetes
|
||||
|
||||
Deploy Pulse to Kubernetes using the official Helm chart.
|
||||
This guide explains how to deploy the Pulse Server (Hub) and Pulse Agents on Kubernetes clusters, including immutable distributions like Talos Linux.
|
||||
|
||||
## 🚀 Installation
|
||||
## Prerequisites
|
||||
|
||||
1. **Install (OCI chart, recommended)**
|
||||
```bash
|
||||
helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart \
|
||||
--namespace pulse \
|
||||
--create-namespace
|
||||
```
|
||||
* A Kubernetes cluster (v1.19+)
|
||||
* `helm` (v3+) installed locally
|
||||
* `kubectl` configured to talk to your cluster
|
||||
|
||||
2. **Access**
|
||||
```bash
|
||||
kubectl -n pulse port-forward svc/pulse 7655:7655
|
||||
```
|
||||
Open `http://localhost:7655` to complete setup.
|
||||
## 1. Deploying the Pulse Server
|
||||
|
||||
> If you installed using a Helm repository URL previously, you can keep using it. OCI is the preferred distribution format going forward.
|
||||
The Pulse Server is the central hub that collects metrics and manages agents.
|
||||
|
||||
---
|
||||
### Option A: Using Helm (Recommended)
|
||||
|
||||
## ⚙️ Configuration
|
||||
1. Install the chart from the OCI registry:
|
||||
```bash
|
||||
helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart \
|
||||
--namespace pulse \
|
||||
--create-namespace \
|
||||
--set persistence.enabled=true \
|
||||
--set persistence.size=10Gi
|
||||
```
|
||||
|
||||
Configure via `values.yaml` or `--set` flags.
|
||||
> **Note**: For production, ensure you configure a proper `storageClass` or `deployment.strategy.type=Recreate` if using ReadWriteOnce (RWO) volumes.
|
||||
|
||||
> **Note**: `API_TOKEN` / `API_TOKENS` environment variables are legacy. Prefer managing API tokens in the UI after initial setup.
|
||||
### Option B: Generating Static Manifests (For Talos / GitOps)
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `service.type` | Service type (ClusterIP/LoadBalancer) | `ClusterIP` |
|
||||
| `ingress.enabled` | Enable Ingress | `false` |
|
||||
| `persistence.enabled` | Enable PVC for /data | `true` |
|
||||
| `persistence.size` | PVC Size | `8Gi` |
|
||||
| `agent.enabled` | Enable legacy `pulse-docker-agent` workload (deprecated) | `false` |
|
||||
If you cannot use Helm directly on the cluster (e.g., restricted Talos environment), you can generate standard Kubernetes YAML manifests:
|
||||
|
||||
> Note: the `agent.*` block is legacy and references `pulse-docker-agent`. For new deployments, prefer the unified agent (`pulse-agent`) where possible.
|
||||
```bash
|
||||
helm template pulse oci://ghcr.io/rcourtman/pulse-chart \
|
||||
--namespace pulse \
|
||||
--set persistence.enabled=true \
|
||||
> pulse-server.yaml
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
You can then apply this file:
|
||||
|
||||
The Helm chart exposes only the main HTTP port (`7655`). Prometheus metrics are served on a separate listener (`9091`) and are **not** exposed by default.
|
||||
```bash
|
||||
kubectl apply -f pulse-server.yaml
|
||||
```
|
||||
|
||||
If you want to scrape metrics:
|
||||
1. Expose port `9091` with an additional Service.
|
||||
2. Point your `ServiceMonitor` at that service/port (the built-in ServiceMonitor targets the HTTP service by default).
|
||||
## 2. Deploying the Pulse Agent
|
||||
|
||||
### Example `values.yaml`
|
||||
### Important: Helm Chart Agent Is Legacy Docker-Only
|
||||
|
||||
The Helm chart includes an `agent` section, but it deploys the **deprecated** `pulse-docker-agent` (Docker socket metrics only). It does **not** deploy the unified `pulse-agent`.
|
||||
|
||||
If you need the unified agent on Kubernetes, use a custom DaemonSet as shown below.
|
||||
|
||||
### Unified Agent on Kubernetes (DaemonSet)
|
||||
|
||||
To monitor Kubernetes resources, run the unified agent as a DaemonSet and enable the Kubernetes module.
|
||||
|
||||
**Recommended options:**
|
||||
- **Kubernetes-only monitoring**: `PULSE_ENABLE_KUBERNETES=true` and `PULSE_ENABLE_HOST=false` (no host mounts required).
|
||||
- **Kubernetes + node metrics**: `PULSE_ENABLE_KUBERNETES=true` and `PULSE_ENABLE_HOST=true` (requires host mounts and privileged mode).
|
||||
|
||||
#### Minimal DaemonSet Example
|
||||
|
||||
This uses the main `rcourtman/pulse` image but runs the `pulse-agent` binary directly.
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: pulse.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
|
||||
server:
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/London
|
||||
secretEnv:
|
||||
create: true
|
||||
data:
|
||||
PULSE_AUTH_USER: "admin"
|
||||
PULSE_AUTH_PASS: "replace-me"
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: pulse-agent
|
||||
namespace: pulse
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: pulse-agent
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: pulse-agent
|
||||
spec:
|
||||
serviceAccountName: pulse-agent
|
||||
containers:
|
||||
- name: pulse-agent
|
||||
image: rcourtman/pulse:latest
|
||||
command: ["/usr/local/bin/pulse-agent"]
|
||||
args:
|
||||
- --enable-kubernetes
|
||||
env:
|
||||
- name: PULSE_URL
|
||||
value: "http://pulse-server.pulse.svc.cluster.local:7655"
|
||||
- name: PULSE_TOKEN
|
||||
value: "YOUR_API_TOKEN_HERE"
|
||||
- name: PULSE_ENABLE_HOST
|
||||
value: "false"
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
resources: {}
|
||||
```
|
||||
|
||||
Apply with:
|
||||
```bash
|
||||
helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart -n pulse -f values.yaml
|
||||
Use a token scoped for the agent:
|
||||
- `kubernetes:report` for Kubernetes reporting
|
||||
- `host-agent:report` if you enable host metrics
|
||||
|
||||
#### Add Host Metrics (Optional)
|
||||
|
||||
If you want node CPU/memory/disk metrics, add privileged mode plus host mounts:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: PULSE_ENABLE_HOST
|
||||
value: "true"
|
||||
- name: HOST_PROC
|
||||
value: "/host/proc"
|
||||
- name: HOST_SYS
|
||||
value: "/host/sys"
|
||||
- name: HOST_ETC
|
||||
value: "/host/etc"
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: host-proc
|
||||
mountPath: /host/proc
|
||||
readOnly: true
|
||||
- name: host-sys
|
||||
mountPath: /host/sys
|
||||
readOnly: true
|
||||
- name: host-root
|
||||
mountPath: /host/root
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: host-proc
|
||||
hostPath:
|
||||
path: /proc
|
||||
- name: host-sys
|
||||
hostPath:
|
||||
path: /sys
|
||||
- name: host-root
|
||||
hostPath:
|
||||
path: /
|
||||
```
|
||||
|
||||
#### RBAC
|
||||
|
||||
The Kubernetes agent uses the in-cluster API and needs read access to cluster resources (nodes, pods, deployments, etc.). Create a read-only `ClusterRole` and bind it to the `pulse-agent` service account.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: pulse-agent
|
||||
namespace: pulse
|
||||
---
|
||||
|
||||
## 🔄 Upgrades
|
||||
|
||||
```bash
|
||||
helm upgrade pulse oci://ghcr.io/rcourtman/pulse-chart -n pulse
|
||||
```
|
||||
|
||||
**Rollback**:
|
||||
```bash
|
||||
helm rollback pulse -n pulse
|
||||
```
|
||||
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: pulse-agent-read
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["nodes", "pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: pulse-agent-read
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: pulse-agent
|
||||
namespace: pulse
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: pulse-agent-read
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
## ⚠️ Troubleshooting
|
||||
## 3. Talos Linux Specifics
|
||||
|
||||
- **Check Pods**: `kubectl -n pulse get pods`
|
||||
- **Check Logs**: `kubectl -n pulse logs deploy/pulse`
|
||||
- **Scheduler Health**:
|
||||
```bash
|
||||
kubectl -n pulse exec deploy/pulse -- curl -s http://localhost:7655/api/monitoring/scheduler/health
|
||||
```
|
||||
Talos Linux is immutable, so you cannot install the agent via the shell script. Use the DaemonSet approach above.
|
||||
|
||||
### Agent Configuration for Talos
|
||||
* **Storage**: Talos mounts the ephemeral OS on `/`. Persistent data is usually in `/var`. The Pulse agent generally doesn't store state, but if it did, ensure it maps to a persistent path.
|
||||
* **Network**: The agent will report the Pod IP by default. To report the Node IP, set `PULSE_REPORT_IP` using the Downward API:
|
||||
|
||||
Add this to the DaemonSet `env` section:
|
||||
```yaml
|
||||
- name: PULSE_REPORT_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.hostIP
|
||||
```
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
* **Agent not showing in UI**: Check logs for the DaemonSet pods, for example: `kubectl logs -l app=pulse-agent -n pulse`.
|
||||
* **"Permission Denied" on metrics**: Ensure `securityContext.privileged: true` is set or proper capabilities are added.
|
||||
* **Connection Refused**: Ensure `PULSE_URL` is correct and reachable from the agent pods.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption k
|
|||
| Guest metadata/notes | — |
|
||||
| — | Host metadata (notes/tags/AI command overrides) |
|
||||
| — | Docker metadata cache |
|
||||
| — | Agent profiles and assignments |
|
||||
| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
|
||||
| — | Pulse Pro license (`license.enc`) |
|
||||
| — | Server sessions (`sessions.json`) |
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ Scheduled background analysis that correlates live state + metrics history to pr
|
|||
- **Autonomous mode**: optional diagnostic/fix commands through connected agents.
|
||||
- **Auto-fix**: guarded remediations when enabled.
|
||||
- **Kubernetes AI analysis**: deep cluster analysis beyond basic monitoring (Pro-only).
|
||||
- **Agent Profiles**: centralized configuration profiles for fleets of agents.
|
||||
|
||||
### What Free Users Still Get
|
||||
- **Heuristic Patrol**: local rule-based checks that surface common issues without any external AI provider.
|
||||
|
|
@ -33,6 +34,7 @@ Scheduled background analysis that correlates live state + metrics history to pr
|
|||
- **Patrol findings**: a prioritized list with severity, evidence, and recommended fixes.
|
||||
- **Alert timelines**: AI analysis events attached to the alert history for auditability.
|
||||
- **Remediation controls**: explicit toggles for autonomous mode and auto-fix workflows.
|
||||
- **Agent profiles**: create, edit, and assign profiles in **Settings → Agents → Agent Profiles**.
|
||||
|
||||
## Pro Feature Gates (License-Enforced)
|
||||
|
||||
|
|
@ -42,6 +44,7 @@ Pulse Pro licenses enable specific server-side features. These are enforced at t
|
|||
- `ai_alerts`: alert-triggered analysis runs.
|
||||
- `ai_autofix`: autonomous mode and auto-fix workflows.
|
||||
- `kubernetes_ai`: AI analysis for Kubernetes clusters (not basic monitoring).
|
||||
- `agent_profiles`: centralized agent configuration profiles.
|
||||
|
||||
## Why It Matters (Technical Value)
|
||||
|
||||
|
|
@ -50,6 +53,7 @@ Pulse Pro licenses enable specific server-side features. These are enforced at t
|
|||
- **Noise control**: Suppression and dismissal memory prevent alert fatigue.
|
||||
- **Actionable findings**: Each finding includes root-cause clues and next steps.
|
||||
- **Auditability**: AI analysis is attached to alerts and stored with finding history, so decisions are traceable.
|
||||
- **Fleet consistency**: Agent Profiles keep monitoring settings consistent across large deployments.
|
||||
|
||||
## Scheduling and Controls
|
||||
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@ Pulse Pro unlocks **LLM-backed AI Patrol** — automated background monitoring t
|
|||
- **What you actually get**: LLM-backed patrol analysis, alert-triggered deep dives, Kubernetes AI analysis, and optional auto-fix workflows.
|
||||
- **Technical highlights**: correlation across nodes/VMs/backups/containers, trend-based capacity predictions, and findings you can resolve/suppress.
|
||||
- **Scheduling**: 10 minutes to 7 days (default 6 hours).
|
||||
- **Agent Profiles (Pro)**: centralized agent configuration profiles. See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md).
|
||||
|
||||
## 📡 Monitoring & Agents
|
||||
|
||||
- **[Unified Agent](UNIFIED_AGENT.md)** – Single binary for host, Docker, and Kubernetes monitoring.
|
||||
- **[Centralized Agent Management (Pro)](CENTRALIZED_MANAGEMENT.md)** – Agent profiles and remote config.
|
||||
- **[Proxmox Backup Server](PBS.md)** – PBS integration, direct API vs PVE passthrough, token setup.
|
||||
- **[VM Disk Monitoring](VM_DISK_MONITORING.md)** – Enabling QEMU Guest Agent for disk stats.
|
||||
- **[Temperature Monitoring](TEMPERATURE_MONITORING.md)** – Agent-based temperature monitoring (`pulse-agent --enable-proxmox`). Sensor proxy is deprecated in v5.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ Auto-detection behavior:
|
|||
- **Kubernetes**: Enabled automatically by the installer when a kubeconfig is detected and `PULSE_ENABLE_KUBERNETES` was not explicitly set.
|
||||
- **Proxmox**: Enabled automatically by the installer when Proxmox is detected. Type auto-detects `pve` vs `pbs` if not specified.
|
||||
|
||||
To disable auto-detection, set the relevant flag or env var (`--disable-docker`, `--disable-kubernetes`, `--disable-proxmox`).
|
||||
To disable auto-detection, explicitly set the relevant flags or env vars, for example:
|
||||
|
||||
- `--enable-docker=false` or `PULSE_ENABLE_DOCKER=false`
|
||||
- `--enable-kubernetes=false` or `PULSE_ENABLE_KUBERNETES=false`
|
||||
- `--enable-proxmox=false` or `PULSE_ENABLE_PROXMOX=false`
|
||||
|
||||
## Installation Options
|
||||
|
||||
|
|
@ -194,6 +198,18 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
|||
PULSE_DISABLE_AUTO_UPDATE=true
|
||||
```
|
||||
|
||||
## Remote Configuration (Agent Profiles, Pro)
|
||||
|
||||
Pulse Pro can push centralized settings to agents via Agent Profiles.
|
||||
|
||||
Behavior:
|
||||
- The agent fetches remote config on startup from `/api/agents/host/{agent_id}/config`.
|
||||
- Profile settings override local flags/env for supported keys.
|
||||
- Profile changes take effect on the next agent restart.
|
||||
- Command execution (`commandsEnabled`) is controlled per agent in **Settings → Agents → Unified Agents** and can change live.
|
||||
|
||||
See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys and profile setup.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
|
|
@ -319,4 +335,3 @@ If your Docker Swarm cluster isn't being detected:
|
|||
```bash
|
||||
LOG_LEVEL=debug journalctl -u pulse-agent -f
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -794,6 +794,10 @@ function App() {
|
|||
AIAPI.getSettings()
|
||||
.then((settings) => {
|
||||
aiChatStore.setEnabled(settings.enabled && settings.configured);
|
||||
// Initialize chat session sync with server
|
||||
if (settings.enabled && settings.configured) {
|
||||
aiChatStore.initSync();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
aiChatStore.setEnabled(false);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import type {
|
|||
AIExecuteResponse,
|
||||
AIStreamEvent,
|
||||
AICostSummary,
|
||||
AIChatSession,
|
||||
AIChatSessionSummary,
|
||||
} from '@/types/ai';
|
||||
import type {
|
||||
PatternsResponse,
|
||||
|
|
@ -474,4 +476,73 @@ export class AIAPI {
|
|||
logger.debug('[AI SSE] Reader released', { receivedComplete, receivedDone });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Chat Sessions API - sync across devices
|
||||
// ============================================
|
||||
|
||||
// List all chat sessions for the current user
|
||||
static async listChatSessions(): Promise<AIChatSessionSummary[]> {
|
||||
return apiFetchJSON(`${this.baseUrl}/ai/chat/sessions`) as Promise<AIChatSessionSummary[]>;
|
||||
}
|
||||
|
||||
// Get a specific chat session by ID
|
||||
static async getChatSession(sessionId: string): Promise<AIChatSession> {
|
||||
const response = await apiFetchJSON(`${this.baseUrl}/ai/chat/sessions/${sessionId}`);
|
||||
// Convert server format to client format (snake_case to camelCase)
|
||||
return this.deserializeChatSession(response);
|
||||
}
|
||||
|
||||
// Save a chat session (create or update)
|
||||
static async saveChatSession(session: AIChatSession): Promise<AIChatSession> {
|
||||
const response = await apiFetchJSON(`${this.baseUrl}/ai/chat/sessions/${session.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(this.serializeChatSession(session)),
|
||||
});
|
||||
return this.deserializeChatSession(response);
|
||||
}
|
||||
|
||||
// Delete a chat session
|
||||
static async deleteChatSession(sessionId: string): Promise<void> {
|
||||
await apiFetch(`${this.baseUrl}/ai/chat/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to convert server format (snake_case) to client format (camelCase)
|
||||
private static deserializeChatSession(data: any): AIChatSession {
|
||||
return {
|
||||
id: data.id,
|
||||
username: data.username || '',
|
||||
title: data.title || '',
|
||||
createdAt: new Date(data.created_at || data.createdAt),
|
||||
updatedAt: new Date(data.updated_at || data.updatedAt),
|
||||
messages: (data.messages || []).map((m: any) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date(m.timestamp),
|
||||
model: m.model,
|
||||
tokens: m.tokens,
|
||||
toolCalls: m.tool_calls || m.toolCalls,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to convert client format (camelCase) to server format (snake_case)
|
||||
private static serializeChatSession(session: AIChatSession): any {
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
messages: session.messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
model: m.model,
|
||||
tokens: m.tokens,
|
||||
tool_calls: m.toolCalls,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
const [showContextPicker, setShowContextPicker] = createSignal(false);
|
||||
const [contextSearch, setContextSearch] = createSignal('');
|
||||
|
||||
// Session management state
|
||||
const [showSessionPicker, setShowSessionPicker] = createSignal(false);
|
||||
|
||||
// Build a list of all available resources for the context picker
|
||||
const availableResources = createMemo(() => {
|
||||
const resources: Array<{
|
||||
|
|
@ -638,11 +641,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
aiChatStore.clearConversation();
|
||||
};
|
||||
|
||||
// Execute an approved command
|
||||
const executeApprovedCommand = async (messageId: string, approval: PendingApproval) => {
|
||||
// Mark as executing
|
||||
|
|
@ -997,20 +995,141 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={clearChat}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Clear chat"
|
||||
>
|
||||
<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="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>
|
||||
{/* Session management dropdown */}
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSessionPicker(!showSessionPicker());
|
||||
if (!showSessionPicker()) {
|
||||
aiChatStore.refreshSessions();
|
||||
}
|
||||
}}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Chat sessions"
|
||||
>
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={showSessionPicker()}>
|
||||
<div class="absolute right-0 top-full mt-1 w-72 max-h-96 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
|
||||
{/* New conversation button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
aiChatStore.newConversation();
|
||||
setShowSessionPicker(false);
|
||||
}}
|
||||
class="w-full px-3 py-2.5 text-left text-sm flex items-center gap-2 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="font-medium">New conversation</span>
|
||||
</button>
|
||||
|
||||
{/* Session list */}
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<Show when={aiChatStore.sessions.length > 0} fallback={
|
||||
<div class="px-3 py-4 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
No previous conversations
|
||||
</div>
|
||||
}>
|
||||
<For each={aiChatStore.sessions}>
|
||||
{(session) => {
|
||||
const isCurrentSession = () => session.id === aiChatStore.sessionId;
|
||||
const sessionDate = new Date(session.updated_at);
|
||||
const isToday = sessionDate.toDateString() === new Date().toDateString();
|
||||
const dateStr = isToday
|
||||
? sessionDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: sessionDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`group relative px-3 py-2 flex items-start gap-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer ${
|
||||
isCurrentSession() ? 'bg-purple-50 dark:bg-purple-900/20' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isCurrentSession()) {
|
||||
aiChatStore.switchSession(session.id);
|
||||
}
|
||||
setShowSessionPicker(false);
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`text-sm font-medium truncate ${
|
||||
isCurrentSession()
|
||||
? 'text-purple-700 dark:text-purple-300'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{session.title || 'Untitled conversation'}
|
||||
</span>
|
||||
<Show when={isCurrentSession()}>
|
||||
<span class="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-purple-500" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{session.message_count} messages</span>
|
||||
<span>·</span>
|
||||
<span>{dateStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Delete button - show on hover */}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Delete this conversation?')) {
|
||||
aiChatStore.deleteSession(session.id);
|
||||
}
|
||||
}}
|
||||
title="Delete conversation"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Sync status */}
|
||||
<div class="px-3 py-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Show when={aiChatStore.syncing} fallback={
|
||||
<svg class="w-3 h-3 text-green-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>
|
||||
}>
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</Show>
|
||||
{aiChatStore.syncing ? 'Syncing...' : 'Synced'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSessionPicker(false)}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { NodeModal } from './NodeModal';
|
||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||
import { UnifiedAgents } from './UnifiedAgents';
|
||||
import { AgentProfilesPanel } from './AgentProfilesPanel';
|
||||
import { OIDCPanel } from './OIDCPanel';
|
||||
import { AISettings } from './AISettings';
|
||||
import { AICostDashboard } from '@/components/AI/AICostDashboard';
|
||||
|
|
@ -3436,8 +3437,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
onClick={() => handleDisableDockerUpdateActionsChange(!disableDockerUpdateActions())}
|
||||
disabled={disableDockerUpdateActionsLocked() || savingDockerUpdateActions()}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${disableDockerUpdateActions()
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${disableDockerUpdateActionsLocked() ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={disableDockerUpdateActions()}
|
||||
|
|
@ -3454,6 +3455,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</Card>
|
||||
|
||||
<UnifiedAgents />
|
||||
|
||||
{/* Agent Profiles (Pro Feature) */}
|
||||
<AgentProfilesPanel />
|
||||
</Show>
|
||||
|
||||
{/* System General Tab */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import type { AIChatSession, AIChatSessionSummary } from '@/types/ai';
|
||||
|
||||
interface AIChatContext {
|
||||
targetType?: string;
|
||||
|
|
@ -34,10 +36,33 @@ interface Message {
|
|||
}>;
|
||||
}
|
||||
|
||||
// Local storage key
|
||||
// Local storage keys
|
||||
const HISTORY_STORAGE_KEY = 'pulse:ai_chat_history';
|
||||
const SESSION_ID_KEY = 'pulse:ai_chat_session_id';
|
||||
|
||||
// Load initial messages from storage
|
||||
// Generate a unique session ID
|
||||
const generateSessionId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
// Load session ID from storage or generate new one
|
||||
const loadOrCreateSessionId = (): string => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SESSION_ID_KEY);
|
||||
if (stored) return stored;
|
||||
} catch (e) {
|
||||
logger.error('Failed to load session ID:', e);
|
||||
}
|
||||
const newId = generateSessionId();
|
||||
try {
|
||||
localStorage.setItem(SESSION_ID_KEY, newId);
|
||||
} catch (e) {
|
||||
logger.error('Failed to save session ID:', e);
|
||||
}
|
||||
return newId;
|
||||
};
|
||||
|
||||
// Load initial messages from local storage (fallback/cache)
|
||||
const loadMessagesFromStorage = (): Message[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
|
||||
|
|
@ -55,6 +80,15 @@ const loadMessagesFromStorage = (): Message[] => {
|
|||
}
|
||||
};
|
||||
|
||||
// Save messages to local storage (cache)
|
||||
const saveMessagesToStorage = (msgs: Message[]) => {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(msgs));
|
||||
} catch (e) {
|
||||
logger.error('Failed to save chat history:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Global state for the AI chat drawer
|
||||
const [isAIChatOpen, setIsAIChatOpen] = createSignal(false);
|
||||
const [aiChatContext, setAIChatContext] = createSignal<AIChatContext>({});
|
||||
|
|
@ -62,9 +96,76 @@ const [contextItems, setContextItems] = createSignal<ContextItem[]>([]);
|
|||
const [messages, setMessages] = createSignal<Message[]>(loadMessagesFromStorage());
|
||||
const [aiEnabled, setAiEnabled] = createSignal<boolean | null>(null); // null = not checked yet
|
||||
|
||||
// Session management state
|
||||
const [currentSessionId, setCurrentSessionId] = createSignal<string>(loadOrCreateSessionId());
|
||||
const [sessionTitle, setSessionTitle] = createSignal<string>('');
|
||||
const [sessions, setSessions] = createSignal<AIChatSessionSummary[]>([]);
|
||||
const [syncEnabled, _setSyncEnabled] = createSignal<boolean>(true);
|
||||
const [isSyncing, setIsSyncing] = createSignal<boolean>(false);
|
||||
|
||||
// Debounce timer for saving
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 2000; // Save 2 seconds after last change
|
||||
|
||||
// Store reference to AI input for focusing from keyboard shortcuts
|
||||
let aiInputRef: HTMLTextAreaElement | null = null;
|
||||
|
||||
// Sync current session to server (debounced)
|
||||
const syncToServer = async () => {
|
||||
if (!syncEnabled()) return;
|
||||
|
||||
const msgs = messages();
|
||||
if (msgs.length === 0) return; // Don't save empty sessions
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const session: AIChatSession = {
|
||||
id: currentSessionId(),
|
||||
username: '', // Server will set from auth
|
||||
title: sessionTitle() || '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
messages: msgs,
|
||||
};
|
||||
|
||||
await AIAPI.saveChatSession(session);
|
||||
logger.debug('Chat session synced to server', { sessionId: session.id, messageCount: msgs.length });
|
||||
} catch (e) {
|
||||
logger.error('Failed to sync chat session to server:', e);
|
||||
// Keep local storage as fallback
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced sync
|
||||
const debouncedSync = () => {
|
||||
if (saveDebounceTimer) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
}
|
||||
saveDebounceTimer = setTimeout(syncToServer, SAVE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Load session from server
|
||||
const loadSessionFromServer = async (sessionId: string): Promise<boolean> => {
|
||||
try {
|
||||
const session = await AIAPI.getChatSession(sessionId);
|
||||
setMessages(session.messages);
|
||||
setSessionTitle(session.title);
|
||||
saveMessagesToStorage(session.messages); // Update local cache
|
||||
logger.debug('Chat session loaded from server', { sessionId, messageCount: session.messages.length });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('404') || e?.message?.includes('not found')) {
|
||||
// Session doesn't exist on server yet, that's fine
|
||||
logger.debug('Chat session not found on server, using local', { sessionId });
|
||||
return false;
|
||||
}
|
||||
logger.error('Failed to load chat session from server:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const aiChatStore = {
|
||||
// Check if chat is open
|
||||
get isOpen() {
|
||||
|
|
@ -91,6 +192,26 @@ export const aiChatStore = {
|
|||
return aiEnabled();
|
||||
},
|
||||
|
||||
// Get current session ID
|
||||
get sessionId() {
|
||||
return currentSessionId();
|
||||
},
|
||||
|
||||
// Get session title
|
||||
get title() {
|
||||
return sessionTitle();
|
||||
},
|
||||
|
||||
// Get all sessions (for session picker)
|
||||
get sessions() {
|
||||
return sessions();
|
||||
},
|
||||
|
||||
// Check if syncing
|
||||
get syncing() {
|
||||
return isSyncing();
|
||||
},
|
||||
|
||||
// Check if a specific item is in context
|
||||
hasContextItem(id: string) {
|
||||
return contextItems().some(item => item.id === id);
|
||||
|
|
@ -104,10 +225,110 @@ export const aiChatStore = {
|
|||
// Set messages (for persistence from AIChat component)
|
||||
setMessages(msgs: Message[]) {
|
||||
setMessages(msgs);
|
||||
saveMessagesToStorage(msgs);
|
||||
debouncedSync();
|
||||
},
|
||||
|
||||
// Set session title
|
||||
setTitle(title: string) {
|
||||
setSessionTitle(title);
|
||||
debouncedSync();
|
||||
},
|
||||
|
||||
// Initialize sync - call this on app startup
|
||||
async initSync() {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(msgs));
|
||||
// Try to load current session from server
|
||||
const sessionId = currentSessionId();
|
||||
const loaded = await loadSessionFromServer(sessionId);
|
||||
|
||||
if (!loaded) {
|
||||
// Server doesn't have this session, use local storage
|
||||
const localMessages = loadMessagesFromStorage();
|
||||
if (localMessages.length > 0) {
|
||||
// Sync local messages to server
|
||||
setMessages(localMessages);
|
||||
await syncToServer();
|
||||
}
|
||||
}
|
||||
|
||||
// Load session list
|
||||
await this.refreshSessions();
|
||||
} catch (e) {
|
||||
logger.error('Failed to save chat history:', e);
|
||||
logger.error('Failed to initialize chat sync:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh session list from server
|
||||
async refreshSessions() {
|
||||
try {
|
||||
const sessionList = await AIAPI.listChatSessions();
|
||||
setSessions(sessionList);
|
||||
} catch (e) {
|
||||
logger.error('Failed to load chat sessions:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Switch to a different session
|
||||
async switchSession(sessionId: string) {
|
||||
// Save current session first
|
||||
await syncToServer();
|
||||
|
||||
// Load new session
|
||||
setCurrentSessionId(sessionId);
|
||||
try {
|
||||
localStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||
} catch (e) {
|
||||
logger.error('Failed to save session ID:', e);
|
||||
}
|
||||
|
||||
const loaded = await loadSessionFromServer(sessionId);
|
||||
if (!loaded) {
|
||||
// Session doesn't exist, clear messages
|
||||
setMessages([]);
|
||||
setSessionTitle('');
|
||||
saveMessagesToStorage([]);
|
||||
}
|
||||
},
|
||||
|
||||
// Start a new conversation
|
||||
async newConversation() {
|
||||
// Save current session first
|
||||
await syncToServer();
|
||||
|
||||
// Generate new session ID
|
||||
const newId = generateSessionId();
|
||||
setCurrentSessionId(newId);
|
||||
try {
|
||||
localStorage.setItem(SESSION_ID_KEY, newId);
|
||||
} catch (e) {
|
||||
logger.error('Failed to save session ID:', e);
|
||||
}
|
||||
|
||||
// Clear messages
|
||||
setMessages([]);
|
||||
setSessionTitle('');
|
||||
saveMessagesToStorage([]);
|
||||
localStorage.removeItem(HISTORY_STORAGE_KEY);
|
||||
|
||||
// Refresh session list
|
||||
await this.refreshSessions();
|
||||
},
|
||||
|
||||
// Delete a session
|
||||
async deleteSession(sessionId: string) {
|
||||
try {
|
||||
await AIAPI.deleteChatSession(sessionId);
|
||||
|
||||
// If we deleted the current session, start a new one
|
||||
if (sessionId === currentSessionId()) {
|
||||
await this.newConversation();
|
||||
} else {
|
||||
// Just refresh the list
|
||||
await this.refreshSessions();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to delete chat session:', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -188,10 +409,9 @@ export const aiChatStore = {
|
|||
setAIChatContext({});
|
||||
},
|
||||
|
||||
// Clear conversation (start fresh)
|
||||
// Clear conversation (start fresh) - now creates a new session
|
||||
clearConversation() {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(HISTORY_STORAGE_KEY);
|
||||
this.newConversation();
|
||||
},
|
||||
|
||||
// Convenience method to update context for a specific target (host, VM, container, etc.)
|
||||
|
|
@ -235,4 +455,13 @@ export const aiChatStore = {
|
|||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Force sync now (for manual save)
|
||||
async syncNow() {
|
||||
if (saveDebounceTimer) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
saveDebounceTimer = null;
|
||||
}
|
||||
await syncToServer();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -243,3 +243,47 @@ export interface AICostSummary {
|
|||
daily_totals: AICostDailySummary[];
|
||||
totals: AICostProviderModelSummary;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Chat Session Types (server-synced)
|
||||
// ============================================
|
||||
|
||||
export interface AIChatMessageTokens {
|
||||
input: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
export interface AIChatToolCall {
|
||||
name: string;
|
||||
input: string;
|
||||
output: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface AIChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
model?: string;
|
||||
tokens?: AIChatMessageTokens;
|
||||
toolCalls?: AIChatToolCall[];
|
||||
}
|
||||
|
||||
export interface AIChatSession {
|
||||
id: string;
|
||||
username: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
messages: AIChatMessage[];
|
||||
}
|
||||
|
||||
// Summary returned by list endpoint (no messages)
|
||||
export interface AIChatSessionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3241,3 +3241,258 @@ func (h *AISettingsHandler) HandleGetDismissedFindings(w http.ResponseWriter, r
|
|||
log.Error().Err(err).Msg("Failed to write dismissed findings response")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Chat Sessions API
|
||||
// ============================================
|
||||
|
||||
// HandleListAIChatSessions lists all chat sessions for the current user (GET /api/ai/chat/sessions)
|
||||
func (h *AISettingsHandler) HandleListAIChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get username from auth context
|
||||
username := getAuthUsername(h.config, r)
|
||||
|
||||
sessions, err := h.persistence.GetAIChatSessionsForUser(username)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load chat sessions")
|
||||
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return summary (without full messages) for list view
|
||||
type sessionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
summaries := make([]sessionSummary, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
summaries = append(summaries, sessionSummary{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
MessageCount: len(s.Messages),
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, summaries); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write chat sessions response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetAIChatSession returns a specific chat session (GET /api/ai/chat/sessions/{id})
|
||||
func (h *AISettingsHandler) HandleGetAIChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract session ID from URL
|
||||
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "Session ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := getAuthUsername(h.config, r)
|
||||
|
||||
sessionsData, err := h.persistence.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load chat sessions")
|
||||
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session, exists := sessionsData.Sessions[sessionID]
|
||||
if !exists {
|
||||
http.Error(w, "Session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership (allow access if single-user or username matches)
|
||||
if session.Username != "" && session.Username != username {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, session); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write chat session response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSaveAIChatSession creates or updates a chat session (PUT /api/ai/chat/sessions/{id})
|
||||
func (h *AISettingsHandler) HandleSaveAIChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract session ID from URL
|
||||
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "Session ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := getAuthUsername(h.config, r)
|
||||
|
||||
// Parse request body
|
||||
var session config.AIChatSession
|
||||
if err := json.NewDecoder(r.Body).Decode(&session); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure session ID matches URL
|
||||
session.ID = sessionID
|
||||
|
||||
// Check ownership if session exists
|
||||
existingData, err := h.persistence.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load chat sessions")
|
||||
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if existing, exists := existingData.Sessions[sessionID]; exists {
|
||||
// Check ownership
|
||||
if existing.Username != "" && existing.Username != username {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Preserve original creation time and username
|
||||
session.CreatedAt = existing.CreatedAt
|
||||
session.Username = existing.Username
|
||||
} else {
|
||||
// New session - set creation time and username
|
||||
session.CreatedAt = time.Now()
|
||||
session.Username = username
|
||||
}
|
||||
|
||||
// Auto-generate title from first user message if not set
|
||||
if session.Title == "" && len(session.Messages) > 0 {
|
||||
for _, msg := range session.Messages {
|
||||
if msg.Role == "user" {
|
||||
title := msg.Content
|
||||
if len(title) > 50 {
|
||||
title = title[:47] + "..."
|
||||
}
|
||||
session.Title = title
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.Title == "" {
|
||||
session.Title = "New conversation"
|
||||
}
|
||||
|
||||
if err := h.persistence.SaveAIChatSession(&session); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save chat session")
|
||||
http.Error(w, "Failed to save chat session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("session_id", sessionID).
|
||||
Str("username", username).
|
||||
Int("messages", len(session.Messages)).
|
||||
Msg("Chat session saved")
|
||||
|
||||
if err := utils.WriteJSONResponse(w, session); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write save chat session response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteAIChatSession deletes a chat session (DELETE /api/ai/chat/sessions/{id})
|
||||
func (h *AISettingsHandler) HandleDeleteAIChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !CheckAuth(h.config, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract session ID from URL
|
||||
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "Session ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := getAuthUsername(h.config, r)
|
||||
|
||||
// Check ownership
|
||||
existingData, err := h.persistence.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load chat sessions")
|
||||
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if existing, exists := existingData.Sessions[sessionID]; exists {
|
||||
if existing.Username != "" && existing.Username != username {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.persistence.DeleteAIChatSession(sessionID); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete chat session")
|
||||
http.Error(w, "Failed to delete chat session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("session_id", sessionID).
|
||||
Str("username", username).
|
||||
Msg("Chat session deleted")
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getAuthUsername extracts the username from the current auth context
|
||||
func getAuthUsername(cfg *config.Config, r *http.Request) string {
|
||||
// Check OIDC session first
|
||||
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
||||
if username := GetSessionUsername(cookie.Value); username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
|
||||
// Check proxy auth
|
||||
if cfg.ProxyAuthSecret != "" {
|
||||
if valid, username, _ := CheckProxyAuth(cfg, r); valid && username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to basic auth username
|
||||
if cfg.AuthUser != "" {
|
||||
return cfg.AuthUser
|
||||
}
|
||||
|
||||
// Single-user mode without auth
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
|
|
@ -247,6 +248,10 @@ func (h *HostAgentHandlers) HandleConfig(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
// handleGetConfig returns the server-side config for an agent to apply.
|
||||
func (h *HostAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
if !h.ensureHostTokenMatch(w, r, hostID) {
|
||||
return
|
||||
}
|
||||
|
||||
config := h.monitor.GetHostAgentConfig(hostID)
|
||||
|
||||
resp := map[string]any{
|
||||
|
|
@ -260,6 +265,32 @@ func (h *HostAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
}
|
||||
|
||||
func (h *HostAgentHandlers) ensureHostTokenMatch(w http.ResponseWriter, r *http.Request, hostID string) bool {
|
||||
record := getAPITokenRecordFromRequest(r)
|
||||
if record == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if record.HasScope(config.ScopeHostManage) || record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeWildcard) {
|
||||
return true
|
||||
}
|
||||
|
||||
state := h.monitor.GetState()
|
||||
for _, host := range state.Hosts {
|
||||
if host.ID != hostID {
|
||||
continue
|
||||
}
|
||||
if host.TokenID == record.ID {
|
||||
return true
|
||||
}
|
||||
writeErrorResponse(w, http.StatusForbidden, "host_lookup_forbidden", "Host does not belong to this API token", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
writeErrorResponse(w, http.StatusNotFound, "host_not_found", "Host has not registered with Pulse yet", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
// handlePatchConfig updates the server-side config for a host agent.
|
||||
func (h *HostAgentHandlers) handlePatchConfig(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
|
|
|
|||
|
|
@ -312,6 +312,54 @@ func TestHandleLookupByHostname(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigForbiddenOnTokenMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hostID := "host-789"
|
||||
|
||||
handler := newHostAgentHandlerForTests(t, models.Host{
|
||||
ID: hostID,
|
||||
TokenID: "token-expected",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents/host/"+hostID+"/config", nil)
|
||||
attachAPITokenRecord(req, &config.APITokenRecord{
|
||||
ID: "token-other",
|
||||
Scopes: []string{config.ScopeHostReport},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleConfig(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigAllowsHostManageScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hostID := "host-910"
|
||||
|
||||
handler := newHostAgentHandlerForTests(t, models.Host{
|
||||
ID: hostID,
|
||||
TokenID: "token-expected",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agents/host/"+hostID+"/config", nil)
|
||||
attachAPITokenRecord(req, &config.APITokenRecord{
|
||||
ID: "token-other",
|
||||
Scopes: []string{config.ScopeHostManage},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleConfig(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newHostAgentHandlerForTests(t *testing.T, hosts ...models.Host) *HostAgentHandlers {
|
||||
t.Helper()
|
||||
|
||||
|
|
|
|||
|
|
@ -86,11 +86,12 @@ func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.R
|
|||
response := LicenseFeaturesResponse{
|
||||
LicenseStatus: string(state),
|
||||
Features: map[string]bool{
|
||||
license.FeatureAIPatrol: h.service.HasFeature(license.FeatureAIPatrol),
|
||||
license.FeatureAIAlerts: h.service.HasFeature(license.FeatureAIAlerts),
|
||||
license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix),
|
||||
license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI),
|
||||
license.FeatureUpdateAlerts: h.service.HasFeature(license.FeatureUpdateAlerts),
|
||||
license.FeatureAIPatrol: h.service.HasFeature(license.FeatureAIPatrol),
|
||||
license.FeatureAIAlerts: h.service.HasFeature(license.FeatureAIAlerts),
|
||||
license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix),
|
||||
license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI),
|
||||
license.FeatureUpdateAlerts: h.service.HasFeature(license.FeatureUpdateAlerts),
|
||||
license.FeatureAgentProfiles: h.service.HasFeature(license.FeatureAgentProfiles),
|
||||
},
|
||||
UpgradeURL: "https://pulserelay.pro",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1319,6 +1319,21 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/ai/intelligence/anomalies", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAnomalies))
|
||||
r.mux.HandleFunc("/api/ai/intelligence/learning", RequireAuth(r.config, r.aiSettingsHandler.HandleGetLearningStatus))
|
||||
|
||||
// AI Chat Sessions - sync across devices
|
||||
r.mux.HandleFunc("/api/ai/chat/sessions", RequireAuth(r.config, r.aiSettingsHandler.HandleListAIChatSessions))
|
||||
r.mux.HandleFunc("/api/ai/chat/sessions/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.aiSettingsHandler.HandleGetAIChatSession(w, req)
|
||||
case http.MethodPut:
|
||||
r.aiSettingsHandler.HandleSaveAIChatSession(w, req)
|
||||
case http.MethodDelete:
|
||||
r.aiSettingsHandler.HandleDeleteAIChatSession(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
|
||||
// Agent WebSocket for AI command execution
|
||||
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -37,6 +38,9 @@ type ConfigPersistence struct {
|
|||
aiFindingsFile string
|
||||
aiPatrolRunsFile string
|
||||
aiUsageHistoryFile string
|
||||
agentProfilesFile string
|
||||
agentAssignmentsFile string
|
||||
aiChatSessionsFile string
|
||||
crypto *crypto.CryptoManager
|
||||
fs FileSystem
|
||||
}
|
||||
|
|
@ -110,6 +114,9 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
|
|||
aiFindingsFile: filepath.Join(configDir, "ai_findings.json"),
|
||||
aiPatrolRunsFile: filepath.Join(configDir, "ai_patrol_runs.json"),
|
||||
aiUsageHistoryFile: filepath.Join(configDir, "ai_usage_history.json"),
|
||||
agentProfilesFile: filepath.Join(configDir, "agent_profiles.json"),
|
||||
agentAssignmentsFile: filepath.Join(configDir, "agent_profile_assignments.json"),
|
||||
aiChatSessionsFile: filepath.Join(configDir, "ai_chat_sessions.json"),
|
||||
crypto: cryptoMgr,
|
||||
fs: defaultFileSystem{},
|
||||
}
|
||||
|
|
@ -2006,3 +2013,277 @@ func (c *ConfigPersistence) LoadDockerMetadata() (*DockerMetadataStore, error) {
|
|||
|
||||
return NewDockerMetadataStore(c.configDir, c.fs), nil
|
||||
}
|
||||
|
||||
// LoadAgentProfiles loads agent profiles from file
|
||||
func (c *ConfigPersistence) LoadAgentProfiles() ([]models.AgentProfile, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
data, err := c.fs.ReadFile(c.agentProfilesFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.AgentProfile{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return []models.AgentProfile{}, nil
|
||||
}
|
||||
|
||||
var profiles []models.AgentProfile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// SaveAgentProfiles saves agent profiles to file
|
||||
func (c *ConfigPersistence) SaveAgentProfiles(profiles []models.AgentProfile) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(profiles, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.EnsureConfigDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.writeConfigFileLocked(c.agentProfilesFile, data, 0600)
|
||||
}
|
||||
|
||||
// LoadAgentProfileAssignments loads agent profile assignments from file
|
||||
func (c *ConfigPersistence) LoadAgentProfileAssignments() ([]models.AgentProfileAssignment, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
data, err := c.fs.ReadFile(c.agentAssignmentsFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.AgentProfileAssignment{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return []models.AgentProfileAssignment{}, nil
|
||||
}
|
||||
|
||||
var assignments []models.AgentProfileAssignment
|
||||
if err := json.Unmarshal(data, &assignments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// SaveAgentProfileAssignments saves agent profile assignments to file
|
||||
func (c *ConfigPersistence) SaveAgentProfileAssignments(assignments []models.AgentProfileAssignment) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
data, err := json.MarshalIndent(assignments, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.EnsureConfigDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.writeConfigFileLocked(c.agentAssignmentsFile, data, 0600)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Chat Sessions Persistence
|
||||
// ============================================
|
||||
|
||||
// AIChatSessionsData is the top-level container for chat sessions
|
||||
type AIChatSessionsData struct {
|
||||
Version int `json:"version"`
|
||||
LastSaved time.Time `json:"last_saved"`
|
||||
Sessions map[string]*AIChatSession `json:"sessions"` // keyed by session ID
|
||||
}
|
||||
|
||||
// AIChatSession represents a single chat conversation
|
||||
type AIChatSession struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"` // owner (from auth), empty for single-user
|
||||
Title string `json:"title"` // auto-generated or user-set
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Messages []AIChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// AIChatMessage represents a single message in a chat session
|
||||
type AIChatMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Tokens *AIChatMessageTokens `json:"tokens,omitempty"`
|
||||
ToolCalls []AIChatToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// AIChatMessageTokens tracks token usage for a message
|
||||
type AIChatMessageTokens struct {
|
||||
Input int `json:"input"`
|
||||
Output int `json:"output"`
|
||||
}
|
||||
|
||||
// AIChatToolCall represents a tool call made during a message
|
||||
type AIChatToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input string `json:"input"`
|
||||
Output string `json:"output"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// SaveAIChatSessions persists all chat sessions to disk
|
||||
func (c *ConfigPersistence) SaveAIChatSessions(sessions map[string]*AIChatSession) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.EnsureConfigDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := AIChatSessionsData{
|
||||
Version: 1,
|
||||
LastSaved: time.Now(),
|
||||
Sessions: sessions,
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.writeConfigFileLocked(c.aiChatSessionsFile, jsonData, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("file", c.aiChatSessionsFile).
|
||||
Int("count", len(sessions)).
|
||||
Msg("AI chat sessions saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAIChatSessions loads all chat sessions from disk
|
||||
func (c *ConfigPersistence) LoadAIChatSessions() (*AIChatSessionsData, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
data, err := c.fs.ReadFile(c.aiChatSessionsFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &AIChatSessionsData{
|
||||
Version: 1,
|
||||
Sessions: make(map[string]*AIChatSession),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sessionsData AIChatSessionsData
|
||||
if err := json.Unmarshal(data, &sessionsData); err != nil {
|
||||
log.Error().Err(err).Str("file", c.aiChatSessionsFile).Msg("Failed to parse AI chat sessions file")
|
||||
return &AIChatSessionsData{
|
||||
Version: 1,
|
||||
Sessions: make(map[string]*AIChatSession),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if sessionsData.Sessions == nil {
|
||||
sessionsData.Sessions = make(map[string]*AIChatSession)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("file", c.aiChatSessionsFile).
|
||||
Int("count", len(sessionsData.Sessions)).
|
||||
Msg("AI chat sessions loaded")
|
||||
return &sessionsData, nil
|
||||
}
|
||||
|
||||
// SaveAIChatSession saves or updates a single chat session
|
||||
func (c *ConfigPersistence) SaveAIChatSession(session *AIChatSession) error {
|
||||
sessionsData, err := c.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.UpdatedAt = time.Now()
|
||||
sessionsData.Sessions[session.ID] = session
|
||||
|
||||
return c.SaveAIChatSessions(sessionsData.Sessions)
|
||||
}
|
||||
|
||||
// DeleteAIChatSession removes a chat session by ID
|
||||
func (c *ConfigPersistence) DeleteAIChatSession(sessionID string) error {
|
||||
sessionsData, err := c.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(sessionsData.Sessions, sessionID)
|
||||
return c.SaveAIChatSessions(sessionsData.Sessions)
|
||||
}
|
||||
|
||||
// GetAIChatSessionsForUser returns all sessions for a specific user (or all if username is empty)
|
||||
func (c *ConfigPersistence) GetAIChatSessionsForUser(username string) ([]*AIChatSession, error) {
|
||||
sessionsData, err := c.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []*AIChatSession
|
||||
for _, session := range sessionsData.Sessions {
|
||||
// If username filter is empty, return all; otherwise filter by username
|
||||
if username == "" || session.Username == username {
|
||||
result = append(result, session)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by UpdatedAt descending (most recent first)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CleanupOldAIChatSessions removes sessions older than maxAge
|
||||
func (c *ConfigPersistence) CleanupOldAIChatSessions(maxAge time.Duration) (int, error) {
|
||||
sessionsData, err := c.LoadAIChatSessions()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
removed := 0
|
||||
|
||||
for id, session := range sessionsData.Sessions {
|
||||
if session.UpdatedAt.Before(cutoff) {
|
||||
delete(sessionsData.Sessions, id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
if err := c.SaveAIChatSessions(sessionsData.Sessions); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log.Info().
|
||||
Int("removed", removed).
|
||||
Dur("max_age", maxAge).
|
||||
Msg("Cleaned up old AI chat sessions")
|
||||
}
|
||||
|
||||
return removed, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ func New(cfg Config) (*Agent, error) {
|
|||
|
||||
displayName := hostname
|
||||
|
||||
machineID := getReliableMachineID(info.HostID, logger)
|
||||
machineID := GetReliableMachineID(info.HostID, logger)
|
||||
|
||||
agentID := strings.TrimSpace(cfg.AgentID)
|
||||
if agentID == "" {
|
||||
|
|
@ -866,7 +866,8 @@ func isLXCContainer() bool {
|
|||
// - Cloned VMs/hosts may share the same DMI product UUID
|
||||
// - Proxmox cluster nodes with identical hardware may have the same UUID
|
||||
// The /etc/machine-id file is guaranteed unique per installation.
|
||||
func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
|
||||
// GetReliableMachineID attempts to find a stable machine ID.
|
||||
func GetReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
|
||||
gopsutilID := strings.TrimSpace(gopsutilHostID)
|
||||
|
||||
// On Linux, prefer /etc/machine-id when available.
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ func TestGetReliableMachineID(t *testing.T) {
|
|||
t.Run("trims whitespace", func(t *testing.T) {
|
||||
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
|
||||
result := getReliableMachineID(" test-id ", logger)
|
||||
result := GetReliableMachineID(" test-id ", logger)
|
||||
if result != "test-id" {
|
||||
t.Errorf("getReliableMachineID trimmed result = %q, want %q", result, "test-id")
|
||||
}
|
||||
|
|
@ -109,7 +109,7 @@ func TestGetReliableMachineID(t *testing.T) {
|
|||
}
|
||||
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
|
||||
|
||||
result := getReliableMachineID("gopsutil-product-uuid", logger)
|
||||
result := GetReliableMachineID("gopsutil-product-uuid", logger)
|
||||
const want = "01234567-89ab-cdef-0123-456789abcdef"
|
||||
if result != want {
|
||||
t.Errorf("getReliableMachineID() = %q, want %q", result, want)
|
||||
|
|
@ -126,7 +126,7 @@ func TestGetReliableMachineID(t *testing.T) {
|
|||
},
|
||||
}, nil
|
||||
}
|
||||
result := getReliableMachineID("gopsutil-product-uuid", logger)
|
||||
result := GetReliableMachineID("gopsutil-product-uuid", logger)
|
||||
if result != "mac-001122aabbcc" {
|
||||
t.Errorf("getReliableMachineID() = %q, want %q", result, "mac-001122aabbcc")
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ func TestGetReliableMachineID(t *testing.T) {
|
|||
t.Run("Linux falls back to gopsutil ID when machine-id missing and MAC unavailable", func(t *testing.T) {
|
||||
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
|
||||
result := getReliableMachineID("gopsutil-product-uuid", logger)
|
||||
result := GetReliableMachineID("gopsutil-product-uuid", logger)
|
||||
if result != "gopsutil-product-uuid" {
|
||||
t.Errorf("getReliableMachineID() = %q, want %q", result, "gopsutil-product-uuid")
|
||||
}
|
||||
|
|
@ -156,14 +156,14 @@ func TestGetReliableMachineID(t *testing.T) {
|
|||
},
|
||||
}, nil
|
||||
}
|
||||
result := getReliableMachineID("gopsutil-product-uuid", logger)
|
||||
result := GetReliableMachineID("gopsutil-product-uuid", logger)
|
||||
if result != "mac-deadbeef0001" {
|
||||
t.Errorf("getReliableMachineID() = %q, want %q", result, "mac-deadbeef0001")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
t.Run("non-Linux uses gopsutil ID", func(t *testing.T) {
|
||||
result := getReliableMachineID("12345678-1234-1234-1234-123456789abc", logger)
|
||||
result := GetReliableMachineID("12345678-1234-1234-1234-123456789abc", logger)
|
||||
if result != "12345678-1234-1234-1234-123456789abc" {
|
||||
t.Errorf("Expected gopsutil ID, got %q", result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ const (
|
|||
FeatureAIAutoFix = "ai_autofix" // Automatic remediation
|
||||
FeatureKubernetesAI = "kubernetes_ai" // AI analysis of K8s (NOT basic monitoring)
|
||||
|
||||
// Pro tier features - Fleet Management
|
||||
FeatureAgentProfiles = "agent_profiles" // Centralized agent configuration profiles
|
||||
|
||||
// Free tier features - Monitoring
|
||||
FeatureUpdateAlerts = "update_alerts" // Alerts for pending container/package updates (free feature)
|
||||
|
||||
|
|
@ -43,6 +46,7 @@ var TierFeatures = map[Tier][]string{
|
|||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureKubernetesAI,
|
||||
FeatureAgentProfiles,
|
||||
FeatureUpdateAlerts,
|
||||
},
|
||||
TierProAnnual: {
|
||||
|
|
@ -50,6 +54,7 @@ var TierFeatures = map[Tier][]string{
|
|||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureKubernetesAI,
|
||||
FeatureAgentProfiles,
|
||||
FeatureUpdateAlerts,
|
||||
},
|
||||
TierLifetime: {
|
||||
|
|
@ -57,6 +62,7 @@ var TierFeatures = map[Tier][]string{
|
|||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureKubernetesAI,
|
||||
FeatureAgentProfiles,
|
||||
FeatureUpdateAlerts,
|
||||
},
|
||||
TierMSP: {
|
||||
|
|
@ -64,6 +70,7 @@ var TierFeatures = map[Tier][]string{
|
|||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureKubernetesAI,
|
||||
FeatureAgentProfiles,
|
||||
FeatureUpdateAlerts,
|
||||
FeatureUnlimited,
|
||||
// Note: FeatureMultiUser, FeatureWhiteLabel, FeatureMultiTenant
|
||||
|
|
@ -74,6 +81,7 @@ var TierFeatures = map[Tier][]string{
|
|||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureKubernetesAI,
|
||||
FeatureAgentProfiles,
|
||||
FeatureUpdateAlerts,
|
||||
FeatureUnlimited,
|
||||
FeatureMultiUser,
|
||||
|
|
@ -137,6 +145,8 @@ func GetFeatureDisplayName(feature string) string {
|
|||
return "Multi-Tenant Mode"
|
||||
case FeatureUnlimited:
|
||||
return "Unlimited Instances"
|
||||
case FeatureAgentProfiles:
|
||||
return "Centralized Agent Profiles"
|
||||
default:
|
||||
return feature
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1365,25 +1365,61 @@ func (m *Monitor) UnlinkHostAgent(hostID string) error {
|
|||
|
||||
// HostAgentConfig represents server-side configuration for a host agent.
|
||||
type HostAgentConfig struct {
|
||||
CommandsEnabled *bool `json:"commandsEnabled,omitempty"` // nil = use agent default
|
||||
CommandsEnabled *bool `json:"commandsEnabled,omitempty"` // nil = use agent default
|
||||
Settings map[string]interface{} `json:"settings,omitempty"` // Merged profile settings
|
||||
}
|
||||
|
||||
// GetHostAgentConfig returns the server-side configuration for a host agent.
|
||||
// The agent can poll this to apply remote config overrides.
|
||||
func (m *Monitor) GetHostAgentConfig(hostID string) HostAgentConfig {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
if hostID == "" || m.hostMetadataStore == nil {
|
||||
if hostID == "" {
|
||||
return HostAgentConfig{}
|
||||
}
|
||||
|
||||
meta := m.hostMetadataStore.Get(hostID)
|
||||
if meta == nil {
|
||||
return HostAgentConfig{}
|
||||
cfg := HostAgentConfig{}
|
||||
|
||||
// 1. Load Host Metadata (CommandsEnabled)
|
||||
if m.hostMetadataStore != nil {
|
||||
if meta := m.hostMetadataStore.Get(hostID); meta != nil {
|
||||
cfg.CommandsEnabled = meta.CommandsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
return HostAgentConfig{
|
||||
CommandsEnabled: meta.CommandsEnabled,
|
||||
// 2. Load Profile Configuration
|
||||
// We handle errors gracefully by logging them and continuing with partial config
|
||||
if m.persistence != nil {
|
||||
// Load Assignments
|
||||
assignments, err := m.persistence.LoadAgentProfileAssignments()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to load agent profile assignments during config fetch")
|
||||
} else {
|
||||
var profileID string
|
||||
for _, a := range assignments {
|
||||
if a.AgentID == hostID {
|
||||
profileID = a.ProfileID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if profileID != "" {
|
||||
// Load Profiles
|
||||
profiles, err := m.persistence.LoadAgentProfiles()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to load agent profiles during config fetch")
|
||||
} else {
|
||||
for _, p := range profiles {
|
||||
if p.ID == profileID {
|
||||
cfg.Settings = p.Config
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// UpdateHostAgentConfig updates the server-side configuration for a host agent.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue