feat(ai): Add URL discovery tool - AI can find and set resource URLs

- Add MetadataProvider interface for AI to update resource URLs
- Add set_resource_url tool to AI service
- Wire up metadata stores to AI service via router
- Add URL discovery guidance to AI system prompt
- AI can now inspect guests/containers/hosts for web services
  and automatically save discovered URLs to Pulse metadata

Usage: Ask the AI 'Find the web URL for this container' and it will:
1. Check for listening ports and web servers
2. Get the IP address
3. Verify the URL works
4. Save it to Pulse for quick dashboard access
This commit is contained in:
rcourtman
2025-12-10 00:29:07 +00:00
parent c8adbb7ae5
commit 387ae309cc
8 changed files with 757 additions and 89 deletions

View File

@@ -0,0 +1,109 @@
package ai
import (
"fmt"
"net/url"
"strings"
"github.com/rs/zerolog/log"
)
// MetadataProvider provides access to resource metadata stores
// This allows the AI to update resource URLs when it discovers web services
type MetadataProvider interface {
// SetGuestURL sets the custom URL for a Proxmox guest (VM/container)
SetGuestURL(guestID, customURL string) error
// SetDockerURL sets the custom URL for a Docker container/service
SetDockerURL(resourceID, customURL string) error
// SetHostURL sets the custom URL for a host
SetHostURL(hostID, customURL string) error
}
// SetMetadataProvider sets the metadata provider for URL updates
func (s *Service) SetMetadataProvider(mp MetadataProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.metadataProvider = mp
log.Info().Msg("AI service: metadata provider configured for URL discovery")
}
// SetResourceURL updates the URL for a resource in Pulse
// This is called by the AI when it discovers a web service
func (s *Service) SetResourceURL(resourceType, resourceID, customURL string) error {
s.mu.RLock()
mp := s.metadataProvider
s.mu.RUnlock()
if mp == nil {
return fmt.Errorf("metadata provider not configured")
}
// Validate and normalize the URL
if customURL != "" {
// Try to parse the URL
parsedURL, err := url.Parse(customURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
// Ensure scheme is present
if parsedURL.Scheme == "" {
// Default to http if no scheme provided
customURL = "http://" + customURL
parsedURL, err = url.Parse(customURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
}
// Validate scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL must use http:// or https:// scheme")
}
// Ensure host is present
if parsedURL.Host == "" {
return fmt.Errorf("URL must include a host")
}
}
// Route to the appropriate metadata store based on resource type
switch strings.ToLower(resourceType) {
case "guest", "vm", "container", "lxc", "qemu":
if err := mp.SetGuestURL(resourceID, customURL); err != nil {
return fmt.Errorf("failed to set guest URL: %w", err)
}
log.Info().
Str("resourceType", resourceType).
Str("resourceID", resourceID).
Str("url", customURL).
Msg("AI set guest URL")
case "docker", "docker_container", "docker_service":
if err := mp.SetDockerURL(resourceID, customURL); err != nil {
return fmt.Errorf("failed to set Docker URL: %w", err)
}
log.Info().
Str("resourceType", resourceType).
Str("resourceID", resourceID).
Str("url", customURL).
Msg("AI set Docker URL")
case "host":
if err := mp.SetHostURL(resourceID, customURL); err != nil {
return fmt.Errorf("failed to set host URL: %w", err)
}
log.Info().
Str("resourceType", resourceType).
Str("resourceID", resourceID).
Str("url", customURL).
Msg("AI set host URL")
default:
return fmt.Errorf("unknown resource type: %s (use 'guest', 'docker', or 'host')", resourceType)
}
return nil
}

View File

@@ -39,6 +39,8 @@ type Service struct {
alertProvider AlertProvider
knowledgeStore *knowledge.Store
resourceProvider ResourceProvider // Unified resource model provider (Phase 2)
patrolService *PatrolService // Background AI monitoring service
metadataProvider MetadataProvider // Enables AI to update resource URLs
}
// NewService creates a new AI service
@@ -66,6 +68,72 @@ func (s *Service) SetStateProvider(sp StateProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.stateProvider = sp
// Initialize patrol service if not already done
if s.patrolService == nil && sp != nil {
s.patrolService = NewPatrolService(s, sp)
}
}
// GetPatrolService returns the patrol service for background monitoring
func (s *Service) GetPatrolService() *PatrolService {
s.mu.RLock()
defer s.mu.RUnlock()
return s.patrolService
}
// SetPatrolThresholdProvider sets the threshold provider for patrol
// This should be called with an AlertThresholdAdapter to connect patrol to user-configured thresholds
func (s *Service) SetPatrolThresholdProvider(provider ThresholdProvider) {
s.mu.RLock()
patrol := s.patrolService
s.mu.RUnlock()
if patrol != nil {
patrol.SetThresholdProvider(provider)
}
}
// StartPatrol starts the background patrol service
func (s *Service) StartPatrol(ctx context.Context) {
s.mu.RLock()
patrol := s.patrolService
cfg := s.cfg
s.mu.RUnlock()
if patrol == nil {
log.Debug().Msg("Patrol service not initialized, cannot start")
return
}
if cfg == nil || !cfg.IsPatrolEnabled() {
log.Debug().Msg("AI Patrol not enabled")
return
}
// Configure patrol from AI config
patrolCfg := PatrolConfig{
Enabled: true,
QuickCheckInterval: cfg.GetPatrolInterval(),
DeepAnalysisInterval: 6 * time.Hour,
AnalyzeNodes: cfg.PatrolAnalyzeNodes,
AnalyzeGuests: cfg.PatrolAnalyzeGuests,
AnalyzeDocker: cfg.PatrolAnalyzeDocker,
AnalyzeStorage: cfg.PatrolAnalyzeStorage,
}
patrol.SetConfig(patrolCfg)
patrol.Start(ctx)
}
// StopPatrol stops the background patrol service
func (s *Service) StopPatrol() {
s.mu.RLock()
patrol := s.patrolService
s.mu.RUnlock()
if patrol != nil {
patrol.Stop()
}
}
// GuestInfo contains information about a guest (VM or container) found by VMID lookup
@@ -878,12 +946,20 @@ Always execute the commands rather than telling the user how to do it.`
callback(StreamEvent{Type: "thinking", Data: resp.ReasoningContent})
}
// Stream intermediate content so users see the AI's explanations between tool calls
// This gives users visibility into the AI's reasoning as it works, not just at the end
if resp.Content != "" {
callback(StreamEvent{Type: "content", Data: resp.Content})
}
log.Debug().
Int("tool_calls", len(resp.ToolCalls)).
Str("stop_reason", resp.StopReason).
Int("iteration", iteration).
Int("total_input_tokens", totalInputTokens).
Int("total_output_tokens", totalOutputTokens).
Int("content_length", len(resp.Content)).
Bool("has_content", resp.Content != "").
Msg("AI streaming iteration complete")
// If no tool calls, we're done
@@ -1057,6 +1133,10 @@ func (s *Service) getToolInputDisplay(tc providers.ToolCall) string {
case "fetch_url":
url, _ := tc.Input["url"].(string)
return url
case "set_resource_url":
resourceType, _ := tc.Input["resource_type"].(string)
url, _ := tc.Input["url"].(string)
return fmt.Sprintf("Set %s URL: %s", resourceType, url)
default:
return fmt.Sprintf("%v", tc.Input)
}
@@ -1154,41 +1234,26 @@ func (s *Service) getTools() []providers.Tool {
},
},
{
Name: "save_note",
Description: "Save a note about the current guest for future reference. Use this to remember important paths, configurations, services, credentials, or learnings. Notes are persisted and will be available in future sessions.",
Name: "set_resource_url",
Description: "Set the web URL for a resource in Pulse after discovering a web service. Use this when you've found a web server running on a guest/container/host and want to save it for quick access. The URL will appear as a clickable link in the Pulse dashboard.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"category": map[string]interface{}{
"resource_type": map[string]interface{}{
"type": "string",
"enum": []string{"service", "path", "config", "credential", "learning"},
"description": "Category of note: 'service' for discovered services, 'path' for important file paths, 'config' for configuration details, 'credential' for passwords/API keys, 'learning' for general learnings",
"description": "Type of resource: 'guest' for VMs/LXC containers, 'docker' for Docker containers/services, or 'host' for standalone hosts",
"enum": []string{"guest", "docker", "host"},
},
"title": map[string]interface{}{
"resource_id": map[string]interface{}{
"type": "string",
"description": "Short title for the note (e.g., 'MQTT Password', 'Config File Location', 'Web UI Port')",
"description": "The resource ID from the context (e.g., 'pve1-delly-101' for guests, 'dockerhost:container:abc123' for Docker). Use the ID from the current context.",
},
"content": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "The information to save (e.g., '/opt/zigbee2mqtt/data/configuration.yaml', 'admin:secret123', 'Port 8080')",
"description": "The discovered URL (e.g., 'http://192.168.1.50:8096' for Jellyfin). Use the IP/hostname and port you discovered.",
},
},
"required": []string{"category", "title", "content"},
},
},
{
Name: "get_notes",
Description: "Retrieve previously saved notes about the current guest. Use this to recall what was learned in previous sessions.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"category": map[string]interface{}{
"type": "string",
"enum": []string{"service", "path", "config", "credential", "learning", ""},
"description": "Optional category filter. Leave empty to get all notes.",
},
},
"required": []string{},
"required": []string{"resource_type", "resource_id", "url"},
},
},
}
@@ -1273,8 +1338,21 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
// If run_on_host is true, override the target type to run on host
if runOnHost {
log.Debug().
Str("command", command).
Str("target_host", targetHost).
Str("original_target_type", req.TargetType).
Str("original_target_id", req.TargetID).
Msg("run_on_host=true - overriding target type to 'host'")
execReq.TargetType = "host"
execReq.TargetID = ""
} else {
log.Debug().
Str("command", command).
Str("target_type", req.TargetType).
Str("target_id", req.TargetID).
Bool("run_on_host", runOnHost).
Msg("Executing command with current target type")
}
// Execute via agent
@@ -1424,75 +1502,37 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
execution.Success = true
return result, execution
case "save_note":
category, _ := tc.Input["category"].(string)
title, _ := tc.Input["title"].(string)
content, _ := tc.Input["content"].(string)
execution.Input = fmt.Sprintf("%s: %s", category, title)
case "set_resource_url":
resourceType, _ := tc.Input["resource_type"].(string)
resourceID, _ := tc.Input["resource_id"].(string)
url, _ := tc.Input["url"].(string)
execution.Input = fmt.Sprintf("%s %s -> %s", resourceType, resourceID, url)
if category == "" || title == "" || content == "" {
execution.Output = "Error: category, title, and content are all required"
if resourceType == "" {
execution.Output = "Error: resource_type is required (use 'guest', 'docker', or 'host')"
return execution.Output, execution
}
if resourceID == "" {
// Try to get the resource ID from the request context
if req.TargetID != "" {
resourceID = req.TargetID
} else {
execution.Output = "Error: resource_id is required"
return execution.Output, execution
}
}
if url == "" {
execution.Output = "Error: url is required"
return execution.Output, execution
}
if s.knowledgeStore == nil {
execution.Output = "Error: knowledge store not available"
// Update the metadata
if err := s.SetResourceURL(resourceType, resourceID, url); err != nil {
execution.Output = fmt.Sprintf("Error setting URL: %s", err)
return execution.Output, execution
}
// Get guest info from request
guestID := s.getGuestID(req)
guestName := req.TargetID
guestType := req.TargetType
if guestID == "" {
execution.Output = "Error: no guest context - save_note requires a target guest"
return execution.Output, execution
}
if err := s.knowledgeStore.SaveNote(guestID, guestName, guestType, category, title, content); err != nil {
execution.Output = fmt.Sprintf("Error saving note: %s", err)
return execution.Output, execution
}
execution.Output = fmt.Sprintf("Saved note [%s] %s: %s", category, title, content)
execution.Success = true
return execution.Output, execution
case "get_notes":
category, _ := tc.Input["category"].(string)
execution.Input = fmt.Sprintf("category=%s", category)
if s.knowledgeStore == nil {
execution.Output = "Error: knowledge store not available"
return execution.Output, execution
}
guestID := s.getGuestID(req)
if guestID == "" {
execution.Output = "Error: no guest context - get_notes requires a target guest"
return execution.Output, execution
}
notes, err := s.knowledgeStore.GetNotesByCategory(guestID, category)
if err != nil {
execution.Output = fmt.Sprintf("Error getting notes: %s", err)
return execution.Output, execution
}
if len(notes) == 0 {
execution.Output = "No notes found for this guest"
execution.Success = true
return execution.Output, execution
}
var result strings.Builder
result.WriteString(fmt.Sprintf("Found %d notes:\n", len(notes)))
for _, note := range notes {
result.WriteString(fmt.Sprintf("- [%s] %s: %s\n", note.Category, note.Title, note.Content))
}
execution.Output = result.String()
execution.Output = fmt.Sprintf("✅ Successfully set URL for %s '%s' to: %s\nThe URL is now visible in the Pulse dashboard as a clickable link.", resourceType, resourceID, url)
execution.Success = true
return execution.Output, execution
@@ -1582,6 +1622,43 @@ func (s *Service) fetchURL(ctx context.Context, urlStr string) (string, error) {
return result, nil
}
// sanitizeError cleans up error messages to remove internal networking details
// that are not helpful to users or AI models (IP addresses, port numbers, etc.)
func sanitizeError(err error) error {
if err == nil {
return nil
}
errMsg := err.Error()
// Replace raw TCP connection details with generic message
// e.g., "write tcp 192.168.0.123:7655->192.168.0.134:58004: i/o timeout"
// becomes "connection to agent timed out"
if strings.Contains(errMsg, "i/o timeout") {
if strings.Contains(errMsg, "failed to send command") {
return fmt.Errorf("connection to agent timed out - the agent may be disconnected or unreachable")
}
return fmt.Errorf("network timeout - the target may be unreachable")
}
// Replace "write tcp ... connection refused" style errors
if strings.Contains(errMsg, "connection refused") {
return fmt.Errorf("connection refused - the agent may not be running on the target host")
}
// Replace "no such host" errors
if strings.Contains(errMsg, "no such host") {
return fmt.Errorf("host not found - verify the hostname is correct and DNS is working")
}
// Replace "context deadline exceeded" with friendlier message
if strings.Contains(errMsg, "context deadline exceeded") {
return fmt.Errorf("operation timed out - the command may have taken too long")
}
return err
}
// executeOnAgent executes a command via the agent WebSocket
func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, command string) (string, error) {
if s.agentServer == nil {
@@ -1665,7 +1742,7 @@ func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, comman
result, err := s.agentServer.ExecuteCommand(ctx, agentID, cmd)
if err != nil {
return "", err
return "", sanitizeError(err)
}
if !result.Success {
@@ -1814,7 +1891,23 @@ Rules:
Pulse manages LXC containers agentlessly from the PVE host.
- DO NOT check for a Pulse agent process or service inside an LXC. It does not exist.
- Use run_command with run_on_host=false to execute commands inside the LXC. Pulse handles the routing.
- For pct commands, always use run_on_host=true and set target_host to the container's node.`
- For pct commands, always use run_on_host=true and set target_host to the container's node.
## URL Discovery Feature
When asked to find the web URL for a guest/container/host, or when you discover a web service:
1. **Inspect for web servers**: Check for listening ports (ss -tlnp), running services (nginx, apache, node, etc.)
2. **Get the IP address**: Use 'hostname -I' or 'ip addr' to find the IP
3. **Test the URL**: Use fetch_url to verify the service is responding
4. **Save the URL**: Use set_resource_url tool to save it to Pulse
Common discovery commands:
- Check listening ports: ss -tlnp | grep LISTEN
- Check nginx: systemctl status nginx && grep -r 'listen' /etc/nginx/
- Check running processes: ps aux | grep -E 'node|python|java|nginx|apache|httpd'
- Get IP: hostname -I | awk '{print $1}'
When you find a web service and are confident, use set_resource_url to save it. The resource_id should match the ID from the current context.`
// Add custom context from AI settings (user's infrastructure description)

View File

@@ -60,6 +60,34 @@ func (h *AISettingsHandler) SetResourceProvider(rp ai.ResourceProvider) {
h.aiService.SetResourceProvider(rp)
}
// SetMetadataProvider sets the metadata provider for AI URL discovery
func (h *AISettingsHandler) SetMetadataProvider(mp ai.MetadataProvider) {
h.aiService.SetMetadataProvider(mp)
}
// StartPatrol starts the background AI patrol service
func (h *AISettingsHandler) StartPatrol(ctx context.Context) {
h.aiService.StartPatrol(ctx)
}
// SetPatrolThresholdProvider sets the threshold provider for the patrol service
func (h *AISettingsHandler) SetPatrolThresholdProvider(provider ai.ThresholdProvider) {
h.aiService.SetPatrolThresholdProvider(provider)
}
// SetPatrolFindingsPersistence enables findings persistence for the patrol service
func (h *AISettingsHandler) SetPatrolFindingsPersistence(persistence ai.FindingsPersistence) error {
if patrol := h.aiService.GetPatrolService(); patrol != nil {
return patrol.SetFindingsPersistence(persistence)
}
return nil
}
// StopPatrol stops the background AI patrol service
func (h *AISettingsHandler) StopPatrol() {
h.aiService.StopPatrol()
}
// AISettingsResponse is returned by GET /api/settings/ai
// API key is masked for security
type AISettingsResponse struct {
@@ -1526,3 +1554,293 @@ func (h *AISettingsHandler) HandleOAuthDisconnect(w http.ResponseWriter, r *http
}
}
// PatrolStatusResponse is the response for GET /api/ai/patrol/status
type PatrolStatusResponse struct {
Running bool `json:"running"`
Enabled bool `json:"enabled"`
LastPatrolAt *time.Time `json:"last_patrol_at,omitempty"`
LastDeepAnalysis *time.Time `json:"last_deep_analysis_at,omitempty"`
NextPatrolAt *time.Time `json:"next_patrol_at,omitempty"`
LastDurationMs int64 `json:"last_duration_ms"`
ResourcesChecked int `json:"resources_checked"`
FindingsCount int `json:"findings_count"`
Healthy bool `json:"healthy"`
Summary struct {
Critical int `json:"critical"`
Warning int `json:"warning"`
Watch int `json:"watch"`
Info int `json:"info"`
} `json:"summary"`
}
// HandleGetPatrolStatus returns the current patrol status (GET /api/ai/patrol/status)
func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
// Patrol not initialized
response := PatrolStatusResponse{
Running: false,
Enabled: false,
Healthy: true,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write patrol status response")
}
return
}
status := patrol.GetStatus()
summary := patrol.GetFindingsSummary()
response := PatrolStatusResponse{
Running: status.Running,
Enabled: h.aiService.IsEnabled(),
LastPatrolAt: status.LastPatrolAt,
LastDeepAnalysis: status.LastDeepAnalysis,
NextPatrolAt: status.NextPatrolAt,
LastDurationMs: status.LastDuration.Milliseconds(),
ResourcesChecked: status.ResourcesChecked,
FindingsCount: status.FindingsCount,
Healthy: status.Healthy,
}
response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning
response.Summary.Watch = summary.Watch
response.Summary.Info = summary.Info
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write patrol status response")
}
}
// HandleGetPatrolFindings returns all active findings (GET /api/ai/patrol/findings)
func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
// Return empty findings
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
log.Error().Err(err).Msg("Failed to write patrol findings response")
}
return
}
// Check for resource_id query parameter
resourceID := r.URL.Query().Get("resource_id")
var findings []*ai.Finding
if resourceID != "" {
findings = patrol.GetFindingsForResource(resourceID)
} else {
findings = patrol.GetAllFindings()
}
if err := utils.WriteJSONResponse(w, findings); err != nil {
log.Error().Err(err).Msg("Failed to write patrol findings response")
}
}
// HandleForcePatrol triggers an immediate patrol run (POST /api/ai/patrol/run)
func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require admin authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
// Check for deep=true query parameter
deep := r.URL.Query().Get("deep") == "true"
// Trigger patrol asynchronously
patrol.ForcePatrol(r.Context(), deep)
patrolType := "quick"
if deep {
patrolType = "deep"
}
response := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Triggered %s patrol run", patrolType),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write force patrol response")
}
}
// HandleAcknowledgeFinding acknowledges a finding (POST /api/ai/patrol/acknowledge)
// This marks the finding as seen but keeps it visible (dimmed). Auto-resolve removes it when condition clears.
// This matches alert acknowledgement behavior for UI consistency.
func (h *AISettingsHandler) HandleAcknowledgeFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
// Just acknowledge - don't resolve. Finding stays visible but marked as seen.
// Auto-resolve will remove it when the underlying condition clears.
if !findings.Acknowledge(req.FindingID) {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Msg("AI Patrol: Finding acknowledged by user")
response := map[string]interface{}{
"success": true,
"message": "Finding acknowledged",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write acknowledge response")
}
}
// HandleSnoozeFinding snoozes a finding for a specified duration (POST /api/ai/patrol/snooze)
// Snoozed findings are hidden from the active list but will reappear if condition persists after snooze expires
func (h *AISettingsHandler) HandleSnoozeFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
DurationHours int `json:"duration_hours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
if req.DurationHours <= 0 {
http.Error(w, "duration_hours must be positive", http.StatusBadRequest)
return
}
// Cap snooze duration at 7 days
if req.DurationHours > 168 {
req.DurationHours = 168
}
findings := patrol.GetFindings()
duration := time.Duration(req.DurationHours) * time.Hour
if !findings.Snooze(req.FindingID, duration) {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Int("hours", req.DurationHours).
Msg("AI Patrol: Finding snoozed by user")
response := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Finding snoozed for %d hours", req.DurationHours),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write snooze response")
}
}
// HandleGetFindingsHistory returns all findings including resolved for history (GET /api/ai/patrol/history)
func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.config, w, r) {
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
// Return empty history
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
log.Error().Err(err).Msg("Failed to write findings history response")
}
return
}
// Parse optional startTime query parameter
var startTime *time.Time
if startTimeStr := r.URL.Query().Get("start_time"); startTimeStr != "" {
if t, err := time.Parse(time.RFC3339, startTimeStr); err == nil {
startTime = &t
}
}
findings := patrol.GetFindingsHistory(startTime)
if err := utils.WriteJSONResponse(w, findings); err != nil {
log.Error().Err(err).Msg("Failed to write findings history response")
}
}

