mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Add deterministic URL suggestion based on service type and external IP: - Add SuggestedURL field to ResourceDiscovery type (Go + TypeScript) - Create url_suggestion.go with 60+ service defaults (Jellyfin, Plex, Home Assistant, Grafana, Proxmox, etc.) - Support HTTPS services, custom paths (/web, /dashboard/, /admin) - Fall back to discovered ports for unknown services - Add UI in DiscoveryTab with "Use this" button to populate URL input - Add comprehensive unit tests for URL suggestion logic Suggestion only appears when no custom URL is saved. User clicks "Use this" to populate the input, then "Save" to confirm.
316 lines
14 KiB
Go
316 lines
14 KiB
Go
// Package discovery provides AI-powered infrastructure discovery capabilities.
|
|
// It discovers services, versions, configurations, and CLI access methods
|
|
// for VMs, LXCs, Docker containers, Kubernetes pods, and hosts.
|
|
package servicediscovery
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ResourceType identifies the type of infrastructure resource.
|
|
type ResourceType string
|
|
|
|
const (
|
|
ResourceTypeVM ResourceType = "vm"
|
|
ResourceTypeLXC ResourceType = "lxc"
|
|
ResourceTypeDocker ResourceType = "docker"
|
|
ResourceTypeK8s ResourceType = "k8s"
|
|
ResourceTypeHost ResourceType = "host"
|
|
ResourceTypeDockerVM ResourceType = "docker_vm" // Docker on a VM
|
|
ResourceTypeDockerLXC ResourceType = "docker_lxc" // Docker in an LXC
|
|
)
|
|
|
|
// FactCategory categorizes discovery facts.
|
|
type FactCategory string
|
|
|
|
const (
|
|
FactCategoryVersion FactCategory = "version"
|
|
FactCategoryConfig FactCategory = "config"
|
|
FactCategoryService FactCategory = "service"
|
|
FactCategoryPort FactCategory = "port"
|
|
FactCategoryHardware FactCategory = "hardware"
|
|
FactCategoryNetwork FactCategory = "network"
|
|
FactCategoryStorage FactCategory = "storage"
|
|
FactCategoryDependency FactCategory = "dependency"
|
|
FactCategorySecurity FactCategory = "security"
|
|
)
|
|
|
|
// ServiceCategory categorizes the type of service discovered.
|
|
type ServiceCategory string
|
|
|
|
const (
|
|
CategoryDatabase ServiceCategory = "database"
|
|
CategoryWebServer ServiceCategory = "web_server"
|
|
CategoryCache ServiceCategory = "cache"
|
|
CategoryMessageQueue ServiceCategory = "message_queue"
|
|
CategoryMonitoring ServiceCategory = "monitoring"
|
|
CategoryBackup ServiceCategory = "backup"
|
|
CategoryNVR ServiceCategory = "nvr"
|
|
CategoryStorage ServiceCategory = "storage"
|
|
CategoryContainer ServiceCategory = "container"
|
|
CategoryVirtualizer ServiceCategory = "virtualizer"
|
|
CategoryNetwork ServiceCategory = "network"
|
|
CategorySecurity ServiceCategory = "security"
|
|
CategoryMedia ServiceCategory = "media"
|
|
CategoryHomeAuto ServiceCategory = "home_automation"
|
|
CategoryUnknown ServiceCategory = "unknown"
|
|
)
|
|
|
|
// ResourceDiscovery is the main data model for discovered resource information.
|
|
type ResourceDiscovery struct {
|
|
// Identity
|
|
ID string `json:"id"` // Unique ID: "lxc:minipc:101"
|
|
ResourceType ResourceType `json:"resource_type"` // vm, lxc, docker, k8s, host
|
|
ResourceID string `json:"resource_id"` // 101, container-name, etc.
|
|
HostID string `json:"host_id"` // Proxmox node name or host agent ID
|
|
Hostname string `json:"hostname"` // Human-readable host name
|
|
|
|
// AI-discovered info
|
|
ServiceType string `json:"service_type"` // frigate, postgres, pbs
|
|
ServiceName string `json:"service_name"` // Human-readable name
|
|
ServiceVersion string `json:"service_version"` // v0.13.2
|
|
Category ServiceCategory `json:"category"` // nvr, database, backup
|
|
CLIAccess string `json:"cli_access"` // pct exec 101 -- ...
|
|
|
|
// Deep discovery facts
|
|
Facts []DiscoveryFact `json:"facts"`
|
|
ConfigPaths []string `json:"config_paths"`
|
|
DataPaths []string `json:"data_paths"`
|
|
LogPaths []string `json:"log_paths"`
|
|
Ports []PortInfo `json:"ports"`
|
|
DockerMounts []DockerBindMount `json:"docker_mounts,omitempty"` // Docker container bind mounts (source->dest)
|
|
|
|
// User-added (also encrypted)
|
|
UserNotes string `json:"user_notes"`
|
|
UserSecrets map[string]string `json:"user_secrets"` // tokens, creds
|
|
|
|
// Metadata
|
|
Confidence float64 `json:"confidence"` // 0-1 confidence score
|
|
AIReasoning string `json:"ai_reasoning"` // AI explanation
|
|
DiscoveredAt time.Time `json:"discovered_at"` // First discovery
|
|
UpdatedAt time.Time `json:"updated_at"` // Last update
|
|
ScanDuration int64 `json:"scan_duration"` // Scan duration in ms
|
|
|
|
// Fingerprint tracking for just-in-time discovery
|
|
Fingerprint string `json:"fingerprint,omitempty"` // Hash when discovery was done
|
|
FingerprintedAt time.Time `json:"fingerprinted_at,omitempty"` // When fingerprint was captured
|
|
FingerprintSchemaVersion int `json:"fingerprint_schema_version,omitempty"` // Schema version when fingerprint was captured
|
|
CLIAccessVersion int `json:"cli_access_version,omitempty"` // Version of CLI access pattern format
|
|
|
|
// Raw data for debugging/re-analysis
|
|
RawCommandOutput map[string]string `json:"raw_command_output,omitempty"`
|
|
|
|
// Auto-suggested web interface URL based on service type and discovered ports
|
|
SuggestedURL string `json:"suggested_url,omitempty"`
|
|
}
|
|
|
|
// DiscoveryFact represents a single discovered fact about a resource.
|
|
type DiscoveryFact struct {
|
|
Category FactCategory `json:"category"` // version, config, service, port
|
|
Key string `json:"key"` // e.g., "coral_tpu", "mqtt_broker"
|
|
Value string `json:"value"` // e.g., "/dev/apex_0", "mosquitto:1883"
|
|
Source string `json:"source"` // command that found this
|
|
Confidence float64 `json:"confidence"` // 0-1 confidence for this fact
|
|
DiscoveredAt time.Time `json:"discovered_at"`
|
|
}
|
|
|
|
// PortInfo represents information about a listening port.
|
|
type PortInfo struct {
|
|
Port int `json:"port"`
|
|
Protocol string `json:"protocol"` // tcp, udp
|
|
Process string `json:"process"` // process name
|
|
Address string `json:"address"` // bind address
|
|
}
|
|
|
|
// DockerBindMount represents a Docker bind mount with source and destination paths.
|
|
// This is critical for knowing where to actually edit files - the source path on the
|
|
// host filesystem, not the destination path inside the container.
|
|
type DockerBindMount struct {
|
|
ContainerName string `json:"container_name"` // Docker container name
|
|
Source string `json:"source"` // Host path (where to actually write files)
|
|
Destination string `json:"destination"` // Container path (what the service sees)
|
|
Type string `json:"type,omitempty"` // Mount type: bind, volume, tmpfs
|
|
ReadOnly bool `json:"read_only,omitempty"` // Whether mount is read-only
|
|
}
|
|
|
|
// MakeResourceID creates a standardized resource ID.
|
|
func MakeResourceID(resourceType ResourceType, hostID, resourceID string) string {
|
|
return fmt.Sprintf("%s:%s:%s", resourceType, hostID, resourceID)
|
|
}
|
|
|
|
// ParseResourceID parses a resource ID into its components.
|
|
func ParseResourceID(id string) (resourceType ResourceType, hostID, resourceID string, err error) {
|
|
var parts [3]string
|
|
count := 0
|
|
start := 0
|
|
for i, c := range id {
|
|
if c == ':' {
|
|
if count < 2 {
|
|
parts[count] = id[start:i]
|
|
count++
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
if count == 2 {
|
|
parts[2] = id[start:]
|
|
return ResourceType(parts[0]), parts[1], parts[2], nil
|
|
}
|
|
return "", "", "", fmt.Errorf("invalid resource ID format: %s", id)
|
|
}
|
|
|
|
// DiscoveryRequest represents a request to discover a resource.
|
|
type DiscoveryRequest struct {
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID string `json:"resource_id"`
|
|
HostID string `json:"host_id"`
|
|
Hostname string `json:"hostname"`
|
|
Force bool `json:"force"` // Force re-scan even if recent
|
|
}
|
|
|
|
// DiscoveryStatus represents the status of a discovery scan.
|
|
type DiscoveryStatus string
|
|
|
|
const (
|
|
DiscoveryStatusPending DiscoveryStatus = "pending"
|
|
DiscoveryStatusRunning DiscoveryStatus = "running"
|
|
DiscoveryStatusCompleted DiscoveryStatus = "completed"
|
|
DiscoveryStatusFailed DiscoveryStatus = "failed"
|
|
DiscoveryStatusNotStarted DiscoveryStatus = "not_started"
|
|
)
|
|
|
|
// DiscoveryProgress represents the progress of an ongoing discovery.
|
|
type DiscoveryProgress struct {
|
|
ResourceID string `json:"resource_id"`
|
|
Status DiscoveryStatus `json:"status"`
|
|
CurrentStep string `json:"current_step"`
|
|
CurrentCommand string `json:"current_command,omitempty"`
|
|
TotalSteps int `json:"total_steps"`
|
|
CompletedSteps int `json:"completed_steps"`
|
|
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
|
|
PercentComplete float64 `json:"percent_complete,omitempty"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// AIProviderInfo describes the AI provider being used for discovery analysis.
|
|
type AIProviderInfo struct {
|
|
Provider string `json:"provider"` // e.g., "anthropic", "openai", "ollama"
|
|
Model string `json:"model"` // e.g., "claude-haiku-4-5", "gpt-4o"
|
|
IsLocal bool `json:"is_local"` // true for ollama (local models)
|
|
Label string `json:"label"` // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
|
|
}
|
|
|
|
// DiscoveryInfo provides metadata about the discovery system configuration.
|
|
type DiscoveryInfo struct {
|
|
AIProvider *AIProviderInfo `json:"ai_provider,omitempty"` // Current AI provider info
|
|
Commands []DiscoveryCommand `json:"commands,omitempty"` // Commands that will be run
|
|
CommandCategories []string `json:"command_categories,omitempty"` // Unique categories of commands
|
|
}
|
|
|
|
// UpdateNotesRequest represents a request to update user notes.
|
|
type UpdateNotesRequest struct {
|
|
UserNotes string `json:"user_notes"`
|
|
UserSecrets map[string]string `json:"user_secrets,omitempty"`
|
|
}
|
|
|
|
// DiscoverySummary provides a summary of discoveries for listing.
|
|
type DiscoverySummary struct {
|
|
ID string `json:"id"`
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID string `json:"resource_id"`
|
|
HostID string `json:"host_id"`
|
|
Hostname string `json:"hostname"`
|
|
ServiceType string `json:"service_type"`
|
|
ServiceName string `json:"service_name"`
|
|
ServiceVersion string `json:"service_version"`
|
|
Category ServiceCategory `json:"category"`
|
|
Confidence float64 `json:"confidence"`
|
|
HasUserNotes bool `json:"has_user_notes"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Fingerprint string `json:"fingerprint,omitempty"` // Current fingerprint
|
|
NeedsDiscovery bool `json:"needs_discovery"` // True if fingerprint changed
|
|
}
|
|
|
|
// ToSummary converts a full discovery to a summary.
|
|
func (d *ResourceDiscovery) ToSummary() DiscoverySummary {
|
|
return DiscoverySummary{
|
|
ID: d.ID,
|
|
ResourceType: d.ResourceType,
|
|
ResourceID: d.ResourceID,
|
|
HostID: d.HostID,
|
|
Hostname: d.Hostname,
|
|
ServiceType: d.ServiceType,
|
|
ServiceName: d.ServiceName,
|
|
ServiceVersion: d.ServiceVersion,
|
|
Category: d.Category,
|
|
Confidence: d.Confidence,
|
|
HasUserNotes: d.UserNotes != "",
|
|
UpdatedAt: d.UpdatedAt,
|
|
Fingerprint: d.Fingerprint,
|
|
NeedsDiscovery: false, // Will be set by caller if fingerprint changed
|
|
}
|
|
}
|
|
|
|
// AIAnalysisRequest is sent to the AI for analysis.
|
|
type AIAnalysisRequest struct {
|
|
ResourceType ResourceType `json:"resource_type"`
|
|
ResourceID string `json:"resource_id"`
|
|
HostID string `json:"host_id"`
|
|
Hostname string `json:"hostname"`
|
|
CommandOutputs map[string]string `json:"command_outputs"`
|
|
ExistingFacts []DiscoveryFact `json:"existing_facts,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"` // Image, labels, etc.
|
|
}
|
|
|
|
// AIAnalysisResponse is returned by the AI.
|
|
type AIAnalysisResponse struct {
|
|
ServiceType string `json:"service_type"`
|
|
ServiceName string `json:"service_name"`
|
|
ServiceVersion string `json:"service_version"`
|
|
Category ServiceCategory `json:"category"`
|
|
CLIAccess string `json:"cli_access"`
|
|
Facts []DiscoveryFact `json:"facts"`
|
|
ConfigPaths []string `json:"config_paths"`
|
|
DataPaths []string `json:"data_paths"`
|
|
LogPaths []string `json:"log_paths"`
|
|
Ports []PortInfo `json:"ports"`
|
|
Confidence float64 `json:"confidence"`
|
|
Reasoning string `json:"reasoning"`
|
|
}
|
|
|
|
// ContainerFingerprint captures the key metadata that indicates a container changed.
|
|
// This is used for just-in-time discovery - only running discovery when something
|
|
// actually changed rather than on a fixed timer.
|
|
// FingerprintSchemaVersion is incremented when the fingerprint algorithm changes.
|
|
// This prevents mass rediscovery when we add new fields to the fingerprint hash.
|
|
// Old fingerprints with different schema versions are treated as "schema changed"
|
|
// rather than "container changed", allowing for more controlled migration.
|
|
const FingerprintSchemaVersion = 3 // v3: Removed IP addresses (DHCP churn caused false positives)
|
|
|
|
// CLIAccessVersion is incremented when the CLI access pattern format changes.
|
|
// When a discovery has an older version, its CLIAccess field is regenerated
|
|
// to use the new instructional format.
|
|
const CLIAccessVersion = 2 // v2: Changed from shell commands to pulse_control instructions
|
|
|
|
type ContainerFingerprint struct {
|
|
ResourceID string `json:"resource_id"`
|
|
HostID string `json:"host_id"`
|
|
Hash string `json:"hash"` // SHA256 of metadata (truncated to 16 chars)
|
|
SchemaVersion int `json:"schema_version"` // Version of fingerprint algorithm
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
|
|
// Components that went into the hash (for debugging)
|
|
ImageID string `json:"image_id,omitempty"`
|
|
ImageName string `json:"image_name,omitempty"`
|
|
Ports []string `json:"ports,omitempty"`
|
|
MountPaths []string `json:"mount_paths,omitempty"`
|
|
EnvKeys []string `json:"env_keys,omitempty"` // Keys only, not values (security)
|
|
CreatedAt string `json:"created_at,omitempty"` // Container creation time
|
|
}
|
|
|
|
// IsSchemaOutdated returns true if this fingerprint was created with an older schema.
|
|
func (fp *ContainerFingerprint) IsSchemaOutdated() bool {
|
|
return fp.SchemaVersion < FingerprintSchemaVersion
|
|
}
|