diff options
| author | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
| commit | 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch) | |
| tree | e7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/config/config.go | |
| download | kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip | |
feat: Initial commit
Diffstat (limited to 'internal/config/config.go')
| -rw-r--r-- | internal/config/config.go | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d4e096f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,299 @@ +package config + +import ( + "fmt" + "os" + "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"` + // ShowThemeToggle controls whether to show the theme toggle button (defaults to true) + ShowThemeToggle *bool `yaml:"show_theme_toggle"` +} + +// SiteConfig contains site metadata +type SiteConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Logo string `yaml:"logo"` + Favicon string `yaml:"favicon"` +} + +// 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) +} + +// MonitorConfig represents a single monitor +type MonitorConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // http, https, tcp + Target string `yaml:"target"` + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` + ExpectedStatus int `yaml:"expected_status,omitempty"` + VerifySSL *bool `yaml:"verify_ssl,omitempty"` + Method string `yaml:"method,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body string `yaml:"body,omitempty"` +} + +// 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.ShowThemeToggle == nil { + defaultShow := true + c.Display.ShowThemeToggle = &defaultShow + } + + // 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] + if m.Interval.Duration == 0 { + m.Interval.Duration = 30 * time.Second + } + if m.Timeout.Duration == 0 { + m.Timeout.Duration = 10 * time.Second + } + if m.Type == "http" || m.Type == "https" { + if m.ExpectedStatus == 0 { + m.ExpectedStatus = 200 + } + if m.Method == "" { + m.Method = "GET" + } + if m.VerifySSL == nil { + defaultVerify := true + m.VerifySSL = &defaultVerify + } + } + } + } +} + +// 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") + } + + monitorNames := make(map[string]bool) + 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 monitorNames[monitor.Name] { + return fmt.Errorf("duplicate monitor name: %q", monitor.Name) + } + monitorNames[monitor.Name] = true + + if monitor.Target == "" { + return fmt.Errorf("monitor %q must have a target", monitor.Name) + } + + switch monitor.Type { + case "http", "https", "tcp": + // Valid types + default: + return fmt.Errorf("monitor %q has invalid type %q (must be http, https, or tcp)", monitor.Name, monitor.Type) + } + } + } + + // 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 +} |