View File

@@ -22,6 +22,11 @@ func NewDockerMetadataHandler(dataPath string) *DockerMetadataHandler {
}
}
// Store returns the underlying metadata store
func (h *DockerMetadataHandler) Store() *config.DockerMetadataStore {
return h.store
}
// HandleGetMetadata retrieves metadata for a specific Docker resource or all resources
func (h *DockerMetadataHandler) HandleGetMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View File

@@ -27,6 +27,11 @@ func (h *GuestMetadataHandler) Reload() error {
return h.store.Load()
}
// Store returns the underlying metadata store
func (h *GuestMetadataHandler) Store() *config.GuestMetadataStore {
return h.store
}
// HandleGetMetadata retrieves metadata for a specific guest or all guests
func (h *GuestMetadataHandler) HandleGetMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View File

@@ -22,6 +22,11 @@ func NewHostMetadataHandler(dataPath string) *HostMetadataHandler {
}
}
// Store returns the underlying metadata store
func (h *HostMetadataHandler) Store() *config.HostMetadataStore {
return h.store
}
// HandleGetMetadata retrieves metadata for a specific host or all hosts
func (h *HostMetadataHandler) HandleGetMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View File

@@ -0,0 +1,87 @@
package api
import (
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// MetadataProviderImpl implements ai.MetadataProvider to allow AI to update resource URLs
type MetadataProviderImpl struct {
guestStore *config.GuestMetadataStore
dockerStore *config.DockerMetadataStore
hostStore *config.HostMetadataStore
}
// NewMetadataProvider creates a new MetadataProvider with the given stores
func NewMetadataProvider(
guestStore *config.GuestMetadataStore,
dockerStore *config.DockerMetadataStore,
hostStore *config.HostMetadataStore,
) *MetadataProviderImpl {
return &MetadataProviderImpl{
guestStore: guestStore,
dockerStore: dockerStore,
hostStore: hostStore,
}
}
// SetGuestURL sets the custom URL for a Proxmox guest (VM/container)
func (m *MetadataProviderImpl) SetGuestURL(guestID, customURL string) error {
if m.guestStore == nil {
return fmt.Errorf("guest metadata store not available")
}
// Get existing metadata or create new
existing := m.guestStore.Get(guestID)
if existing == nil {
existing = &config.GuestMetadata{
ID: guestID,
}
}
// Update the URL
existing.CustomURL = customURL
return m.guestStore.Set(guestID, existing)
}
// SetDockerURL sets the custom URL for a Docker container/service
func (m *MetadataProviderImpl) SetDockerURL(resourceID, customURL string) error {
if m.dockerStore == nil {
return fmt.Errorf("docker metadata store not available")
}
// Get existing metadata or create new
existing := m.dockerStore.Get(resourceID)
if existing == nil {
existing = &config.DockerMetadata{
ID: resourceID,
}
}
// Update the URL
existing.CustomURL = customURL
return m.dockerStore.Set(resourceID, existing)
}
// SetHostURL sets the custom URL for a host
func (m *MetadataProviderImpl) SetHostURL(hostID, customURL string) error {
if m.hostStore == nil {
return fmt.Errorf("host metadata store not available")
}
// Get existing metadata or create new
existing := m.hostStore.Get(hostID)
if existing == nil {
existing = &config.HostMetadata{
ID: hostID,
}
}
// Update the URL
existing.CustomURL = customURL
return m.hostStore.Set(hostID, existing)
}

View File

@@ -1065,6 +1065,14 @@ func (r *Router) setupRoutes() {
if r.resourceHandlers != nil {
r.aiSettingsHandler.SetResourceProvider(r.resourceHandlers.Store())
}
// Inject metadata provider for AI URL discovery feature
// This allows AI to set resource URLs when it discovers web services
metadataProvider := NewMetadataProvider(
guestMetadataHandler.Store(),
dockerMetadataHandler.Store(),
hostMetadataHandler.Store(),
)
r.aiSettingsHandler.SetMetadataProvider(metadataProvider)
r.mux.HandleFunc("/api/settings/ai", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleGetAISettings)))
r.mux.HandleFunc("/api/settings/ai/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings)))
r.mux.HandleFunc("/api/ai/test", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection)))
@@ -1086,6 +1094,14 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/oauth/callback", r.aiSettingsHandler.HandleOAuthCallback) // Public - receives redirect from Anthropic
r.mux.HandleFunc("/api/ai/oauth/disconnect", RequireAdmin(r.config, r.aiSettingsHandler.HandleOAuthDisconnect))
// AI Patrol routes for background monitoring
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolStatus))
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolFindings))
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
// Agent WebSocket for AI command execution
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
@@ -1345,6 +1361,36 @@ func (r *Router) SetConfig(cfg *config.Config) {
}
}
// StartPatrol starts the AI patrol service for background infrastructure monitoring
func (r *Router) StartPatrol(ctx context.Context) {
if r.aiSettingsHandler != nil {
// Connect patrol to user-configured alert thresholds so it warns before alerts fire
if r.monitor != nil {
if alertManager := r.monitor.GetAlertManager(); alertManager != nil {
thresholdAdapter := ai.NewAlertThresholdAdapter(alertManager)
r.aiSettingsHandler.SetPatrolThresholdProvider(thresholdAdapter)
}
}
// Enable findings persistence (load from disk, auto-save on changes)
if r.persistence != nil {
findingsPersistence := ai.NewFindingsPersistenceAdapter(r.persistence)
if err := r.aiSettingsHandler.SetPatrolFindingsPersistence(findingsPersistence); err != nil {
log.Error().Err(err).Msg("Failed to initialize AI findings persistence")
}
}
r.aiSettingsHandler.StartPatrol(ctx)
}
}
// StopPatrol stops the AI patrol service
func (r *Router) StopPatrol() {
if r.aiSettingsHandler != nil {
r.aiSettingsHandler.StopPatrol()
}
}
// reloadSystemSettings loads system settings from disk and caches them
func (r *Router) reloadSystemSettings() {
r.settingsMu.Lock()