From 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 17 Jan 2026 23:17:49 -0800 Subject: feat: Initial commit --- internal/config/config.go | 299 +++++++++++++ internal/monitor/http.go | 182 ++++++++ internal/monitor/monitor.go | 86 ++++ internal/monitor/scheduler.go | 182 ++++++++ internal/monitor/tcp.go | 89 ++++ internal/server/server.go | 839 +++++++++++++++++++++++++++++++++++ internal/server/static/style.css | 417 +++++++++++++++++ internal/server/templates/index.html | 357 +++++++++++++++ internal/storage/sqlite.go | 679 ++++++++++++++++++++++++++++ 9 files changed, 3130 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/monitor/http.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/monitor/scheduler.go create mode 100644 internal/monitor/tcp.go create mode 100644 internal/server/server.go create mode 100644 internal/server/static/style.css create mode 100644 internal/server/templates/index.html create mode 100644 internal/storage/sqlite.go (limited to 'internal') 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 +} diff --git a/internal/monitor/http.go b/internal/monitor/http.go new file mode 100644 index 0000000..8432401 --- /dev/null +++ b/internal/monitor/http.go @@ -0,0 +1,182 @@ +package monitor + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// HTTPMonitor monitors HTTP and HTTPS endpoints +type HTTPMonitor struct { + name string + monitorType string + target string + interval time.Duration + timeout time.Duration + method string + headers map[string]string + body string + expectedStatus int + verifySSL bool + client *http.Client +} + +// NewHTTPMonitor creates a new HTTP/HTTPS monitor +func NewHTTPMonitor(cfg config.MonitorConfig) (*HTTPMonitor, error) { + // Validate target URL + target := cfg.Target + if cfg.Type == "https" && !strings.HasPrefix(target, "https://") { + if strings.HasPrefix(target, "http://") { + target = strings.Replace(target, "http://", "https://", 1) + } else { + target = "https://" + target + } + } else if cfg.Type == "http" && !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + target = "http://" + target + } + + verifySSL := true + if cfg.VerifySSL != nil { + verifySSL = *cfg.VerifySSL + } + + // Create HTTP client with custom transport + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !verifySSL, + }, + DialContext: (&net.Dialer{ + Timeout: cfg.Timeout.Duration, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: cfg.Timeout.Duration, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } + + client := &http.Client{ + Transport: transport, + Timeout: cfg.Timeout.Duration, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + return &HTTPMonitor{ + name: cfg.Name, + monitorType: cfg.Type, + target: target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + method: cfg.Method, + headers: cfg.Headers, + body: cfg.Body, + expectedStatus: cfg.ExpectedStatus, + verifySSL: verifySSL, + client: client, + }, nil +} + +// Name returns the monitor's name +func (m *HTTPMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *HTTPMonitor) Type() string { + return m.monitorType +} + +// Target returns the monitor target +func (m *HTTPMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *HTTPMonitor) Interval() time.Duration { + return m.interval +} + +// Check performs the HTTP/HTTPS check +func (m *HTTPMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Create request + var bodyReader io.Reader + if m.body != "" { + bodyReader = strings.NewReader(m.body) + } + + req, err := http.NewRequestWithContext(ctx, m.method, m.target, bodyReader) + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("failed to create request: %w", err) + return result + } + + // Set headers + req.Header.Set("User-Agent", "Kaze-Monitor/1.0") + for key, value := range m.headers { + req.Header.Set(key, value) + } + + // Perform request and measure response time + start := time.Now() + resp, err := m.client.Do(req) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("request failed: %w", err) + return result + } + defer resp.Body.Close() + + // Discard body to allow connection reuse + io.Copy(io.Discard, resp.Body) + + result.StatusCode = resp.StatusCode + + // Check SSL certificate for HTTPS + if m.monitorType == "https" && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + cert := resp.TLS.PeerCertificates[0] + result.SSLExpiry = &cert.NotAfter + result.SSLDaysLeft = int(time.Until(cert.NotAfter).Hours() / 24) + } + + // Determine status based on response code + if resp.StatusCode == m.expectedStatus { + result.Status = StatusUp + } else if resp.StatusCode >= 200 && resp.StatusCode < 400 { + // Got a success code but not the expected one + result.Status = StatusDegraded + result.Error = fmt.Errorf("unexpected status code: got %d, expected %d", resp.StatusCode, m.expectedStatus) + } else { + result.Status = StatusDown + result.Error = fmt.Errorf("bad status code: %d", resp.StatusCode) + } + + // Check for slow response (degraded if > 2 seconds) + if result.Status == StatusUp && result.ResponseTime > 2*time.Second { + result.Status = StatusDegraded + result.Error = fmt.Errorf("slow response: %v", result.ResponseTime) + } + + return result +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..4f4ab0f --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,86 @@ +package monitor + +import ( + "context" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/storage" +) + +// Result represents the outcome of a monitor check +type Result struct { + MonitorName string + Timestamp time.Time + Status Status + ResponseTime time.Duration + StatusCode int // HTTP status code (0 for non-HTTP) + Error error + SSLExpiry *time.Time + SSLDaysLeft int +} + +// Status represents the status of a monitor +type Status string + +const ( + StatusUp Status = "up" + StatusDown Status = "down" + StatusDegraded Status = "degraded" +) + +// Monitor is the interface that all monitor types must implement +type Monitor interface { + // Name returns the monitor's name + Name() string + + // Type returns the monitor type (http, https, tcp) + Type() string + + // Target returns the monitor target (URL or host:port) + Target() string + + // Interval returns the check interval + Interval() time.Duration + + // Check performs the monitoring check and returns the result + Check(ctx context.Context) *Result +} + +// New creates a new monitor based on the configuration +func New(cfg config.MonitorConfig) (Monitor, error) { + switch cfg.Type { + case "http", "https": + return NewHTTPMonitor(cfg) + case "tcp": + return NewTCPMonitor(cfg) + default: + return nil, &UnsupportedTypeError{Type: cfg.Type} + } +} + +// UnsupportedTypeError is returned when an unknown monitor type is specified +type UnsupportedTypeError struct { + Type string +} + +func (e *UnsupportedTypeError) Error() string { + return "unsupported monitor type: " + e.Type +} + +// ToCheckResult converts a monitor Result to a storage CheckResult +func (r *Result) ToCheckResult() *storage.CheckResult { + cr := &storage.CheckResult{ + MonitorName: r.MonitorName, + Timestamp: r.Timestamp, + Status: string(r.Status), + ResponseTime: r.ResponseTime.Milliseconds(), + StatusCode: r.StatusCode, + SSLExpiry: r.SSLExpiry, + SSLDaysLeft: r.SSLDaysLeft, + } + if r.Error != nil { + cr.Error = r.Error.Error() + } + return cr +} diff --git a/internal/monitor/scheduler.go b/internal/monitor/scheduler.go new file mode 100644 index 0000000..7a06131 --- /dev/null +++ b/internal/monitor/scheduler.go @@ -0,0 +1,182 @@ +package monitor + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/storage" +) + +// Scheduler manages and runs all monitors +type Scheduler struct { + monitors []Monitor + storage *storage.Storage + logger *slog.Logger + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +// NewScheduler creates a new monitor scheduler +func NewScheduler(cfg *config.Config, store *storage.Storage, logger *slog.Logger) (*Scheduler, error) { + ctx, cancel := context.WithCancel(context.Background()) + + s := &Scheduler{ + storage: store, + logger: logger, + ctx: ctx, + cancel: cancel, + } + + // Create monitors from configuration + for _, group := range cfg.Groups { + for _, monCfg := range group.Monitors { + mon, err := New(monCfg) + if err != nil { + cancel() + return nil, err + } + s.monitors = append(s.monitors, mon) + logger.Info("registered monitor", + "name", mon.Name(), + "type", mon.Type(), + "target", mon.Target(), + "interval", mon.Interval()) + } + } + + return s, nil +} + +// Start begins running all monitors +func (s *Scheduler) Start() { + s.logger.Info("starting scheduler", "monitors", len(s.monitors)) + + for _, mon := range s.monitors { + s.wg.Add(1) + go s.runMonitor(mon) + } + + // Start cleanup routine + s.wg.Add(1) + go s.runCleanup() +} + +// Stop gracefully stops all monitors +func (s *Scheduler) Stop() { + s.logger.Info("stopping scheduler") + s.cancel() + s.wg.Wait() + s.logger.Info("scheduler stopped") +} + +// runMonitor runs a single monitor in a loop +func (s *Scheduler) runMonitor(mon Monitor) { + defer s.wg.Done() + + // Run immediately on start + s.executeCheck(mon) + + ticker := time.NewTicker(mon.Interval()) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + s.logger.Info("monitor stopped", "name", mon.Name()) + return + case <-ticker.C: + s.executeCheck(mon) + } + } +} + +// executeCheck performs a single check and saves the result +func (s *Scheduler) executeCheck(mon Monitor) { + // Create a context with timeout for this check + checkCtx, cancel := context.WithTimeout(s.ctx, mon.Interval()) + defer cancel() + + result := mon.Check(checkCtx) + + // Log the result + logAttrs := []any{ + "name", mon.Name(), + "status", result.Status, + "response_time", result.ResponseTime, + } + if result.StatusCode > 0 { + logAttrs = append(logAttrs, "status_code", result.StatusCode) + } + if result.SSLDaysLeft > 0 { + logAttrs = append(logAttrs, "ssl_days_left", result.SSLDaysLeft) + } + if result.Error != nil { + logAttrs = append(logAttrs, "error", result.Error) + } + + if result.Status == StatusUp { + s.logger.Debug("check completed", logAttrs...) + } else { + s.logger.Warn("check completed", logAttrs...) + } + + // Save to storage + if err := s.storage.SaveCheckResult(s.ctx, result.ToCheckResult()); err != nil { + s.logger.Error("failed to save check result", + "name", mon.Name(), + "error", err) + } +} + +// runCleanup periodically cleans up old data +func (s *Scheduler) runCleanup() { + defer s.wg.Done() + + // Run cleanup daily + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.logger.Info("running database cleanup") + if err := s.storage.Cleanup(s.ctx); err != nil { + s.logger.Error("cleanup failed", "error", err) + } else { + s.logger.Info("cleanup completed") + } + } + } +} + +// GetMonitors returns all registered monitors +func (s *Scheduler) GetMonitors() []Monitor { + return s.monitors +} + +// RunCheck manually triggers a check for a specific monitor +func (s *Scheduler) RunCheck(name string) *Result { + for _, mon := range s.monitors { + if mon.Name() == name { + ctx, cancel := context.WithTimeout(context.Background(), mon.Interval()) + defer cancel() + result := mon.Check(ctx) + + // Save the result + if err := s.storage.SaveCheckResult(context.Background(), result.ToCheckResult()); err != nil { + s.logger.Error("failed to save manual check result", + "name", mon.Name(), + "error", err) + } + + return result + } + } + return nil +} diff --git a/internal/monitor/tcp.go b/internal/monitor/tcp.go new file mode 100644 index 0000000..f93ae10 --- /dev/null +++ b/internal/monitor/tcp.go @@ -0,0 +1,89 @@ +package monitor + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/Fuwn/kaze/internal/config" +) + +// TCPMonitor monitors TCP endpoints +type TCPMonitor struct { + name string + target string + interval time.Duration + timeout time.Duration +} + +// NewTCPMonitor creates a new TCP monitor +func NewTCPMonitor(cfg config.MonitorConfig) (*TCPMonitor, error) { + // Validate target format (should be host:port) + _, _, err := net.SplitHostPort(cfg.Target) + if err != nil { + return nil, fmt.Errorf("invalid TCP target %q: must be host:port format: %w", cfg.Target, err) + } + + return &TCPMonitor{ + name: cfg.Name, + target: cfg.Target, + interval: cfg.Interval.Duration, + timeout: cfg.Timeout.Duration, + }, nil +} + +// Name returns the monitor's name +func (m *TCPMonitor) Name() string { + return m.name +} + +// Type returns the monitor type +func (m *TCPMonitor) Type() string { + return "tcp" +} + +// Target returns the monitor target +func (m *TCPMonitor) Target() string { + return m.target +} + +// Interval returns the check interval +func (m *TCPMonitor) Interval() time.Duration { + return m.interval +} + +// Check performs the TCP connection check +func (m *TCPMonitor) Check(ctx context.Context) *Result { + result := &Result{ + MonitorName: m.name, + Timestamp: time.Now(), + } + + // Create a dialer with timeout + dialer := &net.Dialer{ + Timeout: m.timeout, + } + + // Attempt to connect + start := time.Now() + conn, err := dialer.DialContext(ctx, "tcp", m.target) + result.ResponseTime = time.Since(start) + + if err != nil { + result.Status = StatusDown + result.Error = fmt.Errorf("connection failed: %w", err) + return result + } + defer conn.Close() + + result.Status = StatusUp + + // Check for slow response (degraded if > 1 second for TCP) + if result.ResponseTime > 1*time.Second { + result.Status = StatusDegraded + result.Error = fmt.Errorf("slow connection: %v", result.ResponseTime) + } + + return result +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..04532b9 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,839 @@ +package server + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "sort" + "strconv" + "time" + + "github.com/Fuwn/kaze/internal/config" + "github.com/Fuwn/kaze/internal/monitor" + "github.com/Fuwn/kaze/internal/storage" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Server handles HTTP requests for the status page +type Server struct { + config *config.Config + storage *storage.Storage + scheduler *monitor.Scheduler + logger *slog.Logger + server *http.Server + templates *template.Template +} + +// New creates a new HTTP server +func New(cfg *config.Config, store *storage.Storage, sched *monitor.Scheduler, logger *slog.Logger) (*Server, error) { + // Parse templates + tmpl, err := template.New("").Funcs(templateFuncs()).ParseFS(templatesFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + s := &Server{ + config: cfg, + storage: store, + scheduler: sched, + logger: logger, + templates: tmpl, + } + + // Setup routes + mux := http.NewServeMux() + + // Static files + staticContent, err := fs.Sub(staticFS, "static") + if err != nil { + return nil, fmt.Errorf("failed to get static fs: %w", err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) + + // Pages + mux.HandleFunc("GET /", s.handleIndex) + mux.HandleFunc("GET /api/status", s.handleAPIStatus) + mux.HandleFunc("GET /api/monitor/{name}", s.handleAPIMonitor) + mux.HandleFunc("GET /api/history/{name}", s.handleAPIHistory) + + // Create HTTP server + s.server = &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), + Handler: s.withMiddleware(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s, nil +} + +// Start begins serving HTTP requests +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", "addr", s.server.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +// Stop gracefully shuts down the server +func (s *Server) Stop(ctx context.Context) error { + s.logger.Info("stopping HTTP server") + return s.server.Shutdown(ctx) +} + +// withMiddleware adds common middleware to the handler +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Add security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + + next.ServeHTTP(w, r) + + s.logger.Debug("request", + "method", r.Method, + "path", r.URL.Path, + "duration", time.Since(start)) + }) +} + +// PageData contains data for rendering the status page +type PageData struct { + Site config.SiteConfig + Groups []GroupData + Incidents []IncidentData + OverallStatus string + LastUpdated time.Time + CurrentTime string // Formatted date/time for display (without timezone) + TimezoneTooltip string // JSON data for timezone tooltip + LastUpdatedTooltip string // JSON data for last updated tooltip + TickMode string // ping, minute, hour, day + TickCount int + ShowThemeToggle bool + Timezone string // Timezone for display +} + +// GroupData contains data for a monitor group +type GroupData struct { + Name string + Monitors []MonitorData + DefaultCollapsed bool + ShowGroupUptime bool + GroupUptime float64 +} + +// MonitorData contains data for a single monitor +type MonitorData struct { + Name string + Type string + Status string + StatusClass string + ResponseTime int64 + UptimePercent float64 + Ticks []*storage.TickData // Aggregated tick data for history bar + SSLDaysLeft int + SSLExpiryDate time.Time + SSLTooltip string // JSON data for SSL expiration tooltip + LastCheck time.Time + LastError string +} + +// IncidentData contains data for an incident +type IncidentData struct { + Title string + Status string + StatusClass string + Message string + ScheduledStart *time.Time + ScheduledEnd *time.Time + CreatedAt *time.Time + ResolvedAt *time.Time + Updates []IncidentUpdateData + IsScheduled bool + IsActive bool +} + +// IncidentUpdateData contains data for an incident update +type IncidentUpdateData struct { + Time time.Time + Status string + Message string +} + +// handleIndex renders the main status page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get all monitor stats + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.logger.Error("failed to get monitor stats", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Build page data + data := PageData{ + Site: s.config.Site, + TickMode: s.config.Display.TickMode, + TickCount: s.config.Display.TickCount, + ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, + Timezone: s.config.Display.Timezone, + } + + overallUp := true + hasDegraded := false + var mostRecentCheck time.Time + + // Build groups + for _, group := range s.config.Groups { + gd := GroupData{ + Name: group.Name, + DefaultCollapsed: group.DefaultCollapsed != nil && *group.DefaultCollapsed, + ShowGroupUptime: group.ShowGroupUptime == nil || *group.ShowGroupUptime, + } + + var totalUptime float64 + var monitorsWithUptime int + + for _, monCfg := range group.Monitors { + md := MonitorData{ + Name: monCfg.Name, + Type: monCfg.Type, + } + + if stat, ok := stats[monCfg.Name]; ok { + md.Status = stat.CurrentStatus + md.ResponseTime = stat.LastResponseTime + md.UptimePercent = stat.UptimePercent + md.SSLDaysLeft = stat.SSLDaysLeft + md.LastCheck = stat.LastCheck + md.LastError = stat.LastError + + // Set SSL expiry date and tooltip + if stat.SSLExpiry != nil { + md.SSLExpiryDate = *stat.SSLExpiry + md.SSLTooltip = formatSSLTooltip(*stat.SSLExpiry, stat.SSLDaysLeft, s.config.Display.Timezone) + } + + // Track most recent check time for footer + if stat.LastCheck.After(mostRecentCheck) { + mostRecentCheck = stat.LastCheck + } + + // Get aggregated history for display + ticks, err := s.storage.GetAggregatedHistory( + ctx, + monCfg.Name, + s.config.Display.TickCount, + s.config.Display.TickMode, + s.config.Display.PingFixedSlots, + ) + if err != nil { + s.logger.Error("failed to get tick history", "monitor", monCfg.Name, "error", err) + } else { + md.Ticks = ticks + } + + // Update overall status + if stat.CurrentStatus == "down" { + overallUp = false + } else if stat.CurrentStatus == "degraded" { + hasDegraded = true + } + } else { + md.Status = "unknown" + } + + md.StatusClass = statusToClass(md.Status) + gd.Monitors = append(gd.Monitors, md) + + // Accumulate uptime for group average + if md.UptimePercent >= 0 { + totalUptime += md.UptimePercent + monitorsWithUptime++ + } + } + + // Calculate group average uptime + if monitorsWithUptime > 0 { + gd.GroupUptime = totalUptime / float64(monitorsWithUptime) + } + + data.Groups = append(data.Groups, gd) + } + + // Set last updated time from most recent check + now := time.Now() + if !mostRecentCheck.IsZero() { + data.LastUpdated = mostRecentCheck + } else { + data.LastUpdated = now + } + + // Format current time for display + data.CurrentTime = formatCurrentTime(now, s.config.Display.Timezone) + data.TimezoneTooltip = formatTimezoneTooltip(now, s.config.Display.Timezone) + data.LastUpdatedTooltip = formatLastUpdatedTooltip(data.LastUpdated, s.config.Display.Timezone) + + // Determine overall status + if !overallUp { + data.OverallStatus = "Major Outage" + } else if hasDegraded { + data.OverallStatus = "Partial Outage" + } else { + data.OverallStatus = "All Systems Operational" + } + + // Build incidents + for _, inc := range s.config.Incidents { + id := IncidentData{ + Title: inc.Title, + Status: inc.Status, + StatusClass: incidentStatusToClass(inc.Status), + Message: inc.Message, + ScheduledStart: inc.ScheduledStart, + ScheduledEnd: inc.ScheduledEnd, + CreatedAt: inc.CreatedAt, + ResolvedAt: inc.ResolvedAt, + IsScheduled: inc.Status == "scheduled", + } + + // Check if incident is active (not resolved and not future scheduled) + if inc.Status != "resolved" { + if inc.Status == "scheduled" { + if inc.ScheduledStart != nil && inc.ScheduledStart.After(time.Now()) { + id.IsActive = false + } else { + id.IsActive = true + } + } else { + id.IsActive = true + } + } + + // Add updates + for _, upd := range inc.Updates { + id.Updates = append(id.Updates, IncidentUpdateData{ + Time: upd.Time, + Status: upd.Status, + Message: upd.Message, + }) + } + + data.Incidents = append(data.Incidents, id) + } + + // Sort incidents: active first, then by date + sort.Slice(data.Incidents, func(i, j int) bool { + if data.Incidents[i].IsActive != data.Incidents[j].IsActive { + return data.Incidents[i].IsActive + } + return false + }) + + // Render template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil { + s.logger.Error("failed to render template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// handleAPIStatus returns JSON status for all monitors +func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats, err := s.storage.GetAllMonitorStats(ctx) + if err != nil { + s.jsonError(w, "Failed to get stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIMonitor returns JSON status for a specific monitor +func (s *Server) handleAPIMonitor(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + stats, err := s.storage.GetMonitorStats(r.Context(), name) + if err != nil { + s.jsonError(w, "Failed to get monitor stats", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, stats) +} + +// handleAPIHistory returns aggregated history for a monitor +func (s *Server) handleAPIHistory(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + s.jsonError(w, "Monitor name required", http.StatusBadRequest) + return + } + + // Allow optional parameters, default to config values + mode := s.config.Display.TickMode + if modeParam := r.URL.Query().Get("mode"); modeParam != "" { + switch modeParam { + case "ping", "minute", "hour", "day": + mode = modeParam + } + } + + count := s.config.Display.TickCount + if countParam := r.URL.Query().Get("count"); countParam != "" { + if c, err := strconv.Atoi(countParam); err == nil && c > 0 && c <= 200 { + count = c + } + } + + ticks, err := s.storage.GetAggregatedHistory(r.Context(), name, count, mode, s.config.Display.PingFixedSlots) + if err != nil { + s.jsonError(w, "Failed to get history", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, map[string]interface{}{ + "monitor": name, + "mode": mode, + "count": count, + "ticks": ticks, + }) +} + +// jsonResponse writes a JSON response +func (s *Server) jsonResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + s.logger.Error("failed to encode JSON response", "error", err) + } +} + +// jsonError writes a JSON error response +func (s *Server) jsonError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// templateFuncs returns custom template functions +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "formatTime": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 15:04 UTC") + }, + "formatDate": func(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("Jan 2, 2006") + }, + "formatDuration": func(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + return fmt.Sprintf("%.2fs", float64(ms)/1000) + }, + "formatUptime": func(pct float64) string { + if pct < 0 { + return "-" + } + return fmt.Sprintf("%.2f%%", pct) + }, + "timeAgo": func(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + if d < time.Minute { + return fmt.Sprintf("%d seconds ago", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%d hours ago", int(d.Hours())) + } + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + }, + "tickColor": func(tick *storage.TickData) string { + if tick == nil { + return "bg-neutral-200 dark:bg-neutral-800" // No data + } + if tick.UptimePercent >= 99 { + return "bg-emerald-500" + } + if tick.UptimePercent >= 95 { + return "bg-yellow-500" + } + if tick.UptimePercent > 0 { + return "bg-red-500" + } + // For ping mode with status + switch tick.Status { + case "up": + return "bg-emerald-500" + case "degraded": + return "bg-yellow-500" + case "down": + return "bg-red-500" + } + return "bg-neutral-200 dark:bg-neutral-800" + }, + "tickTooltipData": func(tick *storage.TickData, mode, timezone string) string { + if tick == nil { + data := map[string]interface{}{"header": "No data"} + b, _ := json.Marshal(data) + return string(b) + } + + // Convert timestamp to configured timezone + loc := time.Local + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + t := tick.Timestamp.In(loc) + + // Get timezone info + tzAbbr := t.Format("MST") + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + var header, statusClass string + data := make(map[string]interface{}) + rows := []map[string]string{} + + switch mode { + case "ping": + header = t.Format("Jan 2, 15:04:05") + statusClass = tickStatusClass(tick.Status) + rows = append(rows, + map[string]string{"label": "Status", "value": tick.Status, "class": statusClass}, + map[string]string{"label": "Response", "value": fmt.Sprintf("%dms", tick.ResponseTime), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "minute": + header = t.Format("Jan 2, 15:04") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "hour": + header = t.Format("Jan 2, 15:00") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + case "day": + header = t.Format("Jan 2, 2006") + statusClass = uptimeStatusClass(tick.UptimePercent) + rows = append(rows, + map[string]string{"label": "Checks", "value": fmt.Sprintf("%d", tick.TotalChecks), "class": ""}, + map[string]string{"label": "Uptime", "value": fmt.Sprintf("%.1f%%", tick.UptimePercent), "class": statusClass}, + map[string]string{"label": "Avg Response", "value": fmt.Sprintf("%dms", int(tick.AvgResponse)), "class": ""}, + map[string]string{"label": "Timezone", "value": fmt.Sprintf("%s (%s)", tzAbbr, utcOffset), "class": ""}, + ) + default: + header = t.Format("Jan 2, 15:04") + } + + data["header"] = header + data["rows"] = rows + + b, _ := json.Marshal(data) + return string(b) + }, + "seq": func(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + } + return result + }, + } +} + +// formatCurrentTime formats the current time for display without timezone +func formatCurrentTime(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + return t.Format("Jan 2, 2006 15:04") +} + +// formatTimezoneTooltip creates JSON data for timezone tooltip +func formatTimezoneTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation (like PST, EST, etc.) + tzAbbr := t.Format("MST") + + // Get UTC offset in format like "UTC-8" or "UTC+5:30" + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset in same format + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + data := map[string]interface{}{ + "header": "Timezone", + "rows": []map[string]string{ + {"label": "Abbreviation", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// formatSSLTooltip creates JSON data for SSL expiration tooltip +func formatSSLTooltip(expiryDate time.Time, daysLeft int, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t := expiryDate.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the expiry date + expiryStr := t.Format("Jan 2, 2006 15:04:05") + + // Determine status message + var statusMsg, statusClass string + if daysLeft < 0 { + statusMsg = "Expired" + statusClass = "error" + } else if daysLeft < 7 { + statusMsg = fmt.Sprintf("%d days (Critical)", daysLeft) + statusClass = "error" + } else if daysLeft < 14 { + statusMsg = fmt.Sprintf("%d days (Warning)", daysLeft) + statusClass = "warning" + } else { + statusMsg = fmt.Sprintf("%d days", daysLeft) + statusClass = "success" + } + + data := map[string]interface{}{ + "header": "SSL Certificate", + "rows": []map[string]string{ + {"label": "Expires", "value": expiryStr, "class": ""}, + {"label": "Days Left", "value": statusMsg, "class": statusClass}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// formatLastUpdatedTooltip creates JSON data for last updated tooltip +func formatLastUpdatedTooltip(t time.Time, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + t = t.In(loc) + + // Get timezone abbreviation + tzAbbr := t.Format("MST") + + // Get UTC offset + _, offset := t.Zone() + hours := offset / 3600 + minutes := (offset % 3600) / 60 + var utcOffset string + if minutes != 0 { + utcOffset = fmt.Sprintf("UTC%+d:%02d", hours, abs(minutes)) + } else { + utcOffset = fmt.Sprintf("UTC%+d", hours) + } + + // Get GMT offset + var gmtOffset string + if minutes != 0 { + gmtOffset = fmt.Sprintf("GMT%+d:%02d", hours, abs(minutes)) + } else { + gmtOffset = fmt.Sprintf("GMT%+d", hours) + } + + // Format the datetime + datetime := t.Format("Jan 2, 2006 15:04:05") + + data := map[string]interface{}{ + "header": "Last Check", + "rows": []map[string]string{ + {"label": "Date & Time", "value": datetime, "class": ""}, + {"label": "Timezone", "value": tzAbbr, "class": ""}, + {"label": "UTC Offset", "value": utcOffset, "class": ""}, + {"label": "GMT Offset", "value": gmtOffset, "class": ""}, + }, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// statusToClass converts a status to a CSS class +func statusToClass(status string) string { + switch status { + case "up": + return "status-up" + case "down": + return "status-down" + case "degraded": + return "status-degraded" + default: + return "status-unknown" + } +} + +// tickStatusClass returns CSS class for tooltip status text +func tickStatusClass(status string) string { + switch status { + case "up": + return "success" + case "degraded": + return "warning" + case "down": + return "error" + default: + return "" + } +} + +// uptimeStatusClass returns CSS class based on uptime percentage +func uptimeStatusClass(pct float64) string { + if pct >= 99 { + return "success" + } + if pct >= 95 { + return "warning" + } + return "error" +} + +// incidentStatusToClass converts an incident status to a CSS class +func incidentStatusToClass(status string) string { + switch status { + case "scheduled": + return "incident-scheduled" + case "investigating": + return "incident-investigating" + case "identified": + return "incident-identified" + case "monitoring": + return "incident-monitoring" + case "resolved": + return "incident-resolved" + default: + return "incident-unknown" + } +} diff --git a/internal/server/static/style.css b/internal/server/static/style.css new file mode 100644 index 0000000..fc7c4a5 --- /dev/null +++ b/internal/server/static/style.css @@ -0,0 +1,417 @@ +/* Kaze Status Page - OpenCode-inspired Theme */ + +/* Reset and base */ +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + tab-size: 4; +} + +/* Color scheme support */ +:root { + color-scheme: light dark; +} + +/* Font */ +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono'), local('JetBrainsMono-Regular'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono Medium'), local('JetBrainsMono-Medium'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: local('JetBrains Mono Bold'), local('JetBrainsMono-Bold'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* Base styles */ +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Utility classes - Tailwind-inspired */ + +/* Font */ +.font-mono { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; +} + +/* Colors - Light mode */ +.bg-neutral-50 { background-color: #fafafa; } +.bg-neutral-100 { background-color: #f5f5f5; } +.bg-neutral-200 { background-color: #e5e5e5; } +.bg-neutral-800 { background-color: #262626; } +.bg-neutral-900 { background-color: #171717; } +.bg-neutral-950 { background-color: #0a0a0a; } +.bg-white { background-color: #ffffff; } + +.bg-emerald-50 { background-color: #ecfdf5; } +.bg-emerald-100 { background-color: #d1fae5; } +.bg-emerald-500 { background-color: #10b981; } + +.bg-yellow-50 { background-color: #fefce8; } +.bg-yellow-100 { background-color: #fef9c3; } +.bg-yellow-500 { background-color: #eab308; } + +.bg-red-50 { background-color: #fef2f2; } +.bg-red-100 { background-color: #fee2e2; } +.bg-red-500 { background-color: #ef4444; } + +.bg-blue-100 { background-color: #dbeafe; } +.bg-blue-500 { background-color: #3b82f6; } + +.bg-orange-100 { background-color: #ffedd5; } + +.text-neutral-100 { color: #f5f5f5; } +.text-neutral-300 { color: #d4d4d4; } +.text-neutral-400 { color: #a3a3a3; } +.text-neutral-500 { color: #737373; } +.text-neutral-600 { color: #525252; } +.text-neutral-700 { color: #404040; } +.text-neutral-900 { color: #171717; } + +.text-emerald-300 { color: #6ee7b7; } +.text-emerald-400 { color: #34d399; } +.text-emerald-500 { color: #10b981; } +.text-emerald-600 { color: #059669; } +.text-emerald-700 { color: #047857; } + +.text-yellow-300 { color: #fde047; } +.text-yellow-400 { color: #facc15; } +.text-yellow-500 { color: #eab308; } +.text-yellow-600 { color: #ca8a04; } +.text-yellow-700 { color: #a16207; } + +.text-red-400 { color: #f87171; } +.text-red-500 { color: #ef4444; } +.text-red-600 { color: #dc2626; } + +.text-blue-300 { color: #93c5fd; } +.text-blue-500 { color: #3b82f6; } +.text-blue-700 { color: #1d4ed8; } + +.text-orange-700 { color: #c2410c; } +.text-orange-300 { color: #fdba74; } + +/* Border colors */ +.border-neutral-200 { border-color: #e5e5e5; } +.border-neutral-800 { border-color: #262626; } +.border-emerald-200 { border-color: #a7f3d0; } +.border-emerald-900 { border-color: #064e3b; } +.border-yellow-200 { border-color: #fef08a; } +.border-yellow-900 { border-color: #713f12; } +.border-red-200 { border-color: #fecaca; } +.border-red-900 { border-color: #7f1d1d; } + +/* Dark mode */ +.dark .dark\:bg-neutral-800 { background-color: #262626; } +.dark .dark\:bg-neutral-900 { background-color: #171717; } +.dark .dark\:bg-neutral-900\/50 { background-color: rgba(23, 23, 23, 0.5); } +.dark .dark\:bg-neutral-950 { background-color: #0a0a0a; } +.dark .dark\:bg-emerald-900\/50 { background-color: rgba(6, 78, 59, 0.5); } +.dark .dark\:bg-emerald-950\/30 { background-color: rgba(2, 44, 34, 0.3); } +.dark .dark\:bg-yellow-900\/50 { background-color: rgba(113, 63, 18, 0.5); } +.dark .dark\:bg-yellow-950\/20 { background-color: rgba(66, 32, 6, 0.2); } +.dark .dark\:bg-yellow-950\/30 { background-color: rgba(66, 32, 6, 0.3); } +.dark .dark\:bg-red-900\/50 { background-color: rgba(127, 29, 29, 0.5); } +.dark .dark\:bg-red-950\/30 { background-color: rgba(69, 10, 10, 0.3); } +.dark .dark\:bg-blue-900\/50 { background-color: rgba(30, 58, 138, 0.5); } +.dark .dark\:bg-orange-900\/50 { background-color: rgba(124, 45, 18, 0.5); } + +.dark .dark\:text-neutral-100 { color: #f5f5f5; } +.dark .dark\:text-neutral-300 { color: #d4d4d4; } +.dark .dark\:text-neutral-400 { color: #a3a3a3; } +.dark .dark\:text-neutral-500 { color: #737373; } +.dark .dark\:text-emerald-300 { color: #6ee7b7; } +.dark .dark\:text-emerald-400 { color: #34d399; } +.dark .dark\:text-yellow-300 { color: #fde047; } +.dark .dark\:text-yellow-400 { color: #facc15; } +.dark .dark\:text-red-400 { color: #f87171; } +.dark .dark\:text-blue-300 { color: #93c5fd; } +.dark .dark\:text-orange-300 { color: #fdba74; } + +.dark .dark\:border-neutral-800 { border-color: #262626; } +.dark .dark\:border-emerald-900 { border-color: #064e3b; } +.dark .dark\:border-yellow-900 { border-color: #713f12; } +.dark .dark\:border-red-900 { border-color: #7f1d1d; } + +.dark .dark\:divide-neutral-800 > :not([hidden]) ~ :not([hidden]) { border-color: #262626; } + +.dark .dark\:hover\:bg-neutral-800:hover { background-color: #262626; } +.dark .dark\:hover\:bg-neutral-900\/50:hover { background-color: rgba(23, 23, 23, 0.5); } +.dark .dark\:hover\:text-neutral-100:hover { color: #f5f5f5; } + +/* Display */ +.block { display: block; } +.hidden { display: none; } +.flex { display: flex; } +.dark .dark\:block { display: block; } +.dark .dark\:hidden { display: none; } + +/* Flexbox */ +.flex-1 { flex: 1 1 0%; } +.flex-shrink-0 { flex-shrink: 0; } +.items-start { align-items: flex-start; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } + +/* Gap */ +.gap-px { gap: 1px; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } + +/* Sizing */ +.w-2 { width: 0.5rem; } +.w-3 { width: 0.75rem; } +.w-4 { width: 1rem; } +.w-5 { width: 1.25rem; } +.w-8 { width: 2rem; } +.h-2 { height: 0.5rem; } +.h-3 { height: 0.75rem; } +.h-4 { height: 1rem; } +.h-5 { height: 1.25rem; } +.h-6 { height: 1.5rem; } +.h-8 { height: 2rem; } +.min-h-screen { min-height: 100vh; } +.min-w-0 { min-width: 0px; } +.max-w-4xl { max-width: 56rem; } +.max-w-\[200px\] { max-width: 200px; } + +/* Spacing */ +.mx-auto { margin-left: auto; margin-right: auto; } +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-8 { margin-bottom: 2rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-8 { margin-top: 2rem; } +.mt-12 { margin-top: 3rem; } +.p-2 { padding: 0.5rem; } +.p-4 { padding: 1rem; } +.px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; } +.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; } +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.py-8 { padding-top: 2rem; padding-bottom: 2rem; } +.pt-6 { padding-top: 1.5rem; } + +/* Borders */ +.border { border-width: 1px; } +.border-b { border-bottom-width: 1px; } +.border-t { border-top-width: 1px; } +.rounded-sm { border-radius: 0.125rem; } +.rounded-md { border-radius: 0.375rem; } +.rounded-lg { border-radius: 0.5rem; } +.rounded-full { border-radius: 9999px; } +.divide-y > :not([hidden]) ~ :not([hidden]) { border-top-width: 1px; } +.divide-neutral-200 > :not([hidden]) ~ :not([hidden]) { border-color: #e5e5e5; } + +/* Typography */ +.text-xs { font-size: 0.75rem; line-height: 1rem; } +.text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-lg { font-size: 1.125rem; line-height: 1.75rem; } +.text-xl { font-size: 1.25rem; line-height: 1.75rem; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-bold { font-weight: 700; } +.uppercase { text-transform: uppercase; } +.capitalize { text-transform: capitalize; } +.tracking-tight { letter-spacing: -0.025em; } +.tracking-wider { letter-spacing: 0.05em; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.underline { text-decoration-line: underline; } +.underline-offset-2 { text-underline-offset: 2px; } + +/* Overflow */ +.overflow-hidden { overflow: hidden; } + +/* Transitions */ +.transition-colors { + transition-property: color, background-color, border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Hover */ +.hover\:bg-neutral-200:hover { background-color: #e5e5e5; } +.hover\:bg-neutral-100\/50:hover { background-color: rgba(245, 245, 245, 0.5); } +.hover\:text-neutral-900:hover { color: #171717; } + +/* Animation */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + +/* Responsive */ +@media (min-width: 640px) { + .sm\:py-12 { padding-top: 3rem; padding-bottom: 3rem; } + .sm\:mb-12 { margin-bottom: 3rem; } + .sm\:text-2xl { font-size: 1.5rem; line-height: 2rem; } +} + +/* Space */ +.space-y-3 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.75rem; } +.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; } +.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: 1.5rem; } + +/* Fill/Stroke */ +svg { fill: none; } + +/* Collapsible Groups */ +.group-content { + max-height: 10000px; + overflow: hidden; + transition: max-height 0.3s ease-out, opacity 0.3s ease-out; + opacity: 1; +} + +.group-content.collapsed { + max-height: 0; + opacity: 0; + transition: max-height 0.3s ease-in, opacity 0.2s ease-in; +} + +.cursor-pointer { + cursor: pointer; +} + +[data-group-icon] { + transition: transform 0.3s ease; +} + +[data-group-icon].rotated { + transform: rotate(-90deg); +} + +/* Custom Tooltip */ +.tooltip { + position: fixed; + z-index: 1000; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + line-height: 1.4; + background-color: #171717; + color: #f5f5f5; + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + pointer-events: none; + opacity: 0; + transform: translateY(4px); + transition: opacity 150ms ease, transform 150ms ease; + max-width: 280px; + white-space: normal; +} + +.tooltip.visible { + opacity: 1; + transform: translateY(0); +} + +.tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-bottom-color: #171717; +} + +.tooltip.tooltip-top::before { + bottom: auto; + top: 100%; + border-bottom-color: transparent; + border-top-color: #171717; +} + +/* Light mode tooltip */ +:root:not(.dark) .tooltip { + background-color: #ffffff; + color: #171717; + border: 1px solid #e5e5e5; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); +} + +:root:not(.dark) .tooltip::before { + border-bottom-color: #ffffff; + /* Account for border */ + margin-bottom: -1px; +} + +:root:not(.dark) .tooltip.tooltip-top::before { + border-bottom-color: transparent; + border-top-color: #ffffff; + margin-bottom: 0; + margin-top: -1px; +} + +/* Tooltip content styling */ +.tooltip-header { + font-weight: 500; + margin-bottom: 0.25rem; + color: inherit; +} + +.tooltip-row { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.tooltip-label { + color: #a3a3a3; +} + +:root:not(.dark) .tooltip-label { + color: #737373; +} + +.tooltip-value { + font-weight: 500; +} + +.tooltip-value.success { + color: #10b981; +} + +.tooltip-value.warning { + color: #eab308; +} + +.tooltip-value.error { + color: #ef4444; +} + +/* Tooltip trigger */ +[data-tooltip] { + cursor: pointer; +} diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html new file mode 100644 index 0000000..c351c73 --- /dev/null +++ b/internal/server/templates/index.html @@ -0,0 +1,357 @@ + + + + + + {{.Site.Name}} + + {{if .Site.Favicon}}{{end}} + + + + +
+ +
+
+
+ {{if .Site.Logo}} + Logo + {{end}} +
+

