Fixes #858 The patrol interval setting was not being properly applied due to: 1. ReconfigurePatrol() was setting the deprecated QuickCheckInterval field instead of the preferred Interval field 2. SetConfig() was comparing raw field values instead of using GetInterval() to compare effective intervals, causing change detection to fail 3. The API response was missing interval_ms, preventing the frontend from displaying the correct interval Changes: - Update StartPatrol() and ReconfigurePatrol() to use the Interval field - Fix SetConfig() to use GetInterval() for interval comparison - Add IntervalMs to PatrolStatusResponse and include it in the API response
2861 lines
90 KiB
Go
2861 lines
90 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// AISettingsHandler handles AI settings endpoints
|
|
type AISettingsHandler struct {
|
|
config *config.Config
|
|
persistence *config.ConfigPersistence
|
|
aiService *ai.Service
|
|
agentServer *agentexec.Server
|
|
}
|
|
|
|
// NewAISettingsHandler creates a new AI settings handler
|
|
func NewAISettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, agentServer *agentexec.Server) *AISettingsHandler {
|
|
aiService := ai.NewService(persistence, agentServer)
|
|
if err := aiService.LoadConfig(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to load AI config on startup")
|
|
}
|
|
|
|
return &AISettingsHandler{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
aiService: aiService,
|
|
agentServer: agentServer,
|
|
}
|
|
}
|
|
|
|
// SetConfig updates the configuration reference used by the handler.
|
|
func (h *AISettingsHandler) SetConfig(cfg *config.Config) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
h.config = cfg
|
|
}
|
|
|
|
// SetStateProvider sets the state provider for infrastructure context
|
|
func (h *AISettingsHandler) SetStateProvider(sp ai.StateProvider) {
|
|
h.aiService.SetStateProvider(sp)
|
|
}
|
|
|
|
// SetResourceProvider sets the resource provider for unified infrastructure context (Phase 2)
|
|
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
|
|
}
|
|
|
|
// SetPatrolRunHistoryPersistence enables patrol run history persistence for the patrol service
|
|
func (h *AISettingsHandler) SetPatrolRunHistoryPersistence(persistence ai.PatrolHistoryPersistence) error {
|
|
if patrol := h.aiService.GetPatrolService(); patrol != nil {
|
|
return patrol.SetRunHistoryPersistence(persistence)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetMetricsHistoryProvider sets the metrics history provider for enriched AI context
|
|
func (h *AISettingsHandler) SetMetricsHistoryProvider(provider ai.MetricsHistoryProvider) {
|
|
h.aiService.SetMetricsHistoryProvider(provider)
|
|
}
|
|
|
|
// SetBaselineStore sets the baseline store for anomaly detection
|
|
func (h *AISettingsHandler) SetBaselineStore(store *ai.BaselineStore) {
|
|
h.aiService.SetBaselineStore(store)
|
|
}
|
|
|
|
// SetChangeDetector sets the change detector for operational memory
|
|
func (h *AISettingsHandler) SetChangeDetector(detector *ai.ChangeDetector) {
|
|
h.aiService.SetChangeDetector(detector)
|
|
}
|
|
|
|
// SetRemediationLog sets the remediation log for tracking fix attempts
|
|
func (h *AISettingsHandler) SetRemediationLog(remLog *ai.RemediationLog) {
|
|
h.aiService.SetRemediationLog(remLog)
|
|
}
|
|
|
|
// SetPatternDetector sets the pattern detector for failure prediction
|
|
func (h *AISettingsHandler) SetPatternDetector(detector *ai.PatternDetector) {
|
|
h.aiService.SetPatternDetector(detector)
|
|
}
|
|
|
|
// SetCorrelationDetector sets the correlation detector for multi-resource correlation
|
|
func (h *AISettingsHandler) SetCorrelationDetector(detector *ai.CorrelationDetector) {
|
|
h.aiService.SetCorrelationDetector(detector)
|
|
}
|
|
|
|
// StopPatrol stops the background AI patrol service
|
|
func (h *AISettingsHandler) StopPatrol() {
|
|
h.aiService.StopPatrol()
|
|
}
|
|
|
|
// GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for wiring into alert callbacks
|
|
func (h *AISettingsHandler) GetAlertTriggeredAnalyzer() *ai.AlertTriggeredAnalyzer {
|
|
return h.aiService.GetAlertTriggeredAnalyzer()
|
|
}
|
|
|
|
// AISettingsResponse is returned by GET /api/settings/ai
|
|
// API keys are masked for security
|
|
type AISettingsResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
Provider string `json:"provider"` // DEPRECATED: legacy single provider
|
|
APIKeySet bool `json:"api_key_set"` // DEPRECATED: true if legacy API key is configured
|
|
Model string `json:"model"`
|
|
ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (empty = use default)
|
|
PatrolModel string `json:"patrol_model,omitempty"` // Model for patrol (empty = use default)
|
|
AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for auto-fix (empty = use patrol model)
|
|
BaseURL string `json:"base_url,omitempty"` // DEPRECATED: legacy base URL
|
|
Configured bool `json:"configured"` // true if AI is ready to use
|
|
AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval
|
|
CustomContext string `json:"custom_context"` // user-provided infrastructure context
|
|
// OAuth fields for Claude Pro/Max subscription authentication
|
|
AuthMethod string `json:"auth_method"` // "api_key" or "oauth"
|
|
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
|
|
// Patrol settings for token efficiency
|
|
PatrolSchedulePreset string `json:"patrol_schedule_preset"` // DEPRECATED: legacy preset
|
|
PatrolIntervalMinutes int `json:"patrol_interval_minutes"` // Patrol interval in minutes (0 = disabled)
|
|
PatrolAutoFix bool `json:"patrol_auto_fix"` // true if patrol can auto-fix issues
|
|
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // true if AI analyzes when alerts fire
|
|
AvailableModels []config.ModelInfo `json:"available_models"` // List of models for current provider
|
|
// Multi-provider credentials - shows which providers are configured
|
|
AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key or OAuth is set
|
|
OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set
|
|
DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set
|
|
GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set
|
|
OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt)
|
|
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
|
|
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
|
|
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
|
|
// Cost controls
|
|
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
|
|
}
|
|
|
|
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
|
|
type AISettingsUpdateRequest struct {
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Provider *string `json:"provider,omitempty"` // DEPRECATED: use model selection instead
|
|
APIKey *string `json:"api_key,omitempty"` // DEPRECATED: use per-provider keys
|
|
Model *string `json:"model,omitempty"`
|
|
ChatModel *string `json:"chat_model,omitempty"` // Model for interactive chat
|
|
PatrolModel *string `json:"patrol_model,omitempty"` // Model for background patrol
|
|
AutoFixModel *string `json:"auto_fix_model,omitempty"` // Model for auto-fix remediation
|
|
BaseURL *string `json:"base_url,omitempty"` // DEPRECATED: use per-provider URLs
|
|
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
|
|
CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context
|
|
AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth"
|
|
// Patrol settings for token efficiency
|
|
PatrolSchedulePreset *string `json:"patrol_schedule_preset,omitempty"` // DEPRECATED: use patrol_interval_minutes
|
|
PatrolIntervalMinutes *int `json:"patrol_interval_minutes,omitempty"` // Custom interval in minutes (0 = disabled, minimum 10)
|
|
PatrolAutoFix *bool `json:"patrol_auto_fix,omitempty"` // true if patrol can auto-fix issues
|
|
AlertTriggeredAnalysis *bool `json:"alert_triggered_analysis,omitempty"` // true if AI analyzes when alerts fire
|
|
// Multi-provider credentials
|
|
AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` // Set Anthropic API key
|
|
OpenAIAPIKey *string `json:"openai_api_key,omitempty"` // Set OpenAI API key
|
|
DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key
|
|
GeminiAPIKey *string `json:"gemini_api_key,omitempty"` // Set Gemini API key
|
|
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
|
|
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
|
|
// Clear flags for removing credentials
|
|
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
|
|
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
|
|
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
|
|
ClearGeminiKey *bool `json:"clear_gemini_key,omitempty"` // Clear Gemini API key
|
|
ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL
|
|
// Cost controls
|
|
CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"`
|
|
}
|
|
|
|
// HandleGetAISettings returns the current AI settings (GET /api/settings/ai)
|
|
func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
settings, err := h.persistence.LoadAIConfig()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to load AI settings")
|
|
http.Error(w, "Failed to load AI settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if settings == nil {
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
|
|
// Determine auth method string
|
|
authMethod := string(settings.AuthMethod)
|
|
if authMethod == "" {
|
|
authMethod = string(config.AuthMethodAPIKey)
|
|
}
|
|
|
|
response := AISettingsResponse{
|
|
Enabled: settings.Enabled,
|
|
Provider: settings.Provider,
|
|
APIKeySet: settings.APIKey != "",
|
|
Model: settings.GetModel(),
|
|
ChatModel: settings.ChatModel,
|
|
PatrolModel: settings.PatrolModel,
|
|
AutoFixModel: settings.AutoFixModel,
|
|
BaseURL: settings.BaseURL,
|
|
Configured: settings.IsConfigured(),
|
|
AutonomousMode: settings.AutonomousMode,
|
|
CustomContext: settings.CustomContext,
|
|
AuthMethod: authMethod,
|
|
OAuthConnected: settings.OAuthAccessToken != "",
|
|
// Patrol settings
|
|
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
|
PatrolIntervalMinutes: settings.PatrolIntervalMinutes,
|
|
PatrolAutoFix: settings.PatrolAutoFix,
|
|
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
|
|
AvailableModels: nil, // Now populated via /api/ai/models endpoint
|
|
// Multi-provider configuration
|
|
AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic),
|
|
OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI),
|
|
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
|
|
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
|
|
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
|
|
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
|
|
OpenAIBaseURL: settings.OpenAIBaseURL,
|
|
ConfiguredProviders: settings.GetConfiguredProviders(),
|
|
CostBudgetUSD30d: settings.CostBudgetUSD30d,
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI settings response")
|
|
}
|
|
}
|
|
|
|
// HandleUpdateAISettings updates AI settings (PUT /api/settings/ai)
|
|
func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut && r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Require admin authentication
|
|
if !CheckAuth(h.config, w, r) {
|
|
return
|
|
}
|
|
|
|
// Check proxy auth admin status if applicable
|
|
if h.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(h.config, r); valid && !isAdmin {
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Str("path", r.URL.Path).
|
|
Str("method", r.Method).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted to update AI settings")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Load existing settings
|
|
settings, err := h.persistence.LoadAIConfig()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to load existing AI settings")
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
if settings == nil {
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
|
|
// Parse request
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
|
var req AISettingsUpdateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate and apply updates
|
|
if req.Provider != nil {
|
|
provider := strings.ToLower(strings.TrimSpace(*req.Provider))
|
|
switch provider {
|
|
case config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderOllama, config.AIProviderDeepSeek, config.AIProviderGemini:
|
|
settings.Provider = provider
|
|
default:
|
|
http.Error(w, "Invalid provider. Must be 'anthropic', 'openai', 'ollama', 'deepseek', or 'gemini'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.APIKey != nil {
|
|
// Empty string clears the API key
|
|
settings.APIKey = strings.TrimSpace(*req.APIKey)
|
|
}
|
|
|
|
if req.Model != nil {
|
|
settings.Model = strings.TrimSpace(*req.Model)
|
|
}
|
|
|
|
if req.ChatModel != nil {
|
|
settings.ChatModel = strings.TrimSpace(*req.ChatModel)
|
|
}
|
|
|
|
if req.PatrolModel != nil {
|
|
settings.PatrolModel = strings.TrimSpace(*req.PatrolModel)
|
|
}
|
|
|
|
if req.AutoFixModel != nil {
|
|
settings.AutoFixModel = strings.TrimSpace(*req.AutoFixModel)
|
|
}
|
|
|
|
if req.PatrolAutoFix != nil {
|
|
settings.PatrolAutoFix = *req.PatrolAutoFix
|
|
}
|
|
|
|
if req.BaseURL != nil {
|
|
settings.BaseURL = strings.TrimSpace(*req.BaseURL)
|
|
}
|
|
|
|
if req.AutonomousMode != nil {
|
|
settings.AutonomousMode = *req.AutonomousMode
|
|
}
|
|
|
|
if req.CustomContext != nil {
|
|
settings.CustomContext = strings.TrimSpace(*req.CustomContext)
|
|
}
|
|
|
|
// Handle multi-provider credentials FIRST - before enabled check
|
|
// This allows the setup flow to send API key + enabled:true together
|
|
// Clear flags take priority over setting new values
|
|
if req.ClearAnthropicKey != nil && *req.ClearAnthropicKey {
|
|
settings.AnthropicAPIKey = ""
|
|
} else if req.AnthropicAPIKey != nil {
|
|
settings.AnthropicAPIKey = strings.TrimSpace(*req.AnthropicAPIKey)
|
|
}
|
|
if req.ClearOpenAIKey != nil && *req.ClearOpenAIKey {
|
|
settings.OpenAIAPIKey = ""
|
|
} else if req.OpenAIAPIKey != nil {
|
|
settings.OpenAIAPIKey = strings.TrimSpace(*req.OpenAIAPIKey)
|
|
}
|
|
if req.ClearDeepSeekKey != nil && *req.ClearDeepSeekKey {
|
|
settings.DeepSeekAPIKey = ""
|
|
} else if req.DeepSeekAPIKey != nil {
|
|
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
|
|
}
|
|
if req.ClearGeminiKey != nil && *req.ClearGeminiKey {
|
|
settings.GeminiAPIKey = ""
|
|
} else if req.GeminiAPIKey != nil {
|
|
settings.GeminiAPIKey = strings.TrimSpace(*req.GeminiAPIKey)
|
|
}
|
|
if req.ClearOllamaURL != nil && *req.ClearOllamaURL {
|
|
settings.OllamaBaseURL = ""
|
|
} else if req.OllamaBaseURL != nil {
|
|
settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL)
|
|
}
|
|
if req.OpenAIBaseURL != nil {
|
|
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
// Only allow enabling if at least one provider is configured
|
|
if *req.Enabled {
|
|
configuredProviders := settings.GetConfiguredProviders()
|
|
if len(configuredProviders) == 0 {
|
|
// No providers configured - give a helpful error
|
|
http.Error(w, "Please configure an AI provider (API key or Ollama URL) before enabling AI", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// If we have configured providers, we're good to enable
|
|
}
|
|
settings.Enabled = *req.Enabled
|
|
}
|
|
|
|
// Handle patrol interval - prefer custom minutes over preset
|
|
if req.PatrolIntervalMinutes != nil {
|
|
minutes := *req.PatrolIntervalMinutes
|
|
if minutes < 0 {
|
|
http.Error(w, "patrol_interval_minutes cannot be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if minutes > 0 && minutes < 10 {
|
|
http.Error(w, "patrol_interval_minutes must be at least 10 minutes (or 0 to disable)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if minutes > 10080 { // 7 days max
|
|
http.Error(w, "patrol_interval_minutes cannot exceed 10080 (7 days)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
settings.PatrolIntervalMinutes = minutes
|
|
settings.PatrolSchedulePreset = "" // Clear preset when using custom minutes
|
|
} else if req.PatrolSchedulePreset != nil {
|
|
// Legacy preset support
|
|
preset := strings.ToLower(strings.TrimSpace(*req.PatrolSchedulePreset))
|
|
switch preset {
|
|
case "15min", "1hr", "6hr", "12hr", "daily", "disabled":
|
|
settings.PatrolSchedulePreset = preset
|
|
settings.PatrolIntervalMinutes = config.PresetToMinutes(preset)
|
|
default:
|
|
http.Error(w, "Invalid patrol_schedule_preset. Must be '15min', '1hr', '6hr', '12hr', 'daily', or 'disabled'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.CostBudgetUSD30d != nil {
|
|
if *req.CostBudgetUSD30d < 0 {
|
|
http.Error(w, "cost_budget_usd_30d cannot be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
settings.CostBudgetUSD30d = *req.CostBudgetUSD30d
|
|
}
|
|
|
|
// Handle alert-triggered analysis toggle
|
|
if req.AlertTriggeredAnalysis != nil {
|
|
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
|
|
}
|
|
|
|
// Save settings
|
|
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save AI settings")
|
|
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload the AI service with new settings
|
|
if err := h.aiService.Reload(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload AI service after settings update")
|
|
}
|
|
|
|
// Reconfigure patrol service with new settings (applies interval changes immediately)
|
|
h.aiService.ReconfigurePatrol()
|
|
|
|
// Update alert-triggered analyzer if available
|
|
if analyzer := h.aiService.GetAlertTriggeredAnalyzer(); analyzer != nil {
|
|
analyzer.SetEnabled(settings.AlertTriggeredAnalysis)
|
|
}
|
|
|
|
log.Info().
|
|
Bool("enabled", settings.Enabled).
|
|
Str("provider", settings.Provider).
|
|
Str("model", settings.GetModel()).
|
|
Str("patrolPreset", settings.PatrolSchedulePreset).
|
|
Bool("alertTriggeredAnalysis", settings.AlertTriggeredAnalysis).
|
|
Msg("AI settings updated")
|
|
|
|
// Determine auth method for response
|
|
authMethod := string(settings.AuthMethod)
|
|
if authMethod == "" {
|
|
authMethod = string(config.AuthMethodAPIKey)
|
|
}
|
|
|
|
// Return updated settings
|
|
response := AISettingsResponse{
|
|
Enabled: settings.Enabled,
|
|
Provider: settings.Provider,
|
|
APIKeySet: settings.APIKey != "",
|
|
Model: settings.GetModel(),
|
|
ChatModel: settings.ChatModel,
|
|
PatrolModel: settings.PatrolModel,
|
|
AutoFixModel: settings.AutoFixModel,
|
|
BaseURL: settings.BaseURL,
|
|
Configured: settings.IsConfigured(),
|
|
AutonomousMode: settings.AutonomousMode,
|
|
CustomContext: settings.CustomContext,
|
|
AuthMethod: authMethod,
|
|
OAuthConnected: settings.OAuthAccessToken != "",
|
|
PatrolSchedulePreset: settings.PatrolSchedulePreset,
|
|
PatrolIntervalMinutes: settings.PatrolIntervalMinutes,
|
|
PatrolAutoFix: settings.PatrolAutoFix,
|
|
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
|
|
AvailableModels: nil, // Now populated via /api/ai/models endpoint
|
|
// Multi-provider configuration
|
|
AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic),
|
|
OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI),
|
|
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
|
|
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
|
|
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
|
|
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
|
|
OpenAIBaseURL: settings.OpenAIBaseURL,
|
|
ConfiguredProviders: settings.GetConfiguredProviders(),
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI settings update response")
|
|
}
|
|
}
|
|
|
|
// HandleTestAIConnection tests the AI provider connection (POST /api/ai/test)
|
|
func (h *AISettingsHandler) HandleTestAIConnection(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
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
var testResult struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Model string `json:"model,omitempty"`
|
|
}
|
|
|
|
err := h.aiService.TestConnection(ctx)
|
|
if err != nil {
|
|
testResult.Success = false
|
|
testResult.Message = err.Error()
|
|
} else {
|
|
cfg := h.aiService.GetConfig()
|
|
testResult.Success = true
|
|
testResult.Message = "Connection successful"
|
|
if cfg != nil {
|
|
testResult.Model = cfg.GetModel()
|
|
}
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, testResult); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI test response")
|
|
}
|
|
}
|
|
|
|
// HandleTestProvider tests a specific AI provider connection (POST /api/ai/test/:provider)
|
|
func (h *AISettingsHandler) HandleTestProvider(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
|
|
}
|
|
|
|
// Get provider from URL path (e.g., /api/ai/test/anthropic -> anthropic)
|
|
provider := strings.TrimPrefix(r.URL.Path, "/api/ai/test/")
|
|
if provider == "" || provider == r.URL.Path {
|
|
http.Error(w, `{"error":"Provider is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
var testResult struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
testResult.Provider = provider
|
|
|
|
// Load config and create provider for testing
|
|
cfg := h.aiService.GetConfig()
|
|
if cfg == nil {
|
|
testResult.Success = false
|
|
testResult.Message = "AI not configured"
|
|
utils.WriteJSONResponse(w, testResult)
|
|
return
|
|
}
|
|
|
|
// Check if provider is configured
|
|
if !cfg.HasProvider(provider) {
|
|
testResult.Success = false
|
|
testResult.Message = "Provider not configured"
|
|
utils.WriteJSONResponse(w, testResult)
|
|
return
|
|
}
|
|
|
|
// Create provider and test connection
|
|
testProvider, err := providers.NewForProvider(cfg, provider, cfg.GetModel())
|
|
if err != nil {
|
|
testResult.Success = false
|
|
testResult.Message = fmt.Sprintf("Failed to create provider: %v", err)
|
|
utils.WriteJSONResponse(w, testResult)
|
|
return
|
|
}
|
|
|
|
err = testProvider.TestConnection(ctx)
|
|
if err != nil {
|
|
testResult.Success = false
|
|
testResult.Message = err.Error()
|
|
} else {
|
|
testResult.Success = true
|
|
testResult.Message = "Connection successful"
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, testResult); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write provider test response")
|
|
}
|
|
}
|
|
|
|
// HandleListModels fetches available models from the configured AI provider (GET /api/ai/models)
|
|
func (h *AISettingsHandler) HandleListModels(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
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
type ModelInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type Response struct {
|
|
Models []ModelInfo `json:"models"`
|
|
Error string `json:"error,omitempty"`
|
|
Cached bool `json:"cached"`
|
|
}
|
|
|
|
models, cached, err := h.aiService.ListModelsWithCache(ctx)
|
|
if err != nil {
|
|
// Return error but don't fail the request - frontend can show a fallback
|
|
resp := Response{
|
|
Models: []ModelInfo{},
|
|
Error: err.Error(),
|
|
}
|
|
if jsonErr := utils.WriteJSONResponse(w, resp); jsonErr != nil {
|
|
log.Error().Err(jsonErr).Msg("Failed to write AI models response")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Convert provider models to response format
|
|
responseModels := make([]ModelInfo, 0, len(models))
|
|
for _, m := range models {
|
|
responseModels = append(responseModels, ModelInfo{
|
|
ID: m.ID,
|
|
Name: m.Name,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
|
|
resp := Response{
|
|
Models: responseModels,
|
|
Cached: cached,
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI models response")
|
|
}
|
|
}
|
|
|
|
// AIExecuteRequest is the request body for POST /api/ai/execute
|
|
// AIConversationMessage represents a message in conversation history
|
|
type AIConversationMessage struct {
|
|
Role string `json:"role"` // "user" or "assistant"
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type AIExecuteRequest struct {
|
|
Prompt string `json:"prompt"`
|
|
TargetType string `json:"target_type,omitempty"` // "host", "container", "vm", "node"
|
|
TargetID string `json:"target_id,omitempty"`
|
|
Context map[string]interface{} `json:"context,omitempty"` // Current metrics, state, etc.
|
|
History []AIConversationMessage `json:"history,omitempty"` // Previous conversation messages
|
|
FindingID string `json:"finding_id,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
UseCase string `json:"use_case,omitempty"` // "chat" or "patrol"
|
|
}
|
|
|
|
// AIExecuteResponse is the response from POST /api/ai/execute
|
|
type AIExecuteResponse struct {
|
|
Content string `json:"content"`
|
|
Model string `json:"model"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
ToolCalls []ai.ToolExecution `json:"tool_calls,omitempty"` // Commands that were executed
|
|
PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals,omitempty"` // Commands that require approval (non-streaming)
|
|
}
|
|
|
|
// HandleExecute executes an AI prompt (POST /api/ai/execute)
|
|
func (h *AISettingsHandler) HandleExecute(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
|
|
}
|
|
|
|
// Check if AI is enabled
|
|
if !h.aiService.IsEnabled() {
|
|
http.Error(w, "AI is not enabled or configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
|
|
var req AIExecuteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
http.Error(w, "Prompt is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Execute the prompt with a timeout
|
|
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
// Convert history from API type to service type
|
|
var history []ai.ConversationMessage
|
|
for _, msg := range req.History {
|
|
history = append(history, ai.ConversationMessage{
|
|
Role: msg.Role,
|
|
Content: msg.Content,
|
|
})
|
|
}
|
|
|
|
useCase := strings.TrimSpace(req.UseCase)
|
|
if useCase == "" {
|
|
useCase = "chat"
|
|
}
|
|
|
|
resp, err := h.aiService.Execute(ctx, ai.ExecuteRequest{
|
|
Prompt: req.Prompt,
|
|
TargetType: req.TargetType,
|
|
TargetID: req.TargetID,
|
|
Context: req.Context,
|
|
History: history,
|
|
FindingID: req.FindingID,
|
|
Model: req.Model,
|
|
UseCase: useCase,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("AI execution failed")
|
|
http.Error(w, "AI request failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := AIExecuteResponse{
|
|
Content: resp.Content,
|
|
Model: resp.Model,
|
|
InputTokens: resp.InputTokens,
|
|
OutputTokens: resp.OutputTokens,
|
|
ToolCalls: resp.ToolCalls,
|
|
PendingApprovals: resp.PendingApprovals,
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI execute response")
|
|
}
|
|
}
|
|
|
|
// HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream)
|
|
func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) {
|
|
// Handle CORS for dev mode (frontend on different port)
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
// Must use specific origin (not *) when credentials are included
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Cookie")
|
|
w.Header().Set("Vary", "Origin")
|
|
}
|
|
|
|
// Handle preflight
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Require authentication
|
|
if !CheckAuth(h.config, w, r) {
|
|
return
|
|
}
|
|
|
|
// Check if AI is enabled
|
|
if !h.aiService.IsEnabled() {
|
|
http.Error(w, "AI is not enabled or configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
|
|
var req AIExecuteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to decode AI execute stream request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
http.Error(w, "Prompt is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Int("prompt_len", len(req.Prompt)).
|
|
Str("target_type", req.TargetType).
|
|
Str("target_id", req.TargetID).
|
|
Msg("AI streaming request started")
|
|
|
|
// Set up SSE headers
|
|
// IMPORTANT: Set headers BEFORE any writes to prevent Go from auto-adding Transfer-Encoding: chunked
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
|
|
// Prevent chunked encoding which causes "Invalid character in chunk size" errors in Vite proxy
|
|
w.Header().Set("Transfer-Encoding", "identity")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Disable the server's write deadline for this SSE connection
|
|
// This is critical for long-running AI requests that can take several minutes
|
|
rc := http.NewResponseController(w)
|
|
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to disable write deadline for SSE")
|
|
}
|
|
// Also disable read deadline
|
|
if err := rc.SetReadDeadline(time.Time{}); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to disable read deadline for SSE")
|
|
}
|
|
|
|
// Flush headers immediately
|
|
flusher.Flush()
|
|
|
|
// Create context with timeout (15 minutes for complex analysis with multiple tool calls)
|
|
// Use background context to avoid browser disconnect canceling the request
|
|
// DeepSeek reasoning models + multiple tool executions can easily take 5+ minutes
|
|
ctx, cancel := context.WithTimeout(context.Background(), 900*time.Second)
|
|
defer cancel()
|
|
|
|
// Set up heartbeat to keep connection alive during long tool executions
|
|
// NOTE: We don't check r.Context().Done() because Vite proxy may close
|
|
// the request context prematurely. We detect real disconnection via write failures.
|
|
heartbeatDone := make(chan struct{})
|
|
var clientDisconnected atomic.Bool
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Extend write deadline before heartbeat
|
|
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
// Send SSE comment as heartbeat
|
|
_, err := w.Write([]byte(": heartbeat\n\n"))
|
|
if err != nil {
|
|
log.Debug().Err(err).Msg("Heartbeat write failed, stopping heartbeat (AI continues)")
|
|
clientDisconnected.Store(true)
|
|
// Don't cancel the AI request - let it complete with its own timeout
|
|
// The SSE connection may have issues but the AI work can still finish
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
log.Debug().Msg("Sent SSE heartbeat")
|
|
case <-heartbeatDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer close(heartbeatDone)
|
|
|
|
// Helper to safely write SSE events, tracking if client disconnected
|
|
safeWrite := func(data []byte) bool {
|
|
if clientDisconnected.Load() {
|
|
return false
|
|
}
|
|
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
_, err := w.Write(data)
|
|
if err != nil {
|
|
log.Debug().Err(err).Msg("Failed to write SSE event (client may have disconnected)")
|
|
clientDisconnected.Store(true)
|
|
return false
|
|
}
|
|
flusher.Flush()
|
|
return true
|
|
}
|
|
|
|
// Stream callback - write SSE events
|
|
callback := func(event ai.StreamEvent) {
|
|
// Skip the 'done' event from service - we'll send our own at the end
|
|
// This ensures 'complete' comes before 'done'
|
|
if event.Type == "done" {
|
|
log.Debug().Msg("Skipping service 'done' event - will send final 'done' after 'complete'")
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to marshal stream event")
|
|
return
|
|
}
|
|
|
|
log.Debug().
|
|
Str("event_type", event.Type).
|
|
Msg("Streaming AI event")
|
|
|
|
// SSE format: data: <json>\n\n
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
}
|
|
|
|
// Convert history from API type to service type
|
|
var history []ai.ConversationMessage
|
|
for _, msg := range req.History {
|
|
history = append(history, ai.ConversationMessage{
|
|
Role: msg.Role,
|
|
Content: msg.Content,
|
|
})
|
|
}
|
|
|
|
useCase := strings.TrimSpace(req.UseCase)
|
|
if useCase == "" {
|
|
useCase = "chat"
|
|
}
|
|
|
|
// Ensure we always send a final 'done' event
|
|
defer func() {
|
|
if !clientDisconnected.Load() {
|
|
doneEvent := ai.StreamEvent{Type: "done"}
|
|
data, _ := json.Marshal(doneEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
log.Debug().Msg("Sent final 'done' event")
|
|
}
|
|
}()
|
|
|
|
// Execute with streaming
|
|
resp, err := h.aiService.ExecuteStream(ctx, ai.ExecuteRequest{
|
|
Prompt: req.Prompt,
|
|
TargetType: req.TargetType,
|
|
TargetID: req.TargetID,
|
|
Context: req.Context,
|
|
History: history,
|
|
FindingID: req.FindingID,
|
|
Model: req.Model,
|
|
UseCase: useCase,
|
|
}, callback)
|
|
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("AI streaming execution failed")
|
|
// Send error event
|
|
errEvent := ai.StreamEvent{Type: "error", Data: err.Error()}
|
|
data, _ := json.Marshal(errEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("model", resp.Model).
|
|
Int("input_tokens", resp.InputTokens).
|
|
Int("output_tokens", resp.OutputTokens).
|
|
Int("tool_calls", len(resp.ToolCalls)).
|
|
Msg("AI streaming request completed")
|
|
|
|
// Send final response with metadata (before 'done')
|
|
finalEvent := struct {
|
|
Type string `json:"type"`
|
|
Model string `json:"model"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
ToolCalls []ai.ToolExecution `json:"tool_calls,omitempty"`
|
|
}{
|
|
Type: "complete",
|
|
Model: resp.Model,
|
|
InputTokens: resp.InputTokens,
|
|
OutputTokens: resp.OutputTokens,
|
|
ToolCalls: resp.ToolCalls,
|
|
}
|
|
data, _ := json.Marshal(finalEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
// 'done' event is sent by the defer above
|
|
}
|
|
|
|
// AIRunCommandRequest is the request body for POST /api/ai/run-command
|
|
type AIRunCommandRequest struct {
|
|
Command string `json:"command"`
|
|
TargetType string `json:"target_type"`
|
|
TargetID string `json:"target_id"`
|
|
RunOnHost bool `json:"run_on_host"`
|
|
VMID string `json:"vmid,omitempty"`
|
|
TargetHost string `json:"target_host,omitempty"` // Explicit host for routing
|
|
}
|
|
|
|
// HandleRunCommand executes a single approved command (POST /api/ai/run-command)
|
|
func (h *AISettingsHandler) HandleRunCommand(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
|
|
}
|
|
|
|
// Parse request
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
|
bodyBytes, readErr := io.ReadAll(r.Body)
|
|
if readErr != nil {
|
|
log.Error().Err(readErr).Msg("Failed to read request body")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
log.Debug().Int("body_len", len(bodyBytes)).Msg("run-command request received")
|
|
|
|
var req AIRunCommandRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
log.Error().Err(err).Str("body", string(bodyBytes)).Msg("Failed to decode JSON body")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.Command) == "" {
|
|
http.Error(w, "Command is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("command", req.Command).
|
|
Str("target_type", req.TargetType).
|
|
Str("target_id", req.TargetID).
|
|
Bool("run_on_host", req.RunOnHost).
|
|
Str("target_host", req.TargetHost).
|
|
Msg("Executing approved command")
|
|
|
|
// Execute with timeout (5 minutes for long-running commands)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := h.aiService.RunCommand(ctx, ai.RunCommandRequest{
|
|
Command: req.Command,
|
|
TargetType: req.TargetType,
|
|
TargetID: req.TargetID,
|
|
RunOnHost: req.RunOnHost,
|
|
VMID: req.VMID,
|
|
TargetHost: req.TargetHost,
|
|
})
|
|
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to execute command")
|
|
http.Error(w, "Failed to execute command: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write run command response")
|
|
}
|
|
}
|
|
|
|
// HandleGetGuestKnowledge returns all notes for a guest
|
|
func (h *AISettingsHandler) HandleGetGuestKnowledge(w http.ResponseWriter, r *http.Request) {
|
|
guestID := r.URL.Query().Get("guest_id")
|
|
if guestID == "" {
|
|
http.Error(w, "guest_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
knowledge, err := h.aiService.GetGuestKnowledge(guestID)
|
|
if err != nil {
|
|
http.Error(w, "Failed to get knowledge: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, knowledge); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write knowledge response")
|
|
}
|
|
}
|
|
|
|
// HandleSaveGuestNote saves a note for a guest
|
|
func (h *AISettingsHandler) HandleSaveGuestNote(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
GuestID string `json:"guest_id"`
|
|
GuestName string `json:"guest_name"`
|
|
GuestType string `json:"guest_type"`
|
|
Category string `json:"category"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.GuestID == "" || req.Category == "" || req.Title == "" || req.Content == "" {
|
|
http.Error(w, "guest_id, category, title, and content are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.aiService.SaveGuestNote(req.GuestID, req.GuestName, req.GuestType, req.Category, req.Title, req.Content); err != nil {
|
|
http.Error(w, "Failed to save note: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"success": true}`))
|
|
}
|
|
|
|
// HandleDeleteGuestNote deletes a note from a guest
|
|
func (h *AISettingsHandler) HandleDeleteGuestNote(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
GuestID string `json:"guest_id"`
|
|
NoteID string `json:"note_id"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.GuestID == "" || req.NoteID == "" {
|
|
http.Error(w, "guest_id and note_id are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.aiService.DeleteGuestNote(req.GuestID, req.NoteID); err != nil {
|
|
http.Error(w, "Failed to delete note: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"success": true}`))
|
|
}
|
|
|
|
// HandleExportGuestKnowledge exports all knowledge for a guest as JSON
|
|
func (h *AISettingsHandler) HandleExportGuestKnowledge(w http.ResponseWriter, r *http.Request) {
|
|
guestID := r.URL.Query().Get("guest_id")
|
|
if guestID == "" {
|
|
http.Error(w, "guest_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
knowledge, err := h.aiService.GetGuestKnowledge(guestID)
|
|
if err != nil {
|
|
http.Error(w, "Failed to get knowledge: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set headers for file download
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"pulse-notes-"+guestID+".json\"")
|
|
|
|
if err := json.NewEncoder(w).Encode(knowledge); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode knowledge export")
|
|
}
|
|
}
|
|
|
|
// HandleImportGuestKnowledge imports knowledge from a JSON export
|
|
func (h *AISettingsHandler) HandleImportGuestKnowledge(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Limit request body size to 1MB
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
|
|
|
|
var importData struct {
|
|
GuestID string `json:"guest_id"`
|
|
GuestName string `json:"guest_name"`
|
|
GuestType string `json:"guest_type"`
|
|
Notes []struct {
|
|
Category string `json:"category"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
} `json:"notes"`
|
|
Merge bool `json:"merge"` // If true, add to existing notes; if false, replace
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&importData); err != nil {
|
|
http.Error(w, "Invalid import data: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if importData.GuestID == "" {
|
|
http.Error(w, "guest_id is required in import data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(importData.Notes) == 0 {
|
|
http.Error(w, "No notes to import", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// If not merging, we need to delete existing notes first
|
|
if !importData.Merge {
|
|
existing, err := h.aiService.GetGuestKnowledge(importData.GuestID)
|
|
if err == nil && existing != nil {
|
|
for _, note := range existing.Notes {
|
|
_ = h.aiService.DeleteGuestNote(importData.GuestID, note.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import each note
|
|
imported := 0
|
|
for _, note := range importData.Notes {
|
|
if note.Category == "" || note.Title == "" || note.Content == "" {
|
|
continue
|
|
}
|
|
if err := h.aiService.SaveGuestNote(
|
|
importData.GuestID,
|
|
importData.GuestName,
|
|
importData.GuestType,
|
|
note.Category,
|
|
note.Title,
|
|
note.Content,
|
|
); err != nil {
|
|
log.Warn().Err(err).Str("title", note.Title).Msg("Failed to import note")
|
|
continue
|
|
}
|
|
imported++
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"imported": imported,
|
|
"total": len(importData.Notes),
|
|
})
|
|
}
|
|
|
|
// HandleClearGuestKnowledge deletes all notes for a guest
|
|
func (h *AISettingsHandler) HandleClearGuestKnowledge(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
GuestID string `json:"guest_id"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.GuestID == "" {
|
|
http.Error(w, "guest_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !req.Confirm {
|
|
http.Error(w, "confirm must be true to clear all notes", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get existing knowledge and delete all notes
|
|
existing, err := h.aiService.GetGuestKnowledge(req.GuestID)
|
|
if err != nil {
|
|
http.Error(w, "Failed to get knowledge: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
deleted := 0
|
|
for _, note := range existing.Notes {
|
|
if err := h.aiService.DeleteGuestNote(req.GuestID, note.ID); err != nil {
|
|
log.Warn().Err(err).Str("note_id", note.ID).Msg("Failed to delete note")
|
|
continue
|
|
}
|
|
deleted++
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"deleted": deleted,
|
|
})
|
|
}
|
|
|
|
// HandleDebugContext returns the system prompt and context that would be sent to the AI
|
|
// This is useful for debugging when the AI gives incorrect information
|
|
func (h *AISettingsHandler) HandleDebugContext(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Build a sample request to see what context would be sent
|
|
req := ai.ExecuteRequest{
|
|
Prompt: "Debug context request",
|
|
TargetType: r.URL.Query().Get("target_type"),
|
|
TargetID: r.URL.Query().Get("target_id"),
|
|
}
|
|
|
|
// Get the debug context from the service
|
|
debugInfo := h.aiService.GetDebugContext(req)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(debugInfo)
|
|
}
|
|
|
|
// HandleGetConnectedAgents returns the list of agents currently connected via WebSocket
|
|
// This is useful for debugging when AI can't reach certain hosts
|
|
func (h *AISettingsHandler) HandleGetConnectedAgents(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
type agentInfo struct {
|
|
AgentID string `json:"agent_id"`
|
|
Hostname string `json:"hostname"`
|
|
Version string `json:"version"`
|
|
Platform string `json:"platform"`
|
|
ConnectedAt string `json:"connected_at"`
|
|
}
|
|
|
|
var agents []agentInfo
|
|
if h.agentServer != nil {
|
|
for _, a := range h.agentServer.GetConnectedAgents() {
|
|
agents = append(agents, agentInfo{
|
|
AgentID: a.AgentID,
|
|
Hostname: a.Hostname,
|
|
Version: a.Version,
|
|
Platform: a.Platform,
|
|
ConnectedAt: a.ConnectedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"count": len(agents),
|
|
"agents": agents,
|
|
"note": "Agents connect via WebSocket to /api/agent/ws. If a host is missing, check that pulse-agent is installed and can reach the Pulse server.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// AIInvestigateAlertRequest is the request body for POST /api/ai/investigate-alert
|
|
type AIInvestigateAlertRequest struct {
|
|
AlertID string `json:"alert_id"`
|
|
ResourceID string `json:"resource_id"`
|
|
ResourceName string `json:"resource_name"`
|
|
ResourceType string `json:"resource_type"` // guest, node, storage, docker
|
|
AlertType string `json:"alert_type"` // cpu, memory, disk, offline, etc.
|
|
Level string `json:"level"` // warning, critical
|
|
Value float64 `json:"value"`
|
|
Threshold float64 `json:"threshold"`
|
|
Message string `json:"message"`
|
|
Duration string `json:"duration"` // How long the alert has been active
|
|
Node string `json:"node,omitempty"`
|
|
VMID int `json:"vmid,omitempty"`
|
|
}
|
|
|
|
// HandleInvestigateAlert investigates an alert using AI (POST /api/ai/investigate-alert)
|
|
// This is a dedicated endpoint for one-click alert investigation from the UI
|
|
func (h *AISettingsHandler) HandleInvestigateAlert(w http.ResponseWriter, r *http.Request) {
|
|
// Handle CORS
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Cookie")
|
|
w.Header().Set("Vary", "Origin")
|
|
}
|
|
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Require authentication
|
|
if !CheckAuth(h.config, w, r) {
|
|
return
|
|
}
|
|
|
|
// Check if AI is enabled
|
|
if !h.aiService.IsEnabled() {
|
|
http.Error(w, "AI is not enabled or configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
|
var req AIInvestigateAlertRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Build investigation prompt
|
|
investigationPrompt := ai.GenerateAlertInvestigationPrompt(ai.AlertInvestigationRequest{
|
|
AlertID: req.AlertID,
|
|
ResourceID: req.ResourceID,
|
|
ResourceName: req.ResourceName,
|
|
ResourceType: req.ResourceType,
|
|
AlertType: req.AlertType,
|
|
Level: req.Level,
|
|
Value: req.Value,
|
|
Threshold: req.Threshold,
|
|
Message: req.Message,
|
|
Duration: req.Duration,
|
|
Node: req.Node,
|
|
VMID: req.VMID,
|
|
})
|
|
|
|
log.Info().
|
|
Str("alert_id", req.AlertID).
|
|
Str("resource", req.ResourceName).
|
|
Str("type", req.AlertType).
|
|
Msg("AI alert investigation started")
|
|
|
|
// Set up SSE streaming
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.Header().Set("Transfer-Encoding", "identity")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Disable write/read deadlines for SSE
|
|
rc := http.NewResponseController(w)
|
|
_ = rc.SetWriteDeadline(time.Time{})
|
|
_ = rc.SetReadDeadline(time.Time{})
|
|
|
|
flusher.Flush()
|
|
|
|
// Create context with timeout
|
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
|
|
defer cancel()
|
|
|
|
// Heartbeat routine
|
|
heartbeatDone := make(chan struct{})
|
|
var clientDisconnected atomic.Bool
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
_, err := w.Write([]byte(": heartbeat\n\n"))
|
|
if err != nil {
|
|
clientDisconnected.Store(true)
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
case <-heartbeatDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer close(heartbeatDone)
|
|
|
|
safeWrite := func(data []byte) bool {
|
|
if clientDisconnected.Load() {
|
|
return false
|
|
}
|
|
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
_, err := w.Write(data)
|
|
if err != nil {
|
|
clientDisconnected.Store(true)
|
|
return false
|
|
}
|
|
flusher.Flush()
|
|
return true
|
|
}
|
|
|
|
// Determine target type and ID from alert info
|
|
targetType := req.ResourceType
|
|
targetID := req.ResourceID
|
|
|
|
// Map resource type to expected target type format
|
|
switch req.ResourceType {
|
|
case "guest":
|
|
// Could be VM or container - try to determine from VMID
|
|
if req.VMID > 0 {
|
|
targetType = "container" // Default to container, AI will figure it out
|
|
}
|
|
case "docker":
|
|
targetType = "docker_container"
|
|
}
|
|
|
|
// Stream callback
|
|
callback := func(event ai.StreamEvent) {
|
|
if event.Type == "done" {
|
|
return
|
|
}
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return
|
|
}
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
}
|
|
|
|
// Execute with streaming
|
|
defer func() {
|
|
if !clientDisconnected.Load() {
|
|
doneEvent := ai.StreamEvent{Type: "done"}
|
|
data, _ := json.Marshal(doneEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
}
|
|
}()
|
|
|
|
resp, err := h.aiService.ExecuteStream(ctx, ai.ExecuteRequest{
|
|
Prompt: investigationPrompt,
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
Context: map[string]interface{}{
|
|
"alertId": req.AlertID,
|
|
"alertType": req.AlertType,
|
|
"alertLevel": req.Level,
|
|
"alertMessage": req.Message,
|
|
"guestName": req.ResourceName,
|
|
"node": req.Node,
|
|
},
|
|
}, callback)
|
|
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("AI alert investigation failed")
|
|
errEvent := ai.StreamEvent{Type: "error", Data: err.Error()}
|
|
data, _ := json.Marshal(errEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
return
|
|
}
|
|
|
|
// Send completion event
|
|
finalEvent := struct {
|
|
Type string `json:"type"`
|
|
Model string `json:"model"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
ToolCalls []ai.ToolExecution `json:"tool_calls,omitempty"`
|
|
}{
|
|
Type: "complete",
|
|
Model: resp.Model,
|
|
InputTokens: resp.InputTokens,
|
|
OutputTokens: resp.OutputTokens,
|
|
ToolCalls: resp.ToolCalls,
|
|
}
|
|
data, _ := json.Marshal(finalEvent)
|
|
safeWrite([]byte("data: " + string(data) + "\n\n"))
|
|
|
|
log.Info().
|
|
Str("alert_id", req.AlertID).
|
|
Str("model", resp.Model).
|
|
Int("tool_calls", len(resp.ToolCalls)).
|
|
Msg("AI alert investigation completed")
|
|
}
|
|
|
|
// SetAlertProvider sets the alert provider for AI context
|
|
func (h *AISettingsHandler) SetAlertProvider(ap ai.AlertProvider) {
|
|
h.aiService.SetAlertProvider(ap)
|
|
}
|
|
|
|
// oauthSessions stores active OAuth sessions (state -> session)
|
|
// In production, consider using a more robust session store with expiry
|
|
var oauthSessions = make(map[string]*providers.OAuthSession)
|
|
var oauthSessionsMu sync.Mutex
|
|
|
|
// HandleOAuthStart initiates the OAuth flow for Claude Pro/Max subscription (POST /api/ai/oauth/start)
|
|
// Returns an authorization URL for the user to visit manually
|
|
func (h *AISettingsHandler) HandleOAuthStart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Generate OAuth session (redirect URI is not used since we use Anthropic's callback)
|
|
session, err := providers.GenerateOAuthSession("")
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate OAuth session")
|
|
http.Error(w, "Failed to start OAuth flow", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Store session (with cleanup of old sessions)
|
|
oauthSessionsMu.Lock()
|
|
// Clean up sessions older than 15 minutes
|
|
for state, s := range oauthSessions {
|
|
if time.Since(s.CreatedAt) > 15*time.Minute {
|
|
delete(oauthSessions, state)
|
|
}
|
|
}
|
|
oauthSessions[session.State] = session
|
|
oauthSessionsMu.Unlock()
|
|
|
|
// Get authorization URL
|
|
authURL := providers.GetAuthorizationURL(session)
|
|
|
|
log.Info().
|
|
Str("state", safePrefixForLog(session.State, 8)+"...").
|
|
Str("verifier_len", fmt.Sprintf("%d", len(session.CodeVerifier))).
|
|
Str("auth_url", authURL).
|
|
Msg("Starting Claude OAuth flow - user must visit URL and paste code back")
|
|
|
|
// Return the URL for the user to visit
|
|
response := map[string]string{
|
|
"auth_url": authURL,
|
|
"state": session.State,
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write OAuth start response")
|
|
}
|
|
}
|
|
|
|
// HandleOAuthExchange exchanges a manually-pasted authorization code for tokens (POST /api/ai/oauth/exchange)
|
|
func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var req struct {
|
|
Code string `json:"code"`
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Code == "" || req.State == "" {
|
|
http.Error(w, "Missing code or state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Trim any whitespace from the code (user might have copied extra spaces)
|
|
code := strings.TrimSpace(req.Code)
|
|
|
|
// Anthropic's callback page displays the code as "code#state"
|
|
// We need to extract just the code part before the #
|
|
if idx := strings.Index(code, "#"); idx > 0 {
|
|
code = code[:idx]
|
|
}
|
|
|
|
log.Debug().
|
|
Str("code_len", fmt.Sprintf("%d", len(code))).
|
|
Str("code_prefix", code[:min(20, len(code))]).
|
|
Str("state_prefix", req.State[:min(8, len(req.State))]).
|
|
Msg("Processing OAuth code exchange")
|
|
|
|
// Look up session
|
|
oauthSessionsMu.Lock()
|
|
session, ok := oauthSessions[req.State]
|
|
if ok {
|
|
delete(oauthSessions, req.State) // One-time use
|
|
}
|
|
oauthSessionsMu.Unlock()
|
|
|
|
if !ok {
|
|
log.Error().Str("state", req.State[:min(8, len(req.State))]+"...").Msg("OAuth exchange with unknown state")
|
|
http.Error(w, "Invalid or expired session. Please start the OAuth flow again.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
tokens, err := providers.ExchangeCodeForTokens(ctx, code, session)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens")
|
|
http.Error(w, "Failed to exchange authorization code: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try to create an API key from the OAuth access token
|
|
// Team/Enterprise users get org:create_api_key scope and can create API keys
|
|
// Pro/Max users don't have this scope and will use OAuth tokens directly
|
|
apiKey, err := providers.CreateAPIKeyFromOAuth(ctx, tokens.AccessToken)
|
|
if err != nil {
|
|
// Check if it's a permission error (Pro/Max users)
|
|
if strings.Contains(err.Error(), "org:create_api_key") || strings.Contains(err.Error(), "403") {
|
|
log.Info().Msg("User doesn't have org:create_api_key permission - will use OAuth tokens directly")
|
|
// This is fine for Pro/Max users - they'll use OAuth tokens
|
|
} else {
|
|
log.Error().Err(err).Msg("Failed to create API key from OAuth token")
|
|
http.Error(w, "Failed to create API key: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if apiKey != "" {
|
|
log.Info().Msg("Successfully created API key from OAuth - using subscription-based billing")
|
|
}
|
|
|
|
// Load existing settings
|
|
settings, err := h.persistence.LoadAIConfig()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to load AI settings for OAuth")
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
if settings == nil {
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
|
|
// Update settings
|
|
settings.Provider = config.AIProviderAnthropic
|
|
settings.AuthMethod = config.AuthMethodOAuth
|
|
settings.OAuthAccessToken = tokens.AccessToken
|
|
settings.OAuthRefreshToken = tokens.RefreshToken
|
|
settings.OAuthExpiresAt = tokens.ExpiresAt
|
|
settings.Enabled = true
|
|
|
|
// If we got an API key, use it; otherwise use OAuth tokens directly
|
|
if apiKey != "" {
|
|
settings.APIKey = apiKey
|
|
} else {
|
|
// Pro/Max users: clear any old API key, will use OAuth client
|
|
settings.ClearAPIKey()
|
|
}
|
|
|
|
// Save settings
|
|
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save OAuth tokens")
|
|
http.Error(w, "Failed to save OAuth credentials", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload the AI service with new settings
|
|
if err := h.aiService.Reload(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup")
|
|
}
|
|
|
|
log.Info().Msg("Claude OAuth authentication successful")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Successfully connected to Claude with your subscription",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write OAuth exchange response")
|
|
}
|
|
}
|
|
|
|
// HandleOAuthCallback handles the OAuth callback (GET /api/ai/oauth/callback)
|
|
// This is kept for backwards compatibility but mainly serves as a fallback
|
|
func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Get code and state from query params
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
errParam := r.URL.Query().Get("error")
|
|
errDesc := r.URL.Query().Get("error_description")
|
|
|
|
// Check for OAuth error
|
|
if errParam != "" {
|
|
log.Error().
|
|
Str("error", errParam).
|
|
Str("description", errDesc).
|
|
Msg("OAuth authorization failed")
|
|
// Redirect to settings page with error
|
|
http.Redirect(w, r, "/settings?ai_oauth_error="+errParam, http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
if code == "" || state == "" {
|
|
log.Error().Msg("OAuth callback missing code or state")
|
|
http.Redirect(w, r, "/settings?ai_oauth_error=missing_params", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Look up session
|
|
oauthSessionsMu.Lock()
|
|
session, ok := oauthSessions[state]
|
|
if ok {
|
|
delete(oauthSessions, state) // One-time use
|
|
}
|
|
oauthSessionsMu.Unlock()
|
|
|
|
if !ok {
|
|
log.Error().Str("state", state).Msg("OAuth callback with unknown state")
|
|
http.Redirect(w, r, "/settings?ai_oauth_error=invalid_state", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
tokens, err := providers.ExchangeCodeForTokens(ctx, code, session)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens")
|
|
http.Redirect(w, r, "/settings?ai_oauth_error=token_exchange_failed", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Load existing settings
|
|
settings, err := h.persistence.LoadAIConfig()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to load AI settings for OAuth")
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
if settings == nil {
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
|
|
// Update settings with OAuth tokens
|
|
settings.Provider = config.AIProviderAnthropic
|
|
settings.AuthMethod = config.AuthMethodOAuth
|
|
settings.OAuthAccessToken = tokens.AccessToken
|
|
settings.OAuthRefreshToken = tokens.RefreshToken
|
|
settings.OAuthExpiresAt = tokens.ExpiresAt
|
|
settings.Enabled = true
|
|
// Clear API key since we're using OAuth
|
|
settings.ClearAPIKey()
|
|
|
|
// Save settings
|
|
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save OAuth tokens")
|
|
http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Reload the AI service with new settings
|
|
if err := h.aiService.Reload(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup")
|
|
}
|
|
|
|
log.Info().Msg("Claude OAuth authentication successful")
|
|
|
|
// Redirect to settings page with success
|
|
http.Redirect(w, r, "/settings?ai_oauth_success=true", http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
// HandleOAuthDisconnect disconnects OAuth and clears tokens (POST /api/ai/oauth/disconnect)
|
|
func (h *AISettingsHandler) HandleOAuthDisconnect(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
|
|
}
|
|
|
|
// Load existing settings
|
|
settings, err := h.persistence.LoadAIConfig()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to load AI settings for OAuth disconnect")
|
|
http.Error(w, "Failed to load settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if settings == nil {
|
|
settings = config.NewDefaultAIConfig()
|
|
}
|
|
|
|
// Clear OAuth tokens
|
|
settings.ClearOAuthTokens()
|
|
settings.AuthMethod = config.AuthMethodAPIKey
|
|
|
|
// Save settings
|
|
if err := h.persistence.SaveAIConfig(*settings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save settings after OAuth disconnect")
|
|
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload the AI service
|
|
if err := h.aiService.Reload(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth disconnect")
|
|
}
|
|
|
|
log.Info().Msg("Claude OAuth disconnected")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "OAuth disconnected successfully",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write OAuth disconnect response")
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
|
|
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,
|
|
NextPatrolAt: status.NextPatrolAt,
|
|
LastDurationMs: status.LastDuration.Milliseconds(),
|
|
ResourcesChecked: status.ResourcesChecked,
|
|
FindingsCount: status.FindingsCount,
|
|
Healthy: status.Healthy,
|
|
IntervalMs: status.IntervalMs,
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// HandlePatrolStream streams real-time patrol analysis via SSE (GET /api/ai/patrol/stream)
|
|
func (h *AISettingsHandler) HandlePatrolStream(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 {
|
|
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Set SSE headers
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Subscribe to patrol stream
|
|
// Note: SubscribeToStream already sends the current buffered output to the channel
|
|
ch := patrol.SubscribeToStream()
|
|
defer patrol.UnsubscribeFromStream(ch)
|
|
|
|
// Stream events until client disconnects
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// HandleResolveFinding manually marks a finding as resolved (POST /api/ai/patrol/resolve)
|
|
// Use this when the user has fixed the issue and wants to mark it as resolved
|
|
func (h *AISettingsHandler) HandleResolveFinding(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()
|
|
|
|
// Mark as manually resolved (auto=false since user did it)
|
|
if !findings.Resolve(req.FindingID, false) {
|
|
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("finding_id", req.FindingID).
|
|
Msg("AI Patrol: Finding manually resolved by user")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Finding marked as resolved",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write resolve response")
|
|
}
|
|
}
|
|
|
|
// HandleDismissFinding dismisses a finding with a reason and optional note (POST /api/ai/patrol/dismiss)
|
|
// This is part of the LLM memory system - dismissed findings are included in context to prevent re-raising
|
|
// Valid reasons: "not_an_issue", "expected_behavior", "will_fix_later"
|
|
func (h *AISettingsHandler) HandleDismissFinding(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"`
|
|
Reason string `json:"reason"` // "not_an_issue", "expected_behavior", "will_fix_later"
|
|
Note string `json:"note"` // Optional freeform note
|
|
}
|
|
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
|
|
}
|
|
|
|
// Validate reason
|
|
validReasons := map[string]bool{
|
|
"not_an_issue": true,
|
|
"expected_behavior": true,
|
|
"will_fix_later": true,
|
|
}
|
|
if req.Reason != "" && !validReasons[req.Reason] {
|
|
http.Error(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
findings := patrol.GetFindings()
|
|
|
|
if !findings.Dismiss(req.FindingID, req.Reason, req.Note) {
|
|
http.Error(w, "Finding not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("finding_id", req.FindingID).
|
|
Str("reason", req.Reason).
|
|
Bool("has_note", req.Note != "").
|
|
Msg("AI Patrol: Finding dismissed by user with reason")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Finding dismissed",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write dismiss response")
|
|
}
|
|
}
|
|
|
|
// HandleSuppressFinding permanently suppresses similar findings for a resource (POST /api/ai/patrol/suppress)
|
|
// The LLM will be told never to re-raise findings of this type for this resource
|
|
func (h *AISettingsHandler) HandleSuppressFinding(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()
|
|
|
|
if !findings.Suppress(req.FindingID) {
|
|
http.Error(w, "Finding not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("finding_id", req.FindingID).
|
|
Msg("AI Patrol: Finding type permanently suppressed by user")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Finding type suppressed - similar issues will not be raised again",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write suppress 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")
|
|
}
|
|
}
|
|
|
|
// HandleGetPatrolRunHistory returns the history of patrol check runs (GET /api/ai/patrol/runs)
|
|
func (h *AISettingsHandler) HandleGetPatrolRunHistory(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 history
|
|
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write patrol run history response")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parse optional limit query parameter (default: 50)
|
|
limit := 50
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if l, err := fmt.Sscanf(limitStr, "%d", &limit); err == nil && l > 0 {
|
|
if limit > 100 {
|
|
limit = 100 // Cap at MaxPatrolRunHistory
|
|
}
|
|
}
|
|
}
|
|
|
|
runs := patrol.GetRunHistory(limit)
|
|
|
|
if err := utils.WriteJSONResponse(w, runs); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write patrol run history response")
|
|
}
|
|
}
|
|
|
|
// HandleGetAICostSummary returns AI usage rollups (GET /api/ai/cost/summary?days=N).
|
|
func (h *AISettingsHandler) HandleGetAICostSummary(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Parse optional days query parameter (default: 30, max: 365)
|
|
days := 30
|
|
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
|
|
if _, err := fmt.Sscanf(daysStr, "%d", &days); err == nil && days > 0 {
|
|
if days > 365 {
|
|
days = 365
|
|
}
|
|
}
|
|
}
|
|
|
|
var summary cost.Summary
|
|
if h.aiService != nil {
|
|
summary = h.aiService.GetCostSummary(days)
|
|
} else {
|
|
summary = cost.Summary{
|
|
Days: days,
|
|
PricingAsOf: cost.PricingAsOf(),
|
|
ProviderModels: []cost.ProviderModelSummary{},
|
|
DailyTotals: []cost.DailySummary{},
|
|
Totals: cost.ProviderModelSummary{Provider: "all"},
|
|
}
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, summary); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI cost summary response")
|
|
}
|
|
}
|
|
|
|
// HandleResetAICostHistory deletes retained AI usage events (POST /api/ai/cost/reset).
|
|
func (h *AISettingsHandler) HandleResetAICostHistory(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if h.aiService == nil {
|
|
http.Error(w, "AI service unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
backupFile := ""
|
|
if h.persistence != nil {
|
|
configDir := h.persistence.DataDir()
|
|
if strings.TrimSpace(configDir) != "" {
|
|
usagePath := filepath.Join(configDir, "ai_usage_history.json")
|
|
if _, err := os.Stat(usagePath); err == nil {
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
backupFile = fmt.Sprintf("ai_usage_history.json.bak-%s", ts)
|
|
backupPath := filepath.Join(configDir, backupFile)
|
|
if err := os.Rename(usagePath, backupPath); err != nil {
|
|
log.Error().Err(err).Str("path", usagePath).Msg("Failed to backup AI usage history before reset")
|
|
http.Error(w, "Failed to backup AI usage history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.aiService.ClearCostHistory(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to clear AI cost history")
|
|
http.Error(w, "Failed to clear AI cost history", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{"ok": true}
|
|
if backupFile != "" {
|
|
resp["backup_file"] = backupFile
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write clear cost history response")
|
|
}
|
|
}
|
|
|
|
// HandleExportAICostHistory exports recent AI usage history as JSON or CSV (GET /api/ai/cost/export?days=N&format=csv|json).
|
|
func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if h.aiService == nil {
|
|
http.Error(w, "AI service unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
days := 30
|
|
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
|
|
if v, err := strconv.Atoi(daysStr); err == nil && v > 0 {
|
|
if v > 365 {
|
|
v = 365
|
|
}
|
|
days = v
|
|
}
|
|
}
|
|
|
|
format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format")))
|
|
if format == "" {
|
|
format = "json"
|
|
}
|
|
if format != "json" && format != "csv" {
|
|
http.Error(w, "format must be 'json' or 'csv'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
events := h.aiService.ListCostEvents(days)
|
|
|
|
filename := fmt.Sprintf("pulse-ai-usage-%s-%dd.%s", time.Now().UTC().Format("20060102"), days, format)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
|
|
if format == "json" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
type exportEvent struct {
|
|
cost.UsageEvent
|
|
EstimatedUSD float64 `json:"estimated_usd,omitempty"`
|
|
PricingKnown bool `json:"pricing_known"`
|
|
}
|
|
exported := make([]exportEvent, 0, len(events))
|
|
for _, e := range events {
|
|
provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel)
|
|
usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens))
|
|
exported = append(exported, exportEvent{
|
|
UsageEvent: e,
|
|
EstimatedUSD: usd,
|
|
PricingKnown: ok,
|
|
})
|
|
}
|
|
resp := map[string]any{
|
|
"days": days,
|
|
"events": exported,
|
|
}
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI cost export JSON")
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
cw := csv.NewWriter(w)
|
|
_ = cw.Write([]string{
|
|
"timestamp",
|
|
"provider",
|
|
"request_model",
|
|
"response_model",
|
|
"use_case",
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"estimated_usd",
|
|
"pricing_known",
|
|
"target_type",
|
|
"target_id",
|
|
"finding_id",
|
|
})
|
|
for _, e := range events {
|
|
provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel)
|
|
usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens))
|
|
|
|
_ = cw.Write([]string{
|
|
e.Timestamp.UTC().Format(time.RFC3339Nano),
|
|
e.Provider,
|
|
e.RequestModel,
|
|
e.ResponseModel,
|
|
e.UseCase,
|
|
strconv.Itoa(e.InputTokens),
|
|
strconv.Itoa(e.OutputTokens),
|
|
strconv.FormatFloat(usd, 'f', 6, 64),
|
|
strconv.FormatBool(ok),
|
|
e.TargetType,
|
|
e.TargetID,
|
|
e.FindingID,
|
|
})
|
|
}
|
|
cw.Flush()
|
|
if err := cw.Error(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write AI cost export CSV")
|
|
}
|
|
}
|
|
|
|
// HandleGetSuppressionRules returns all suppression rules (GET /api/ai/patrol/suppressions)
|
|
func (h *AISettingsHandler) HandleGetSuppressionRules(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 {
|
|
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write suppression rules response")
|
|
}
|
|
return
|
|
}
|
|
|
|
findings := patrol.GetFindings()
|
|
rules := findings.GetSuppressionRules()
|
|
|
|
if err := utils.WriteJSONResponse(w, rules); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write suppression rules response")
|
|
}
|
|
}
|
|
|
|
// HandleAddSuppressionRule creates a new suppression rule (POST /api/ai/patrol/suppressions)
|
|
func (h *AISettingsHandler) HandleAddSuppressionRule(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 {
|
|
ResourceID string `json:"resource_id"` // Can be empty for "any resource"
|
|
ResourceName string `json:"resource_name"` // Human-readable name
|
|
Category string `json:"category"` // Can be empty for "any category"
|
|
Description string `json:"description"` // Required - user's reason
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Description == "" {
|
|
http.Error(w, "description is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Convert category string to FindingCategory
|
|
var category ai.FindingCategory
|
|
switch req.Category {
|
|
case "performance":
|
|
category = ai.FindingCategoryPerformance
|
|
case "capacity":
|
|
category = ai.FindingCategoryCapacity
|
|
case "reliability":
|
|
category = ai.FindingCategoryReliability
|
|
case "backup":
|
|
category = ai.FindingCategoryBackup
|
|
case "security":
|
|
category = ai.FindingCategorySecurity
|
|
case "general":
|
|
category = ai.FindingCategoryGeneral
|
|
case "":
|
|
category = "" // Any category
|
|
default:
|
|
http.Error(w, "Invalid category", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
findings := patrol.GetFindings()
|
|
rule := findings.AddSuppressionRule(req.ResourceID, req.ResourceName, category, req.Description)
|
|
|
|
log.Info().
|
|
Str("rule_id", rule.ID).
|
|
Str("resource_id", req.ResourceID).
|
|
Str("category", req.Category).
|
|
Str("description", req.Description).
|
|
Msg("AI Patrol: Manual suppression rule created")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Suppression rule created",
|
|
"rule": rule,
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write add suppression rule response")
|
|
}
|
|
}
|
|
|
|
// HandleDeleteSuppressionRule removes a suppression rule (DELETE /api/ai/patrol/suppressions/:id)
|
|
func (h *AISettingsHandler) HandleDeleteSuppressionRule(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
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
|
|
}
|
|
|
|
// Get rule ID from URL path
|
|
ruleID := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/suppressions/")
|
|
if ruleID == "" {
|
|
http.Error(w, "rule_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
findings := patrol.GetFindings()
|
|
|
|
if !findings.DeleteSuppressionRule(ruleID) {
|
|
http.Error(w, "Rule not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("rule_id", ruleID).
|
|
Msg("AI Patrol: Suppression rule deleted")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Suppression rule deleted",
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write delete suppression rule response")
|
|
}
|
|
}
|
|
|
|
// HandleGetDismissedFindings returns all dismissed/suppressed findings (GET /api/ai/patrol/dismissed)
|
|
func (h *AISettingsHandler) HandleGetDismissedFindings(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 {
|
|
if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write dismissed findings response")
|
|
}
|
|
return
|
|
}
|
|
|
|
findings := patrol.GetFindings()
|
|
dismissed := findings.GetDismissedFindings()
|
|
|
|
if err := utils.WriteJSONResponse(w, dismissed); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write dismissed findings response")
|
|
}
|
|
}
|