From 387ae309cc4442af8b93a0cb907db8a9f2049a93 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Dec 2025 00:29:07 +0000 Subject: [PATCH] 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 --- internal/ai/metadata_provider.go | 109 ++++++++++ internal/ai/service.go | 271 ++++++++++++++++--------- internal/api/ai_handlers.go | 318 ++++++++++++++++++++++++++++++ internal/api/docker_metadata.go | 5 + internal/api/guest_metadata.go | 5 + internal/api/host_metadata.go | 5 + internal/api/metadata_provider.go | 87 ++++++++ internal/api/router.go | 46 +++++ 8 files changed, 757 insertions(+), 89 deletions(-) create mode 100644 internal/ai/metadata_provider.go create mode 100644 internal/api/metadata_provider.go diff --git a/internal/ai/metadata_provider.go b/internal/ai/metadata_provider.go new file mode 100644 index 000000000..1aeb35598 --- /dev/null +++ b/internal/ai/metadata_provider.go @@ -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 +} diff --git a/internal/ai/service.go b/internal/ai/service.go index 583fa1d38..5e85feaa8 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -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) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index d5854552a..0acca416a 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -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") + } +} diff --git a/internal/api/docker_metadata.go b/internal/api/docker_metadata.go index 7ebe5907f..5e75bd96d 100644 --- a/internal/api/docker_metadata.go +++ b/internal/api/docker_metadata.go @@ -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 { diff --git a/internal/api/guest_metadata.go b/internal/api/guest_metadata.go index 3a567225c..848d19f88 100644 --- a/internal/api/guest_metadata.go +++ b/internal/api/guest_metadata.go @@ -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 { diff --git a/internal/api/host_metadata.go b/internal/api/host_metadata.go index b84c8ea41..0d3d517da 100644 --- a/internal/api/host_metadata.go +++ b/internal/api/host_metadata.go @@ -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 { diff --git a/internal/api/metadata_provider.go b/internal/api/metadata_provider.go new file mode 100644 index 000000000..77d3f8d08 --- /dev/null +++ b/internal/api/metadata_provider.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index 4fdfc5640..2eb4ae65c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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()