{{.Site.Name}}

+

{{.Site.Description}}

+
+
+ {{if .ShowThemeToggle}} + + {{end}} +
+
+ + +
+
+
+
+ {{if eq .OverallStatus "All Systems Operational"}} +
+ {{else if eq .OverallStatus "Partial Outage"}} +
+ {{else}} +
+ {{end}} +
+ {{.OverallStatus}} +
+ {{.CurrentTime}} +
+
+ + +
+ {{range $groupIndex, $group := .Groups}} +
+
+
+
+ + + +

{{$group.Name}}

+
+ {{if $group.ShowGroupUptime}} + {{formatUptime $group.GroupUptime}} + {{end}} +
+
+
+ {{range .Monitors}} +
+
+
+
+ {{if eq .Status "up"}} +
+ {{else if eq .Status "degraded"}} +
+ {{else if eq .Status "down"}} +
+ {{else}} +
+ {{end}} + {{.Name}} + {{.Type}} +
+
+ {{formatDuration .ResponseTime}} + {{if gt .SSLDaysLeft 0}} + SSL: {{.SSLDaysLeft}}d + {{end}} + {{if .LastError}} + {{if .LastError}}{{.LastError}}{{end}} + {{end}} +
+
+
+ {{formatUptime .UptimePercent}} +
+
+ +
+ {{range .Ticks}} +
+ {{else}} + {{range seq $.TickCount}} +
+ {{end}} + {{end}} +
+
+ {{end}} +
+
+ {{end}} +
+ + + {{if .Incidents}} +
+

