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:
rcourtman 2026-01-08 10:46:17 +00:00
parent 695ced6273
commit 7db6b3e47d
25 changed files with 1552 additions and 123 deletions

View file

@ -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)**

View file

@ -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)")
}
}
}
}

View file

@ -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)

View file

@ -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`

View file

@ -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.

View file

@ -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`) |

View file

@ -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

View file

@ -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.

View file

@ -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
```

View file

@ -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);

View file

@ -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,
})),
};
}
}

View file

@ -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"

View file

@ -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 */}

View file

@ -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();
},
};

View file

@ -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;
}

View file

@ -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 ""
}

View file

@ -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)

View file

@ -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()

View file

@ -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",
}

View file

@ -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)

View file

@ -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
}

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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.