package config import ( "fmt" "os" "strings" "time" "gopkg.in/yaml.v3" ) // Config represents the root configuration structure type Config struct { Site SiteConfig `yaml:"site"` Server ServerConfig `yaml:"server"` Storage StorageConfig `yaml:"storage"` Display DisplayConfig `yaml:"display"` Groups []GroupConfig `yaml:"groups"` Incidents []IncidentConfig `yaml:"incidents"` } // DisplayConfig contains display/UI settings type DisplayConfig struct { // TickMode controls how history is aggregated: ping, minute, hour, day TickMode string `yaml:"tick_mode"` // TickCount is the number of ticks/bars to display in the history TickCount int `yaml:"tick_count"` // PingFixedSlots: when true and tick_mode=ping, shows fixed slots with empty bars // when false, bars grow dynamically as pings come in PingFixedSlots bool `yaml:"ping_fixed_slots"` // Timezone for display (e.g., "UTC", "America/New_York", "Local") Timezone string `yaml:"timezone"` // Scale adjusts the overall UI scale (default: 1.0, range: 0.5-2.0) Scale float64 `yaml:"scale"` } // SiteConfig contains site metadata type SiteConfig struct { Name string `yaml:"name"` Description string `yaml:"description"` Logo string `yaml:"logo"` Favicon string `yaml:"favicon"` ThemeURL string `yaml:"theme_url"` // URL to OpenCode-compatible theme JSON CustomHead string `yaml:"custom_head"` // Custom HTML to inject into (e.g., analytics) } // ServerConfig contains HTTP server settings type ServerConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` } // StorageConfig contains database settings type StorageConfig struct { Path string `yaml:"path"` HistoryDays int `yaml:"history_days"` } // GroupConfig represents a group of monitors type GroupConfig struct { Name string `yaml:"name"` Monitors []MonitorConfig `yaml:"monitors"` DefaultCollapsed *bool `yaml:"default_collapsed"` // nil = false (expanded by default) ShowGroupUptime *bool `yaml:"show_group_uptime"` // nil = true (show by default) // Group-level defaults that apply to all monitors in the group (can be overridden per monitor) Defaults *MonitorDefaults `yaml:"defaults,omitempty"` } // MonitorDefaults contains default values that can be set at group level type MonitorDefaults struct { Interval *Duration `yaml:"interval,omitempty"` Timeout *Duration `yaml:"timeout,omitempty"` Retries *int `yaml:"retries,omitempty"` VerifySSL *bool `yaml:"verify_ssl,omitempty"` DisablePingTooltips *bool `yaml:"disable_ping_tooltips,omitempty"` DisableUptimeTooltip *bool `yaml:"disable_uptime_tooltip,omitempty"` } // MonitorConfig represents a single monitor type MonitorConfig struct { Name string `yaml:"name"` Type string `yaml:"type"` // http, https, tcp, gemini Target string `yaml:"target"` Link string `yaml:"link,omitempty"` // Custom URL for clicking the monitor name (e.g., docs page) Interval Duration `yaml:"interval"` Timeout Duration `yaml:"timeout"` Retries int `yaml:"retries,omitempty"` // Number of retry attempts before marking as down ResetOnNextCheck bool `yaml:"reset_on_next_check,omitempty"` // Wipe monitor data on next check and flip to false ExpectedStatus int `yaml:"expected_status,omitempty"` ExpectedContent string `yaml:"expected_content,omitempty"` // Expected text in response body VerifySSL *bool `yaml:"verify_ssl,omitempty"` HideSSLDays bool `yaml:"hide_ssl_days,omitempty"` // Hide SSL days left from display HidePing bool `yaml:"hide_ping,omitempty"` // Hide response time from display RoundResponseTime bool `yaml:"round_response_time,omitempty"` // Round response time to nearest second RoundUptime bool `yaml:"round_uptime,omitempty"` // Round uptime percentage (e.g., 99.99% → 100%) DisablePingTooltips bool `yaml:"disable_ping_tooltips,omitempty"` // Disable hover tooltips on ping history bars DisableUptimeTooltip bool `yaml:"disable_uptime_tooltip,omitempty"` // Disable hover tooltip on uptime percentage Method string `yaml:"method,omitempty"` UserAgent string `yaml:"user_agent,omitempty"` // Custom User-Agent header (default: "Kaze-Monitor/1.0") Headers map[string]string `yaml:"headers,omitempty"` Body string `yaml:"body,omitempty"` // ICMP specific fields PingCount int `yaml:"ping_count,omitempty"` // Number of ICMP packets to send (default: 4) // DNS specific fields DNSServer string `yaml:"dns_server,omitempty"` // DNS server to query (default: system resolver) ExpectedIPs []string `yaml:"expected_ips,omitempty"` // Expected IP addresses for DNS resolution ExpectedCNAME string `yaml:"expected_cname,omitempty"` // Expected CNAME record RecordType string `yaml:"record_type,omitempty"` // DNS record type (A, AAAA, CNAME, MX, TXT, etc.) // GraphQL specific fields GraphQLQuery string `yaml:"graphql_query,omitempty"` // GraphQL query to execute GraphQLVariables map[string]string `yaml:"graphql_variables,omitempty"` // GraphQL query variables // Database specific fields DBType string `yaml:"db_type,omitempty"` // Database type: postgres, mysql, redis, memcached, mongodb } // IncidentConfig represents an incident or maintenance type IncidentConfig struct { Title string `yaml:"title"` Status string `yaml:"status"` // scheduled, investigating, identified, monitoring, resolved Message string `yaml:"message"` ScheduledStart *time.Time `yaml:"scheduled_start,omitempty"` ScheduledEnd *time.Time `yaml:"scheduled_end,omitempty"` CreatedAt *time.Time `yaml:"created_at,omitempty"` ResolvedAt *time.Time `yaml:"resolved_at,omitempty"` AffectedMonitors []string `yaml:"affected_monitors,omitempty"` Updates []IncidentUpdate `yaml:"updates,omitempty"` } // IncidentUpdate represents an update to an incident type IncidentUpdate struct { Time time.Time `yaml:"time"` Status string `yaml:"status"` Message string `yaml:"message"` } // Duration is a wrapper around time.Duration for YAML parsing type Duration struct { time.Duration } // UnmarshalYAML implements yaml.Unmarshaler for Duration func (d *Duration) UnmarshalYAML(value *yaml.Node) error { var s string if err := value.Decode(&s); err != nil { return err } duration, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("invalid duration %q: %w", s, err) } d.Duration = duration return nil } // MarshalYAML implements yaml.Marshaler for Duration func (d Duration) MarshalYAML() (interface{}, error) { return d.Duration.String(), nil } // Load reads and parses a configuration file func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } // Apply defaults cfg.applyDefaults() // Validate configuration if err := cfg.validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return &cfg, nil } // applyDefaults sets default values for missing configuration func (c *Config) applyDefaults() { if c.Site.Name == "" { c.Site.Name = "Kaze Status" } if c.Site.Description == "" { c.Site.Description = "Service Status Page" } if c.Server.Host == "" { c.Server.Host = "0.0.0.0" } if c.Server.Port == 0 { c.Server.Port = 8080 } if c.Storage.Path == "" { c.Storage.Path = "./kaze.db" } if c.Storage.HistoryDays == 0 { c.Storage.HistoryDays = 90 } // Apply display defaults if c.Display.TickMode == "" { c.Display.TickMode = "hour" } if c.Display.TickCount == 0 { c.Display.TickCount = 45 } if c.Display.Timezone == "" { c.Display.Timezone = "Local" } if c.Display.Scale == 0 { c.Display.Scale = 1.0 } // Clamp scale to reasonable range if c.Display.Scale < 0.5 { c.Display.Scale = 0.5 } else if c.Display.Scale > 2.0 { c.Display.Scale = 2.0 } // Apply group defaults for i := range c.Groups { grp := &c.Groups[i] if grp.DefaultCollapsed == nil { defaultCollapsed := false grp.DefaultCollapsed = &defaultCollapsed } if grp.ShowGroupUptime == nil { defaultShow := true grp.ShowGroupUptime = &defaultShow } for j := range c.Groups[i].Monitors { m := &c.Groups[i].Monitors[j] // Apply group-level defaults first, then monitor-level overrides if m.Interval.Duration == 0 { if grp.Defaults != nil && grp.Defaults.Interval != nil { m.Interval = *grp.Defaults.Interval } else { m.Interval.Duration = 30 * time.Second } } if m.Timeout.Duration == 0 { if grp.Defaults != nil && grp.Defaults.Timeout != nil { m.Timeout = *grp.Defaults.Timeout } else { m.Timeout.Duration = 10 * time.Second } } // Apply group-level retries default if m.Retries == 0 && grp.Defaults != nil && grp.Defaults.Retries != nil { m.Retries = *grp.Defaults.Retries } // Retries default to 0 (no retries) if not specified if m.Retries < 0 { m.Retries = 0 } if m.Type == "http" || m.Type == "https" { if m.ExpectedStatus == 0 { m.ExpectedStatus = 200 } if m.Method == "" { m.Method = "GET" } if m.VerifySSL == nil { // Apply group default if available if grp.Defaults != nil && grp.Defaults.VerifySSL != nil { m.VerifySSL = grp.Defaults.VerifySSL } else { defaultVerify := true m.VerifySSL = &defaultVerify } } } if m.Type == "gemini" { if m.VerifySSL == nil { // Apply group default if available if grp.Defaults != nil && grp.Defaults.VerifySSL != nil { m.VerifySSL = grp.Defaults.VerifySSL } else { defaultVerify := true m.VerifySSL = &defaultVerify } } } // Apply group-level disable_ping_tooltips default if !m.DisablePingTooltips && grp.Defaults != nil && grp.Defaults.DisablePingTooltips != nil && *grp.Defaults.DisablePingTooltips { m.DisablePingTooltips = true } // Apply group-level disable_uptime_tooltip default if !m.DisableUptimeTooltip && grp.Defaults != nil && grp.Defaults.DisableUptimeTooltip != nil && *grp.Defaults.DisableUptimeTooltip { m.DisableUptimeTooltip = true } } } } // validate checks the configuration for errors func (c *Config) validate() error { if len(c.Groups) == 0 { return fmt.Errorf("at least one group with monitors is required") } for _, group := range c.Groups { if group.Name == "" { return fmt.Errorf("group name cannot be empty") } if len(group.Monitors) == 0 { return fmt.Errorf("group %q must have at least one monitor", group.Name) } for _, monitor := range group.Monitors { if monitor.Name == "" { return fmt.Errorf("monitor name cannot be empty in group %q", group.Name) } if monitor.Target == "" { return fmt.Errorf("monitor %q must have a target", monitor.Name) } switch monitor.Type { case "http", "https", "tcp", "gemini", "icmp", "dns", "graphql", "database", "db": // Valid types default: return fmt.Errorf("monitor %q has invalid type %q (must be http, https, tcp, gemini, icmp, dns, graphql, or database)", monitor.Name, monitor.Type) } // Type-specific validation if monitor.Type == "graphql" && monitor.GraphQLQuery == "" { return fmt.Errorf("monitor %q is type 'graphql' but missing required 'graphql_query' field", monitor.Name) } if (monitor.Type == "database" || monitor.Type == "db") && monitor.DBType == "" { return fmt.Errorf("monitor %q is type 'database' but missing required 'db_type' field", monitor.Name) } } } // Validate incidents for _, incident := range c.Incidents { if incident.Title == "" { return fmt.Errorf("incident title cannot be empty") } switch incident.Status { case "scheduled", "investigating", "identified", "monitoring", "resolved": // Valid statuses default: return fmt.Errorf("incident %q has invalid status %q", incident.Title, incident.Status) } } // Validate display config switch c.Display.TickMode { case "ping", "minute", "hour", "day": // Valid modes default: return fmt.Errorf("invalid tick_mode %q (must be ping, minute, hour, or day)", c.Display.TickMode) } if c.Display.TickCount < 1 || c.Display.TickCount > 200 { return fmt.Errorf("tick_count must be between 1 and 200, got %d", c.Display.TickCount) } return nil } // GetAllMonitors returns all monitors from all groups with their group names func (c *Config) GetAllMonitors() []MonitorWithGroup { var monitors []MonitorWithGroup for _, group := range c.Groups { for _, monitor := range group.Monitors { monitors = append(monitors, MonitorWithGroup{ GroupName: group.Name, Monitor: monitor, }) } } return monitors } // MonitorWithGroup pairs a monitor with its group name type MonitorWithGroup struct { GroupName string Monitor MonitorConfig } // UpdateResetFlag updates the reset_on_next_check flag for a specific monitor in the config file // Uses a line-based approach to preserve original formatting and omitted fields func UpdateResetFlag(configPath string, monitorName string, value bool) error { // Read the config file data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read config file: %w", err) } lines := strings.Split(string(data), "\n") inMonitor := false monitorIndent := "" foundMonitor := false for i := 0; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) // Check if this is the start of our target monitor if strings.HasPrefix(trimmed, "- name:") || strings.HasPrefix(trimmed, "name:") { // Extract monitor name from line namePart := strings.TrimPrefix(trimmed, "- name:") namePart = strings.TrimPrefix(namePart, "name:") namePart = strings.TrimSpace(namePart) namePart = strings.Trim(namePart, "\"'") if namePart == monitorName { inMonitor = true foundMonitor = true // Calculate the indentation level monitorIndent = line[:len(line)-len(strings.TrimLeft(line, " \t"))] continue } } // If we're in the target monitor, look for reset_on_next_check or end of monitor if inMonitor { // Check if we've left the monitor section (new monitor or less indented line) if strings.HasPrefix(trimmed, "- name:") || strings.HasPrefix(trimmed, "name:") { // We've reached another monitor without finding reset_on_next_check // Insert the line before this one if we're setting to false if !value { // Only add if setting to false (after a reset) insertLine := monitorIndent + " reset_on_next_check: false" lines = append(lines[:i], append([]string{insertLine}, lines[i:]...)...) } break } // Check if this line is reset_on_next_check if strings.Contains(trimmed, "reset_on_next_check:") { // Update the value if !value { // Set to false after reset lines[i] = monitorIndent + " reset_on_next_check: false" } else { // Keep as true (should already be true, but update to be sure) lines[i] = monitorIndent + " reset_on_next_check: true" } break } } } if !foundMonitor { return fmt.Errorf("monitor %q not found in config", monitorName) } // Write back to file newData := []byte(strings.Join(lines, "\n")) if err := os.WriteFile(configPath, newData, 0644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil }