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/server/server.go | 839 +++++++++++++++++++++++++++++++++++ internal/server/static/style.css | 417 +++++++++++++++++ internal/server/templates/index.html | 357 +++++++++++++++ 3 files changed, 1613 insertions(+) create mode 100644 internal/server/server.go create mode 100644 internal/server/static/style.css create mode 100644 internal/server/templates/index.html (limited to 'internal/server') 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 +
+
+
+ + +
+ + + + -- cgit v1.2.3