Incidents

+
+ {{range .Incidents}} +
+
+
+
+
+ {{if eq .Status "resolved"}} + + + + {{else if eq .Status "scheduled"}} + + + + {{else}} + + + + {{end}} + {{.Title}} +
+

{{.Message}}

+ {{if .IsScheduled}} +

+ Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} +

+ {{end}} +
+
+ + {{.Status}} + +
+
+
+ {{if .Updates}} +
+ {{range .Updates}} +
+
+ {{.Status}} + {{formatTime .Time}} +
+

{{.Message}}

+
+ {{end}} +
+ {{end}} +
+ {{end}} +
+
+ {{end}} + + +
+
+ Updated {{timeAgo .LastUpdated}} + Powered by Kaze +
+
+
+ + +
+ + + + diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..e08e4ee --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,679 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Storage handles all database operations +type Storage struct { + db *sql.DB + historyDays int +} + +// CheckResult represents a single monitor check result +type CheckResult struct { + ID int64 + MonitorName string + Timestamp time.Time + Status string // up, down, degraded + ResponseTime int64 // milliseconds + StatusCode int // HTTP status code (0 for non-HTTP) + Error string + SSLExpiry *time.Time + SSLDaysLeft int +} + +// DailyStatus represents aggregated daily status for a monitor +type DailyStatus struct { + Date time.Time + MonitorName string + SuccessCount int + FailureCount int + TotalChecks int + AvgResponse float64 + UptimePercent float64 +} + +// MonitorStats represents overall statistics for a monitor +type MonitorStats struct { + MonitorName string + CurrentStatus string + LastCheck time.Time + LastResponseTime int64 + LastError string + UptimePercent float64 + AvgResponseTime float64 + SSLExpiry *time.Time + SSLDaysLeft int + TotalChecks int64 +} + +// TickData represents aggregated data for one tick in the history bar +type TickData struct { + Timestamp time.Time + TotalChecks int + SuccessCount int + FailureCount int + AvgResponse float64 + UptimePercent float64 + // For ping mode only + Status string // up, down, degraded (only for ping mode) + ResponseTime int64 // milliseconds (only for ping mode) +} + +// New creates a new storage instance +func New(dbPath string, historyDays int) (*Storage, error) { + // Add connection parameters for better concurrency handling + // _txlock=immediate ensures transactions acquire locks immediately + // _busy_timeout=5000 waits up to 5 seconds for locks + // _journal_mode=WAL enables write-ahead logging for better concurrency + // _synchronous=NORMAL balances safety and performance + dsn := fmt.Sprintf("%s?_txlock=immediate&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL", dbPath) + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // CRITICAL: Set max open connections to 1 for SQLite + // SQLite only supports one writer at a time, so we serialize all writes + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) // Don't close idle connections + + // Verify WAL mode is enabled + var journalMode string + if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil { + db.Close() + return nil, fmt.Errorf("failed to check journal mode: %w", err) + } + + // Enable foreign keys (must be done per-connection, but with 1 conn it's fine) + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + s := &Storage{ + db: db, + historyDays: historyDays, + } + + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return s, nil +} + +// migrate creates the database schema +func (s *Storage) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS check_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_name TEXT NOT NULL, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status TEXT NOT NULL CHECK(status IN ('up', 'down', 'degraded')), + response_time_ms INTEGER NOT NULL DEFAULT 0, + status_code INTEGER DEFAULT 0, + error_message TEXT, + ssl_expiry DATETIME, + ssl_days_left INTEGER DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_check_results_monitor_time + ON check_results(monitor_name, timestamp DESC); + + CREATE INDEX IF NOT EXISTS idx_check_results_timestamp + ON check_results(timestamp); + + CREATE TABLE IF NOT EXISTS daily_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_name TEXT NOT NULL, + date DATE NOT NULL, + success_count INTEGER NOT NULL DEFAULT 0, + failure_count INTEGER NOT NULL DEFAULT 0, + total_checks INTEGER NOT NULL DEFAULT 0, + avg_response_ms REAL DEFAULT 0, + uptime_percent REAL DEFAULT 0, + UNIQUE(monitor_name, date) + ); + + CREATE INDEX IF NOT EXISTS idx_daily_stats_monitor_date + ON daily_stats(monitor_name, date DESC); + + CREATE TABLE IF NOT EXISTS monitor_state ( + monitor_name TEXT PRIMARY KEY, + current_status TEXT NOT NULL DEFAULT 'unknown', + last_check DATETIME, + last_response_time_ms INTEGER DEFAULT 0, + last_error TEXT, + ssl_expiry DATETIME, + ssl_days_left INTEGER DEFAULT 0 + ); + ` + + _, err := s.db.Exec(schema) + return err +} + +// SaveCheckResult saves a check result and updates monitor state +func (s *Storage) SaveCheckResult(ctx context.Context, result *CheckResult) error { + // Retry logic for transient lock issues + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + // Wait before retry with exponential backoff + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt*100) * time.Millisecond): + } + } + + lastErr = s.saveCheckResultOnce(ctx, result) + if lastErr == nil { + return nil + } + + // Only retry on lock errors + if !isLockError(lastErr) { + return lastErr + } + } + return fmt.Errorf("failed after 3 attempts: %w", lastErr) +} + +// isLockError checks if the error is a database lock error +func isLockError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "database is locked") || + strings.Contains(errStr, "SQLITE_BUSY") || + strings.Contains(errStr, "database table is locked") +} + +// saveCheckResultOnce performs a single attempt to save the check result +func (s *Storage) saveCheckResultOnce(ctx context.Context, result *CheckResult) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Insert check result + _, err = tx.ExecContext(ctx, ` + INSERT INTO check_results ( + monitor_name, timestamp, status, response_time_ms, + status_code, error_message, ssl_expiry, ssl_days_left + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, result.MonitorName, result.Timestamp, result.Status, + result.ResponseTime, result.StatusCode, result.Error, + result.SSLExpiry, result.SSLDaysLeft) + if err != nil { + return fmt.Errorf("failed to insert check result: %w", err) + } + + // Update or insert monitor state + _, err = tx.ExecContext(ctx, ` + INSERT INTO monitor_state ( + monitor_name, current_status, last_check, + last_response_time_ms, last_error, ssl_expiry, ssl_days_left + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(monitor_name) DO UPDATE SET + current_status = excluded.current_status, + last_check = excluded.last_check, + last_response_time_ms = excluded.last_response_time_ms, + last_error = excluded.last_error, + ssl_expiry = excluded.ssl_expiry, + ssl_days_left = excluded.ssl_days_left + `, result.MonitorName, result.Status, result.Timestamp, + result.ResponseTime, result.Error, result.SSLExpiry, result.SSLDaysLeft) + if err != nil { + return fmt.Errorf("failed to update monitor state: %w", err) + } + + // Update daily stats + date := result.Timestamp.Format("2006-01-02") + successIncr := 0 + failureIncr := 0 + if result.Status == "up" { + successIncr = 1 + } else { + failureIncr = 1 + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO daily_stats ( + monitor_name, date, success_count, failure_count, + total_checks, avg_response_ms, uptime_percent + ) VALUES (?, ?, ?, ?, 1, ?, ?) + ON CONFLICT(monitor_name, date) DO UPDATE SET + success_count = daily_stats.success_count + ?, + failure_count = daily_stats.failure_count + ?, + total_checks = daily_stats.total_checks + 1, + avg_response_ms = (daily_stats.avg_response_ms * daily_stats.total_checks + ?) / (daily_stats.total_checks + 1), + uptime_percent = CAST((daily_stats.success_count + ?) AS REAL) / (daily_stats.total_checks + 1) * 100 + `, result.MonitorName, date, successIncr, failureIncr, + float64(result.ResponseTime), float64(successIncr)*100, + successIncr, failureIncr, float64(result.ResponseTime), successIncr) + if err != nil { + return fmt.Errorf("failed to update daily stats: %w", err) + } + + return tx.Commit() +} + +// GetMonitorStats returns statistics for a specific monitor +func (s *Storage) GetMonitorStats(ctx context.Context, monitorName string) (*MonitorStats, error) { + stats := &MonitorStats{MonitorName: monitorName} + + // Get current state + err := s.db.QueryRowContext(ctx, ` + SELECT current_status, last_check, last_response_time_ms, + last_error, ssl_expiry, ssl_days_left + FROM monitor_state + WHERE monitor_name = ? + `, monitorName).Scan(&stats.CurrentStatus, &stats.LastCheck, + &stats.LastResponseTime, &stats.LastError, + &stats.SSLExpiry, &stats.SSLDaysLeft) + if err == sql.ErrNoRows { + stats.CurrentStatus = "unknown" + return stats, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get monitor state: %w", err) + } + + // Get aggregate stats from check results within history window + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + err = s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) as total, + AVG(response_time_ms) as avg_response, + CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime + FROM check_results + WHERE monitor_name = ? AND timestamp >= ? + `, monitorName, cutoff).Scan(&stats.TotalChecks, &stats.AvgResponseTime, &stats.UptimePercent) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to get aggregate stats: %w", err) + } + + return stats, nil +} + +// GetAllMonitorStats returns statistics for all monitors +func (s *Storage) GetAllMonitorStats(ctx context.Context) (map[string]*MonitorStats, error) { + stats := make(map[string]*MonitorStats) + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + + // Get all monitor states + rows, err := s.db.QueryContext(ctx, ` + SELECT monitor_name, current_status, last_check, + last_response_time_ms, last_error, ssl_expiry, ssl_days_left + FROM monitor_state + `) + if err != nil { + return nil, fmt.Errorf("failed to query monitor states: %w", err) + } + defer rows.Close() + + for rows.Next() { + var ms MonitorStats + var lastError sql.NullString + var sslExpiry sql.NullTime + err := rows.Scan(&ms.MonitorName, &ms.CurrentStatus, &ms.LastCheck, + &ms.LastResponseTime, &lastError, &sslExpiry, &ms.SSLDaysLeft) + if err != nil { + return nil, fmt.Errorf("failed to scan monitor state: %w", err) + } + if lastError.Valid { + ms.LastError = lastError.String + } + if sslExpiry.Valid { + ms.SSLExpiry = &sslExpiry.Time + } + stats[ms.MonitorName] = &ms + } + + // Get aggregate stats for all monitors + rows, err = s.db.QueryContext(ctx, ` + SELECT + monitor_name, + COUNT(*) as total, + AVG(response_time_ms) as avg_response, + CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100 as uptime + FROM check_results + WHERE timestamp >= ? + GROUP BY monitor_name + `, cutoff) + if err != nil { + return nil, fmt.Errorf("failed to query aggregate stats: %w", err) + } + defer rows.Close() + + for rows.Next() { + var name string + var total int64 + var avgResponse, uptime float64 + if err := rows.Scan(&name, &total, &avgResponse, &uptime); err != nil { + return nil, fmt.Errorf("failed to scan aggregate stats: %w", err) + } + if ms, ok := stats[name]; ok { + ms.TotalChecks = total + ms.AvgResponseTime = avgResponse + ms.UptimePercent = uptime + } + } + + return stats, nil +} + +// GetDailyStats returns daily statistics for a monitor +func (s *Storage) GetDailyStats(ctx context.Context, monitorName string, days int) ([]DailyStatus, error) { + cutoff := time.Now().AddDate(0, 0, -days) + + rows, err := s.db.QueryContext(ctx, ` + SELECT date, success_count, failure_count, total_checks, + avg_response_ms, uptime_percent + FROM daily_stats + WHERE monitor_name = ? AND date >= ? + ORDER BY date ASC + `, monitorName, cutoff.Format("2006-01-02")) + if err != nil { + return nil, fmt.Errorf("failed to query daily stats: %w", err) + } + defer rows.Close() + + var results []DailyStatus + for rows.Next() { + var ds DailyStatus + var dateStr string + ds.MonitorName = monitorName + if err := rows.Scan(&dateStr, &ds.SuccessCount, &ds.FailureCount, + &ds.TotalChecks, &ds.AvgResponse, &ds.UptimePercent); err != nil { + return nil, fmt.Errorf("failed to scan daily stats: %w", err) + } + ds.Date, _ = time.Parse("2006-01-02", dateStr) + results = append(results, ds) + } + + return results, nil +} + +// GetUptimeHistory returns uptime data for the history visualization +// Returns a slice of daily uptime percentages, one per day for the specified period +func (s *Storage) GetUptimeHistory(ctx context.Context, monitorName string, days int) ([]float64, error) { + // Create a map of date to uptime + uptimeMap := make(map[string]float64) + + // Use local time for date range calculation (matches how we store dates) + cutoffDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + + rows, err := s.db.QueryContext(ctx, ` + SELECT date, uptime_percent + FROM daily_stats + WHERE monitor_name = ? AND date >= ? + ORDER BY date ASC + `, monitorName, cutoffDate) + if err != nil { + return nil, fmt.Errorf("failed to query uptime history: %w", err) + } + defer rows.Close() + + for rows.Next() { + var dateStr string + var uptime float64 + if err := rows.Scan(&dateStr, &uptime); err != nil { + return nil, fmt.Errorf("failed to scan uptime: %w", err) + } + // Normalize date format (strip any time component if present) + if len(dateStr) > 10 { + dateStr = dateStr[:10] + } + uptimeMap[dateStr] = uptime + } + + // Build result array with -1 for days with no data + result := make([]float64, days) + today := time.Now().Format("2006-01-02") + for i := 0; i < days; i++ { + date := time.Now().AddDate(0, 0, -days+i+1).Format("2006-01-02") + if uptime, ok := uptimeMap[date]; ok { + result[i] = uptime + } else if date == today { + // For today, if we have monitor data but no daily stats yet, + // check if there's recent data and show it + result[i] = -1 + } else { + result[i] = -1 // No data for this day + } + } + + return result, nil +} + +// PingResult represents a single ping for the history display +type PingResult struct { + Status string // up, down, degraded + ResponseTime int64 // milliseconds + Timestamp time.Time +} + +// GetRecentPings returns the last N check results for a monitor +// Results are ordered from oldest to newest (left to right on display) +func (s *Storage) GetRecentPings(ctx context.Context, monitorName string, limit int) ([]PingResult, error) { + // Query in descending order then reverse, so we get the most recent N results + // but in chronological order for display + rows, err := s.db.QueryContext(ctx, ` + SELECT status, response_time_ms, timestamp + FROM check_results + WHERE monitor_name = ? + ORDER BY timestamp DESC + LIMIT ? + `, monitorName, limit) + if err != nil { + return nil, fmt.Errorf("failed to query recent pings: %w", err) + } + defer rows.Close() + + var results []PingResult + for rows.Next() { + var p PingResult + if err := rows.Scan(&p.Status, &p.ResponseTime, &p.Timestamp); err != nil { + return nil, fmt.Errorf("failed to scan ping: %w", err) + } + results = append(results, p) + } + + // Reverse to get chronological order (oldest first) + for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { + results[i], results[j] = results[j], results[i] + } + + return results, nil +} + +// GetAggregatedHistory returns tick data for the history visualization +// tickMode: "ping", "minute", "hour", "day" +// fixedSlots: when true, returns exactly tickCount elements with nil for missing periods +// Returns a slice where nil entries indicate no data for that period +func (s *Storage) GetAggregatedHistory(ctx context.Context, monitorName string, tickCount int, tickMode string, fixedSlots bool) ([]*TickData, error) { + switch tickMode { + case "ping": + return s.getAggregatedPings(ctx, monitorName, tickCount, fixedSlots) + case "minute": + return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Minute, "%Y-%m-%d %H:%M") + case "hour": + return s.getAggregatedByTime(ctx, monitorName, tickCount, time.Hour, "%Y-%m-%d %H") + case "day": + return s.getAggregatedByTime(ctx, monitorName, tickCount, 24*time.Hour, "%Y-%m-%d") + default: + return nil, fmt.Errorf("invalid tick mode: %s", tickMode) + } +} + +// getAggregatedPings returns individual pings as tick data +func (s *Storage) getAggregatedPings(ctx context.Context, monitorName string, tickCount int, fixedSlots bool) ([]*TickData, error) { + pings, err := s.GetRecentPings(ctx, monitorName, tickCount) + if err != nil { + return nil, err + } + + result := make([]*TickData, 0, tickCount) + + // If fixedSlots is true, pad the beginning with nils + if fixedSlots && len(pings) < tickCount { + for i := 0; i < tickCount-len(pings); i++ { + result = append(result, nil) + } + } + + // Convert pings to TickData + for _, p := range pings { + uptime := 0.0 + if p.Status == "up" { + uptime = 100.0 + } else if p.Status == "degraded" { + uptime = 50.0 + } + result = append(result, &TickData{ + Timestamp: p.Timestamp, + TotalChecks: 1, + SuccessCount: boolToInt(p.Status == "up"), + FailureCount: boolToInt(p.Status == "down"), + AvgResponse: float64(p.ResponseTime), + UptimePercent: uptime, + Status: p.Status, + ResponseTime: p.ResponseTime, + }) + } + + return result, nil +} + +// getAggregatedByTime returns aggregated tick data by time bucket +func (s *Storage) getAggregatedByTime(ctx context.Context, monitorName string, tickCount int, bucketDuration time.Duration, sqlFormat string) ([]*TickData, error) { + // Calculate the start time for our window + now := time.Now() + startTime := now.Add(-time.Duration(tickCount) * bucketDuration) + + // Query aggregated data grouped by time bucket + // First extract just the datetime part (first 19 chars: "2006-01-02 15:04:05") + // then apply strftime to that + query := ` + SELECT + strftime('` + sqlFormat + `', substr(timestamp, 1, 19)) as bucket, + COUNT(*) as total_checks, + SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) as failure_count, + AVG(response_time_ms) as avg_response + FROM check_results + WHERE monitor_name = ? AND timestamp >= ? + GROUP BY bucket + HAVING bucket IS NOT NULL + ORDER BY bucket ASC + ` + + rows, err := s.db.QueryContext(ctx, query, monitorName, startTime) + if err != nil { + return nil, fmt.Errorf("failed to query aggregated history: %w", err) + } + defer rows.Close() + + // Build a map of bucket -> data + dataMap := make(map[string]*TickData) + for rows.Next() { + var bucket sql.NullString + var td TickData + if err := rows.Scan(&bucket, &td.TotalChecks, &td.SuccessCount, &td.FailureCount, &td.AvgResponse); err != nil { + return nil, fmt.Errorf("failed to scan aggregated data: %w", err) + } + if !bucket.Valid || bucket.String == "" { + continue // Skip null buckets + } + if td.TotalChecks > 0 { + td.UptimePercent = float64(td.SuccessCount) / float64(td.TotalChecks) * 100 + } + dataMap[bucket.String] = &td + } + + // Generate all time slots and fill in data + result := make([]*TickData, tickCount) + for i := 0; i < tickCount; i++ { + slotTime := startTime.Add(time.Duration(i+1) * bucketDuration) + bucket := formatTimeBucket(slotTime, bucketDuration) + + if td, ok := dataMap[bucket]; ok { + td.Timestamp = slotTime + result[i] = td + } else { + result[i] = nil // No data for this slot + } + } + + return result, nil +} + +// formatTimeBucket formats a time into the bucket key format +func formatTimeBucket(t time.Time, duration time.Duration) string { + switch { + case duration >= 24*time.Hour: + return t.Format("2006-01-02") + case duration >= time.Hour: + return t.Format("2006-01-02 15") + case duration >= time.Minute: + return t.Format("2006-01-02 15:04") + default: + return t.Format("2006-01-02 15:04:05") + } +} + +// boolToInt converts a boolean to an int (1 for true, 0 for false) +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// Cleanup removes old data beyond the history retention period +func (s *Storage) Cleanup(ctx context.Context) error { + cutoff := time.Now().AddDate(0, 0, -s.historyDays) + + _, err := s.db.ExecContext(ctx, ` + DELETE FROM check_results WHERE timestamp < ? + `, cutoff) + if err != nil { + return fmt.Errorf("failed to cleanup check_results: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + DELETE FROM daily_stats WHERE date < ? + `, cutoff.Format("2006-01-02")) + if err != nil { + return fmt.Errorf("failed to cleanup daily_stats: %w", err) + } + + // Vacuum to reclaim space + _, err = s.db.ExecContext(ctx, "VACUUM") + if err != nil { + return fmt.Errorf("failed to vacuum database: %w", err) + } + + return nil +} + +// Close closes the database connection +func (s *Storage) Close() error { + return s.db.Close() +} -- cgit v1.2.3