package config import ( "fmt" "net/url" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // Config represents the root configuration structure type Config struct { Imports []string `yaml:"imports,omitempty"` Site SiteConfig `yaml:"site"` Server ServerConfig `yaml:"server"` Storage StorageConfig `yaml:"storage"` Display DisplayConfig `yaml:"display"` API APIConfig `yaml:"api"` Groups []GroupConfig `yaml:"groups"` Incidents []IncidentConfig `yaml:"incidents"` } // APIConfig contains settings for the JSON API endpoints type APIConfig struct { // Access controls who can access /api/* endpoints: // "public" - Anyone can access (default) // "private" - API endpoints are disabled (return 403) // "authenticated" - Requires API key via X-API-Key header or ?api_key= query param Access string `yaml:"access"` // Keys is a list of valid API keys (only used when access is "authenticated") Keys []string `yaml:"keys"` } // 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"` // RefreshMode controls how the page updates: // "page" - Full page refresh via meta refresh (default) // "api" - Fetch updates via API without page reload RefreshMode string `yaml:"refresh_mode"` // RefreshInterval is how often to refresh in seconds (default: 30) RefreshInterval int `yaml:"refresh_interval"` } // 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"` URL string `yaml:"url,omitempty"` // libsql:// or file:// URL (overrides path, supports $ENV_VAR) HistoryDays int `yaml:"history_days"` Maintenance MaintenanceConfig `yaml:"maintenance,omitempty"` } func (s *StorageConfig) GetDatabaseURL() string { url := s.URL if url == "" { url = s.Path } return os.ExpandEnv(url) } // MaintenanceConfig controls database maintenance/pruning behavior type MaintenanceConfig struct { // Mode: "never" (default), "backup" (rename with epoch suffix), "reset" (delete in place) Mode string `yaml:"mode,omitempty"` Triggers MaintenanceTriggers `yaml:"triggers,omitempty"` } // MaintenanceTriggers defines when maintenance should occur (OR'd together) type MaintenanceTriggers struct { Size string `yaml:"size,omitempty"` // e.g., "100MB", "1GB" Checks int64 `yaml:"checks,omitempty"` // total check count threshold Cron string `yaml:"cron,omitempty"` // cron expression, e.g., "0 3 * * 0" Daily string `yaml:"daily,omitempty"` // daily time, e.g., "03:00" } // 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"` HideSSLDays *bool `yaml:"hide_ssl_days,omitempty"` HidePing *bool `yaml:"hide_ping,omitempty"` RoundResponseTime *bool `yaml:"round_response_time,omitempty"` RoundUptime *bool `yaml:"round_uptime,omitempty"` DisablePingTooltips *bool `yaml:"disable_ping_tooltips,omitempty"` DisableUptimeTooltip *bool `yaml:"disable_uptime_tooltip,omitempty"` Method string `yaml:"method,omitempty"` UserAgent string `yaml:"user_agent,omitempty"` Headers map[string]string `yaml:"headers,omitempty"` ExpectedStatus *int `yaml:"expected_status,omitempty"` ExpectedContent string `yaml:"expected_content,omitempty"` Body string `yaml:"body,omitempty"` // ICMP specific PingCount *int `yaml:"ping_count,omitempty"` // DNS specific DNSServer string `yaml:"dns_server,omitempty"` RecordType string `yaml:"record_type,omitempty"` // GraphQL specific GraphQLQuery string `yaml:"graphql_query,omitempty"` GraphQLVariables map[string]string `yaml:"graphql_variables,omitempty"` // Database specific DBType string `yaml:"db_type,omitempty"` } // MonitorConfig represents a single monitor type MonitorConfig struct { Name string `yaml:"name"` Group string `yaml:"-"` // Set at runtime, not from YAML - the group this monitor belongs to 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 } // ID returns the unique identifier for this monitor (group/name format) // Both group and name are URL-encoded to handle special characters like '/' func (m *MonitorConfig) ID() string { if m.Group == "" { return url.PathEscape(m.Name) } return url.PathEscape(m.Group) + "/" + url.PathEscape(m.Name) } // ParseMonitorID splits a monitor ID back into group and name components // Returns (group, name, ok) where ok is false if the ID format is invalid func ParseMonitorID(id string) (group, name string, ok bool) { // Find the separator (first unescaped '/') idx := strings.Index(id, "/") if idx == -1 { // No group, just a name decoded, err := url.PathUnescape(id) if err != nil { return "", "", false } return "", decoded, true } groupPart := id[:idx] namePart := id[idx+1:] decodedGroup, err := url.PathUnescape(groupPart) if err != nil { return "", "", false } decodedName, err := url.PathUnescape(namePart) if err != nil { return "", "", false } return decodedGroup, decodedName, true } // 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) { visited := make(map[string]bool) return loadWithImports(path, visited) } func loadWithImports(path string, visited map[string]bool) (*Config, error) { absPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("failed to resolve path %q: %w", path, err) } if visited[absPath] { return nil, fmt.Errorf("circular import detected: %q", path) } visited[absPath] = true 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) } if len(cfg.Imports) > 0 { baseDir := filepath.Dir(absPath) if err := cfg.processImports(baseDir, visited); err != nil { return nil, err } } cfg.applyDefaults() if err := cfg.validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return &cfg, nil } func (c *Config) processImports(baseDir string, visited map[string]bool) error { for _, importPath := range c.Imports { fullPath := importPath if !filepath.IsAbs(importPath) { fullPath = filepath.Join(baseDir, importPath) } imported, err := loadWithImports(fullPath, visited) if err != nil { return fmt.Errorf("failed to import %q: %w", importPath, err) } c.mergeImported(imported) } return nil } func (c *Config) mergeImported(imported *Config) { groupIndex := make(map[string]int) for i, g := range c.Groups { groupIndex[g.Name] = i } for _, importedGroup := range imported.Groups { if idx, exists := groupIndex[importedGroup.Name]; exists { c.Groups[idx] = mergeGroups(c.Groups[idx], importedGroup) } else { c.Groups = append(c.Groups, importedGroup) groupIndex[importedGroup.Name] = len(c.Groups) - 1 } } incidentSet := make(map[string]bool) for _, inc := range c.Incidents { incidentSet[inc.Title] = true } for _, inc := range imported.Incidents { if !incidentSet[inc.Title] { c.Incidents = append(c.Incidents, inc) incidentSet[inc.Title] = true } } } func mergeGroups(local, imported GroupConfig) GroupConfig { monitorIndex := make(map[string]int) for i, m := range local.Monitors { monitorIndex[m.Name] = i } for _, importedMon := range imported.Monitors { if _, exists := monitorIndex[importedMon.Name]; !exists { local.Monitors = append(local.Monitors, importedMon) monitorIndex[importedMon.Name] = len(local.Monitors) - 1 } } return local } // 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 } if c.Display.RefreshMode == "" { c.Display.RefreshMode = "page" } if c.Display.RefreshInterval == 0 { c.Display.RefreshInterval = 30 } // Apply API defaults if c.API.Access == "" { c.API.Access = "public" } // 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] // Set the group name on the monitor m.Group = grp.Name // 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 boolean defaults (only if not explicitly set on monitor) if grp.Defaults != nil { if !m.HideSSLDays && grp.Defaults.HideSSLDays != nil && *grp.Defaults.HideSSLDays { m.HideSSLDays = true } if !m.HidePing && grp.Defaults.HidePing != nil && *grp.Defaults.HidePing { m.HidePing = true } if !m.RoundResponseTime && grp.Defaults.RoundResponseTime != nil && *grp.Defaults.RoundResponseTime { m.RoundResponseTime = true } if !m.RoundUptime && grp.Defaults.RoundUptime != nil && *grp.Defaults.RoundUptime { m.RoundUptime = true } if !m.DisablePingTooltips && grp.Defaults.DisablePingTooltips != nil && *grp.Defaults.DisablePingTooltips { m.DisablePingTooltips = true } if !m.DisableUptimeTooltip && grp.Defaults.DisableUptimeTooltip != nil && *grp.Defaults.DisableUptimeTooltip { m.DisableUptimeTooltip = true } // Apply string defaults (only if not set on monitor) if m.Method == "" && grp.Defaults.Method != "" { m.Method = grp.Defaults.Method } if m.UserAgent == "" && grp.Defaults.UserAgent != "" { m.UserAgent = grp.Defaults.UserAgent } // Apply headers (merge, monitor headers take precedence) if len(grp.Defaults.Headers) > 0 { if m.Headers == nil { m.Headers = make(map[string]string) } for k, v := range grp.Defaults.Headers { if _, exists := m.Headers[k]; !exists { m.Headers[k] = v } } } // Apply expected_status default if m.ExpectedStatus == 0 && grp.Defaults.ExpectedStatus != nil { m.ExpectedStatus = *grp.Defaults.ExpectedStatus } // Apply expected_content default if m.ExpectedContent == "" && grp.Defaults.ExpectedContent != "" { m.ExpectedContent = grp.Defaults.ExpectedContent } // Apply body default if m.Body == "" && grp.Defaults.Body != "" { m.Body = grp.Defaults.Body } // Apply ICMP ping_count default if m.PingCount == 0 && grp.Defaults.PingCount != nil { m.PingCount = *grp.Defaults.PingCount } // Apply DNS defaults if m.DNSServer == "" && grp.Defaults.DNSServer != "" { m.DNSServer = grp.Defaults.DNSServer } if m.RecordType == "" && grp.Defaults.RecordType != "" { m.RecordType = grp.Defaults.RecordType } // Apply GraphQL defaults if m.GraphQLQuery == "" && grp.Defaults.GraphQLQuery != "" { m.GraphQLQuery = grp.Defaults.GraphQLQuery } // Apply GraphQL variables (merge, monitor variables take precedence) if len(grp.Defaults.GraphQLVariables) > 0 { if m.GraphQLVariables == nil { m.GraphQLVariables = make(map[string]string) } for k, v := range grp.Defaults.GraphQLVariables { if _, exists := m.GraphQLVariables[k]; !exists { m.GraphQLVariables[k] = v } } } // Apply database type default if m.DBType == "" && grp.Defaults.DBType != "" { m.DBType = grp.Defaults.DBType } } } } } // 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) } // Validate API config switch c.API.Access { case "", "public", "private", "authenticated": // Valid modes ("" defaults to "public") default: return fmt.Errorf("invalid api.access %q (must be public, private, or authenticated)", c.API.Access) } if c.API.Access == "authenticated" && len(c.API.Keys) == 0 { return fmt.Errorf("api.access is 'authenticated' but no api.keys provided") } switch c.Display.RefreshMode { case "page", "api", "stream": default: return fmt.Errorf("invalid display.refresh_mode %q (must be page, api, or stream)", c.Display.RefreshMode) } if c.Display.RefreshInterval < 5 { return fmt.Errorf("display.refresh_interval must be at least 5 seconds, got %d", c.Display.RefreshInterval) } 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 // groupName and monitorName are used to find the correct monitor in the YAML structure func UpdateResetFlag(configPath string, groupName 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") inTargetGroup := false inMonitor := false monitorIndent := "" foundMonitor := false for i := 0; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) // Check if this is a group name line if strings.HasPrefix(trimmed, "- name:") && !inMonitor { // Could be a group name - check if it matches our target group namePart := strings.TrimPrefix(trimmed, "- name:") namePart = strings.TrimSpace(namePart) namePart = strings.Trim(namePart, "\"'") // Check if this is our target group if namePart == groupName { inTargetGroup = true } else if inTargetGroup { // We've moved to a different group, stop looking break } continue } // Check if this is the start of our target monitor (within the target group) if inTargetGroup && (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 in group %q not found in config", monitorName, groupName) } // 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 }