aboutsummaryrefslogtreecommitdiff
path: root/internal/config/config.go
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-17 23:17:49 -0800
committerFuwn <[email protected]>2026-01-17 23:17:49 -0800
commit4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch)
treee7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/config/config.go
downloadkaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz
kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip
feat: Initial commit
Diffstat (limited to 'internal/config/config.go')
-rw-r--r--internal/config/config.go299
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
+}