mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
109
internal/ai/metadata_provider.go
Normal file
109
internal/ai/metadata_provider.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
internal/api/metadata_provider.go
Normal file
87
internal/api/metadata_provider.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user