mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-30 11:51:05 +00:00
The issue was a SolidJS reactivity problem in the Dashboard component.
When guestMetadata signal was accessed inside a For loop callback and
assigned to a plain variable, SolidJS lost reactive tracking.
Changed from:
const metadata = guestMetadata()[guestId] || ...
customUrl={metadata?.customUrl}
To:
const getMetadata = () => guestMetadata()[guestId] || ...
customUrl={getMetadata()?.customUrl}
This ensures SolidJS properly tracks the signal dependency when the
getter function is called directly in JSX props.
456 lines
16 KiB
Go
456 lines
16 KiB
Go
package config
|
|
|
|
import "time"
|
|
|
|
// AuthMethod represents how Anthropic authentication is performed
|
|
type AuthMethod string
|
|
|
|
const (
|
|
// AuthMethodAPIKey uses a traditional API key (pay-per-use billing)
|
|
AuthMethodAPIKey AuthMethod = "api_key"
|
|
// AuthMethodOAuth uses OAuth tokens (subscription-based, Pro/Max plans)
|
|
AuthMethodOAuth AuthMethod = "oauth"
|
|
)
|
|
|
|
// AIConfig holds AI feature configuration
|
|
// This is stored in ai.enc (encrypted) in the config directory
|
|
type AIConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Provider string `json:"provider"` // DEPRECATED: legacy single provider field, kept for migration
|
|
APIKey string `json:"api_key"` // DEPRECATED: legacy single API key, kept for migration
|
|
Model string `json:"model"` // Currently selected default model (format: "provider:model-name")
|
|
ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (defaults to Model)
|
|
PatrolModel string `json:"patrol_model,omitempty"` // Model for background patrol (defaults to Model, can be cheaper)
|
|
BaseURL string `json:"base_url"` // DEPRECATED: legacy base URL, kept for migration
|
|
AutonomousMode bool `json:"autonomous_mode"` // when true, AI executes commands without approval
|
|
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
|
|
|
// Multi-provider credentials - each provider can be configured independently
|
|
AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key
|
|
OpenAIAPIKey string `json:"openai_api_key,omitempty"` // OpenAI API key
|
|
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
|
|
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
|
|
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
|
|
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
|
|
|
|
// OAuth fields for Claude Pro/Max subscription authentication
|
|
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or "oauth" (for anthropic only)
|
|
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // OAuth access token (encrypted at rest)
|
|
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // OAuth refresh token (encrypted at rest)
|
|
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // Token expiration time
|
|
|
|
// Patrol settings for background AI monitoring
|
|
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
|
|
PatrolIntervalMinutes int `json:"patrol_interval_minutes,omitempty"` // How often to run quick patrols (default: 360 = 6 hours)
|
|
PatrolSchedulePreset string `json:"patrol_schedule_preset,omitempty"` // User-friendly preset: "15min", "1hr", "6hr", "12hr", "daily", "disabled"
|
|
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes,omitempty"` // Include Proxmox nodes in patrol
|
|
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests,omitempty"` // Include VMs/containers in patrol
|
|
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker,omitempty"` // Include Docker hosts in patrol
|
|
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage,omitempty"` // Include storage in patrol
|
|
PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only)
|
|
AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for automatic remediation (defaults to PatrolModel, may want more capable model)
|
|
|
|
// Alert-triggered AI analysis - analyze specific resources when alerts fire
|
|
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis,omitempty"` // Enable AI analysis when alerts fire (token-efficient)
|
|
|
|
// AI cost controls
|
|
// Budget is expressed as an estimated USD amount over a 30-day window (pro-rated in UI for other ranges).
|
|
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
|
|
}
|
|
|
|
// AIProvider constants
|
|
const (
|
|
AIProviderAnthropic = "anthropic"
|
|
AIProviderOpenAI = "openai"
|
|
AIProviderOllama = "ollama"
|
|
AIProviderDeepSeek = "deepseek"
|
|
AIProviderGemini = "gemini"
|
|
)
|
|
|
|
// Default models per provider
|
|
const (
|
|
DefaultAIModelAnthropic = "claude-opus-4-5-20251101"
|
|
DefaultAIModelOpenAI = "gpt-4o"
|
|
DefaultAIModelOllama = "llama3"
|
|
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
|
|
DefaultAIModelGemini = "gemini-2.5-flash" // Latest stable Gemini model
|
|
DefaultOllamaBaseURL = "http://localhost:11434"
|
|
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
|
|
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
|
)
|
|
|
|
// ModelInfo represents information about an available model
|
|
// Deprecated: Use providers.ModelInfo instead - models are now fetched dynamically from APIs
|
|
type ModelInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
IsDefault bool `json:"is_default,omitempty"`
|
|
}
|
|
|
|
// GetAvailableModels is deprecated - models are now fetched dynamically from provider APIs
|
|
// This returns nil; use the /api/ai/models endpoint instead which queries the actual API
|
|
func GetAvailableModels(provider string) []ModelInfo {
|
|
return nil
|
|
}
|
|
|
|
// NewDefaultAIConfig returns an AIConfig with sensible defaults
|
|
func NewDefaultAIConfig() *AIConfig {
|
|
return &AIConfig{
|
|
Enabled: false,
|
|
Provider: AIProviderAnthropic,
|
|
Model: DefaultAIModelAnthropic,
|
|
AuthMethod: AuthMethodAPIKey,
|
|
// Patrol defaults - enabled when AI is enabled
|
|
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
|
PatrolEnabled: true,
|
|
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
|
|
PatrolSchedulePreset: "6hr",
|
|
PatrolAnalyzeNodes: true,
|
|
PatrolAnalyzeGuests: true,
|
|
PatrolAnalyzeDocker: true,
|
|
PatrolAnalyzeStorage: true,
|
|
// Alert-triggered analysis is highly token-efficient - enabled by default
|
|
AlertTriggeredAnalysis: true,
|
|
}
|
|
}
|
|
|
|
// IsConfigured returns true if the AI config has enough info to make API calls
|
|
// For multi-provider setup, returns true if ANY provider is configured
|
|
func (c *AIConfig) IsConfigured() bool {
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
|
|
// Check multi-provider credentials first (new format)
|
|
if c.HasProvider(AIProviderAnthropic) || c.HasProvider(AIProviderOpenAI) ||
|
|
c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) ||
|
|
c.HasProvider(AIProviderGemini) {
|
|
return true
|
|
}
|
|
|
|
// Fall back to legacy single-provider check for backward compatibility
|
|
switch c.Provider {
|
|
case AIProviderAnthropic:
|
|
if c.AuthMethod == AuthMethodOAuth {
|
|
return c.OAuthAccessToken != ""
|
|
}
|
|
return c.APIKey != ""
|
|
case AIProviderOpenAI, AIProviderDeepSeek:
|
|
return c.APIKey != ""
|
|
case AIProviderOllama:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// HasProvider returns true if the specified provider has credentials configured
|
|
func (c *AIConfig) HasProvider(provider string) bool {
|
|
switch provider {
|
|
case AIProviderAnthropic:
|
|
// Anthropic can use API key OR OAuth
|
|
if c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != "" {
|
|
return true
|
|
}
|
|
return c.AnthropicAPIKey != ""
|
|
case AIProviderOpenAI:
|
|
return c.OpenAIAPIKey != ""
|
|
case AIProviderDeepSeek:
|
|
return c.DeepSeekAPIKey != ""
|
|
case AIProviderGemini:
|
|
return c.GeminiAPIKey != ""
|
|
case AIProviderOllama:
|
|
// Ollama is only "configured" if user has explicitly set a base URL
|
|
return c.OllamaBaseURL != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// GetConfiguredProviders returns a list of all providers with credentials configured
|
|
func (c *AIConfig) GetConfiguredProviders() []string {
|
|
var providers []string
|
|
if c.HasProvider(AIProviderAnthropic) {
|
|
providers = append(providers, AIProviderAnthropic)
|
|
}
|
|
if c.HasProvider(AIProviderOpenAI) {
|
|
providers = append(providers, AIProviderOpenAI)
|
|
}
|
|
if c.HasProvider(AIProviderDeepSeek) {
|
|
providers = append(providers, AIProviderDeepSeek)
|
|
}
|
|
if c.HasProvider(AIProviderGemini) {
|
|
providers = append(providers, AIProviderGemini)
|
|
}
|
|
if c.HasProvider(AIProviderOllama) {
|
|
providers = append(providers, AIProviderOllama)
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// GetAPIKeyForProvider returns the API key for the specified provider
|
|
func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
|
|
switch provider {
|
|
case AIProviderAnthropic:
|
|
if c.AnthropicAPIKey != "" {
|
|
return c.AnthropicAPIKey
|
|
}
|
|
// Fall back to legacy API key if provider matches
|
|
if c.Provider == AIProviderAnthropic {
|
|
return c.APIKey
|
|
}
|
|
case AIProviderOpenAI:
|
|
if c.OpenAIAPIKey != "" {
|
|
return c.OpenAIAPIKey
|
|
}
|
|
if c.Provider == AIProviderOpenAI {
|
|
return c.APIKey
|
|
}
|
|
case AIProviderDeepSeek:
|
|
if c.DeepSeekAPIKey != "" {
|
|
return c.DeepSeekAPIKey
|
|
}
|
|
if c.Provider == AIProviderDeepSeek {
|
|
return c.APIKey
|
|
}
|
|
case AIProviderGemini:
|
|
if c.GeminiAPIKey != "" {
|
|
return c.GeminiAPIKey
|
|
}
|
|
if c.Provider == AIProviderGemini {
|
|
return c.APIKey
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetBaseURLForProvider returns the base URL for the specified provider
|
|
func (c *AIConfig) GetBaseURLForProvider(provider string) string {
|
|
switch provider {
|
|
case AIProviderOllama:
|
|
if c.OllamaBaseURL != "" {
|
|
return c.OllamaBaseURL
|
|
}
|
|
// Fall back to legacy BaseURL if provider matches
|
|
if c.Provider == AIProviderOllama && c.BaseURL != "" {
|
|
return c.BaseURL
|
|
}
|
|
return DefaultOllamaBaseURL
|
|
case AIProviderOpenAI:
|
|
if c.OpenAIBaseURL != "" {
|
|
return c.OpenAIBaseURL
|
|
}
|
|
return "" // Uses default OpenAI URL
|
|
case AIProviderDeepSeek:
|
|
return DefaultDeepSeekBaseURL
|
|
case AIProviderGemini:
|
|
return DefaultGeminiBaseURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsUsingOAuth returns true if OAuth authentication is configured for Anthropic
|
|
func (c *AIConfig) IsUsingOAuth() bool {
|
|
return c.AuthMethod == AuthMethodOAuth && c.OAuthAccessToken != ""
|
|
}
|
|
|
|
// ParseModelString parses a model string in "provider:model-name" format
|
|
// Returns the provider and model name. If no provider prefix, attempts to detect.
|
|
func ParseModelString(model string) (provider, modelName string) {
|
|
// Check for explicit provider prefix
|
|
for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderDeepSeek, AIProviderGemini, AIProviderOllama} {
|
|
prefix := p + ":"
|
|
if len(model) > len(prefix) && model[:len(prefix)] == prefix {
|
|
return p, model[len(prefix):]
|
|
}
|
|
}
|
|
|
|
// No prefix - try to detect from model name patterns
|
|
switch {
|
|
case len(model) >= 6 && model[:6] == "claude":
|
|
return AIProviderAnthropic, model
|
|
case len(model) >= 3 && (model[:3] == "gpt" || model[:2] == "o1" || model[:2] == "o3" || model[:2] == "o4"):
|
|
return AIProviderOpenAI, model
|
|
case len(model) >= 8 && model[:8] == "deepseek":
|
|
return AIProviderDeepSeek, model
|
|
case len(model) >= 6 && model[:6] == "gemini":
|
|
return AIProviderGemini, model
|
|
default:
|
|
// Assume Ollama for unrecognized models (local models have varied names)
|
|
return AIProviderOllama, model
|
|
}
|
|
}
|
|
|
|
// FormatModelString creates a "provider:model-name" format string
|
|
func FormatModelString(provider, modelName string) string {
|
|
return provider + ":" + modelName
|
|
}
|
|
|
|
// GetBaseURL returns the base URL, using defaults where appropriate
|
|
// DEPRECATED: Use GetBaseURLForProvider instead
|
|
func (c *AIConfig) GetBaseURL() string {
|
|
if c.BaseURL != "" {
|
|
return c.BaseURL
|
|
}
|
|
switch c.Provider {
|
|
case AIProviderOllama:
|
|
return DefaultOllamaBaseURL
|
|
case AIProviderDeepSeek:
|
|
return DefaultDeepSeekBaseURL
|
|
case AIProviderGemini:
|
|
return DefaultGeminiBaseURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetModel returns the model, using defaults where appropriate
|
|
func (c *AIConfig) GetModel() string {
|
|
if c.Model != "" {
|
|
return c.Model
|
|
}
|
|
|
|
// If only one provider is configured, use its default model
|
|
// This handles the case where user configures Ollama but doesn't explicitly select a model
|
|
configured := c.GetConfiguredProviders()
|
|
if len(configured) == 1 {
|
|
switch configured[0] {
|
|
case AIProviderAnthropic:
|
|
return DefaultAIModelAnthropic
|
|
case AIProviderOpenAI:
|
|
return DefaultAIModelOpenAI
|
|
case AIProviderOllama:
|
|
return DefaultAIModelOllama
|
|
case AIProviderDeepSeek:
|
|
return DefaultAIModelDeepSeek
|
|
case AIProviderGemini:
|
|
return DefaultAIModelGemini
|
|
}
|
|
}
|
|
|
|
// Fall back to legacy Provider field for backwards compatibility
|
|
switch c.Provider {
|
|
case AIProviderAnthropic:
|
|
return DefaultAIModelAnthropic
|
|
case AIProviderOpenAI:
|
|
return DefaultAIModelOpenAI
|
|
case AIProviderOllama:
|
|
return DefaultAIModelOllama
|
|
case AIProviderDeepSeek:
|
|
return DefaultAIModelDeepSeek
|
|
case AIProviderGemini:
|
|
return DefaultAIModelGemini
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// GetChatModel returns the model for interactive chat conversations
|
|
// Falls back to the main Model if ChatModel is not set
|
|
func (c *AIConfig) GetChatModel() string {
|
|
if c.ChatModel != "" {
|
|
return c.ChatModel
|
|
}
|
|
return c.GetModel()
|
|
}
|
|
|
|
// GetPatrolModel returns the model for background patrol analysis
|
|
// Falls back to the main Model if PatrolModel is not set
|
|
func (c *AIConfig) GetPatrolModel() string {
|
|
if c.PatrolModel != "" {
|
|
return c.PatrolModel
|
|
}
|
|
return c.GetModel()
|
|
}
|
|
|
|
// GetAutoFixModel returns the model for automatic remediation actions
|
|
// Falls back to PatrolModel, then to the main Model if AutoFixModel is not set
|
|
// Auto-fix may warrant a more capable model since it takes actions
|
|
func (c *AIConfig) GetAutoFixModel() string {
|
|
if c.AutoFixModel != "" {
|
|
return c.AutoFixModel
|
|
}
|
|
return c.GetPatrolModel()
|
|
}
|
|
|
|
// ClearOAuthTokens clears OAuth tokens (used when switching back to API key auth)
|
|
func (c *AIConfig) ClearOAuthTokens() {
|
|
c.OAuthAccessToken = ""
|
|
c.OAuthRefreshToken = ""
|
|
c.OAuthExpiresAt = time.Time{}
|
|
}
|
|
|
|
// ClearAPIKey clears the API key (used when switching to OAuth auth)
|
|
func (c *AIConfig) ClearAPIKey() {
|
|
c.APIKey = ""
|
|
}
|
|
|
|
// GetPatrolInterval returns the patrol interval as a duration
|
|
// Uses the preset if set, otherwise falls back to custom minutes
|
|
func (c *AIConfig) GetPatrolInterval() time.Duration {
|
|
// If preset is set, use it
|
|
if c.PatrolSchedulePreset != "" {
|
|
switch c.PatrolSchedulePreset {
|
|
case "15min":
|
|
return 15 * time.Minute
|
|
case "1hr":
|
|
return 1 * time.Hour
|
|
case "6hr":
|
|
return 6 * time.Hour
|
|
case "12hr":
|
|
return 12 * time.Hour
|
|
case "daily":
|
|
return 24 * time.Hour
|
|
case "disabled":
|
|
return 0 // Signal that scheduled patrol is disabled
|
|
}
|
|
}
|
|
|
|
// Fall back to custom minutes if set
|
|
// BUT: If PatrolIntervalMinutes is the old default (15), migrate to new default (360 = 6hr)
|
|
// This provides better token efficiency for existing installations
|
|
if c.PatrolIntervalMinutes > 0 {
|
|
// Migrate old 15-minute default to new 6-hour default
|
|
if c.PatrolIntervalMinutes == 15 && c.PatrolSchedulePreset == "" {
|
|
return 6 * time.Hour
|
|
}
|
|
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
|
|
}
|
|
|
|
return 6 * time.Hour // default to 6 hours
|
|
}
|
|
|
|
// PresetToMinutes converts a patrol schedule preset to minutes
|
|
func PresetToMinutes(preset string) int {
|
|
switch preset {
|
|
case "15min":
|
|
return 15
|
|
case "1hr":
|
|
return 60
|
|
case "6hr":
|
|
return 360
|
|
case "12hr":
|
|
return 720
|
|
case "daily":
|
|
return 1440
|
|
case "disabled":
|
|
return 0
|
|
default:
|
|
return 360 // default 6hr
|
|
}
|
|
}
|
|
|
|
// IsPatrolEnabled returns true if patrol should run
|
|
// Note: Patrol uses local heuristics and doesn't require an AI API key
|
|
func (c *AIConfig) IsPatrolEnabled() bool {
|
|
// If preset is "disabled", patrol is disabled
|
|
if c.PatrolSchedulePreset == "disabled" {
|
|
return false
|
|
}
|
|
return c.PatrolEnabled
|
|
}
|
|
|
|
// IsAlertTriggeredAnalysisEnabled returns true if AI should analyze resources when alerts fire
|
|
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
|
|
return c.AlertTriggeredAnalysis
|
|
}
|