aboutsummaryrefslogtreecommitdiff
path: root/internal/server
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-17 23:17:49 -0800
committerFuwn <[email protected]>2026-01-17 23:17:49 -0800
commit4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch)
treee7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/server
downloadkaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz
kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip
feat: Initial commit
Diffstat (limited to 'internal/server')
-rw-r--r--internal/server/server.go839
-rw-r--r--internal/server/static/style.css417
-rw-r--r--internal/server/templates/index.html357
3 files changed, 1613 insertions, 0 deletions
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Site.Name}}</title>
+ <meta name="description" content="{{.Site.Description}}">
+ {{if .Site.Favicon}}<link rel="icon" href="{{.Site.Favicon}}">{{end}}
+ <link rel="stylesheet" href="/static/style.css">
+ <script>
+ // Theme detection
+ if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+ document.documentElement.classList.add('dark');
+ }
+ </script>
+</head>
+<body class="bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 min-h-screen font-mono">
+ <div class="max-w-4xl mx-auto px-4 py-8 sm:py-12">
+ <!-- Header -->
+ <header class="mb-8 sm:mb-12">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ {{if .Site.Logo}}
+ <img src="{{.Site.Logo}}" alt="Logo" class="h-8 w-8">
+ {{end}}
+ <div>
+ <h1 class="text-xl sm:text-2xl font-bold tracking-tight">{{.Site.Name}}</h1>
+ <p class="text-sm text-neutral-500 dark:text-neutral-400">{{.Site.Description}}</p>
+ </div>
+ </div>
+ {{if .ShowThemeToggle}}
+ <button onclick="toggleTheme()" class="p-2 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" aria-label="Toggle theme">
+ <svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
+ </svg>
+ <svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
+ </svg>
+ </button>
+ {{end}}
+ </div>
+ </header>
+
+ <!-- Overall Status Banner -->
+ <div class="mb-8 p-4 rounded-lg border {{if eq .OverallStatus "All Systems Operational"}}bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900{{else if eq .OverallStatus "Partial Outage"}}bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-900{{else}}bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900{{end}}">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <div class="flex-shrink-0">
+ {{if eq .OverallStatus "All Systems Operational"}}
+ <div class="w-3 h-3 rounded-full bg-emerald-500 animate-pulse"></div>
+ {{else if eq .OverallStatus "Partial Outage"}}
+ <div class="w-3 h-3 rounded-full bg-yellow-500 animate-pulse"></div>
+ {{else}}
+ <div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
+ {{end}}
+ </div>
+ <span class="font-medium">{{.OverallStatus}}</span>
+ </div>
+ <span class="text-sm text-neutral-500 dark:text-neutral-400" data-tooltip='{{.TimezoneTooltip}}'>{{.CurrentTime}}</span>
+ </div>
+ </div>
+
+ <!-- Monitor Groups -->
+ <div class="space-y-6">
+ {{range $groupIndex, $group := .Groups}}
+ <section class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden" data-group="{{$group.Name}}">
+ <div class="px-4 py-3 bg-neutral-100 dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors" onclick="toggleGroup('{{$group.Name}}')">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <svg class="w-4 h-4 text-neutral-600 dark:text-neutral-400 transition-transform" data-group-icon="{{$group.Name}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
+ </svg>
+ <h2 class="font-semibold text-sm uppercase tracking-wider text-neutral-600 dark:text-neutral-400">{{$group.Name}}</h2>
+ </div>
+ {{if $group.ShowGroupUptime}}
+ <span class="text-sm font-medium {{if ge $group.GroupUptime 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge $group.GroupUptime 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime $group.GroupUptime}}</span>
+ {{end}}
+ </div>
+ </div>
+ <div class="divide-y divide-neutral-200 dark:divide-neutral-800 group-content" data-group-content="{{$group.Name}}" data-default-collapsed="{{$group.DefaultCollapsed}}">
+ {{range .Monitors}}
+ <div class="p-4 hover:bg-neutral-100/50 dark:hover:bg-neutral-900/50 transition-colors">
+ <div class="flex items-start justify-between gap-4">
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2 mb-2">
+ {{if eq .Status "up"}}
+ <div class="w-2 h-2 rounded-full bg-emerald-500 flex-shrink-0"></div>
+ {{else if eq .Status "degraded"}}
+ <div class="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0"></div>
+ {{else if eq .Status "down"}}
+ <div class="w-2 h-2 rounded-full bg-red-500 flex-shrink-0"></div>
+ {{else}}
+ <div class="w-2 h-2 rounded-full bg-neutral-400 flex-shrink-0"></div>
+ {{end}}
+ <span class="font-medium truncate">{{.Name}}</span>
+ <span class="text-xs px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 uppercase">{{.Type}}</span>
+ </div>
+ <div class="flex items-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
+ <span>{{formatDuration .ResponseTime}}</span>
+ {{if gt .SSLDaysLeft 0}}
+ <span class="{{if lt .SSLDaysLeft 14}}text-yellow-600 dark:text-yellow-400{{else if lt .SSLDaysLeft 7}}text-red-600 dark:text-red-400{{end}}" data-tooltip='{{.SSLTooltip}}'>SSL: {{.SSLDaysLeft}}d</span>
+ {{end}}
+ {{if .LastError}}
+ {{if .LastError}}<span class="text-red-600 dark:text-red-400 truncate max-w-[200px]" data-tooltip='{"header":"Last Error","error":"{{.LastError}}"}'>{{.LastError}}</span>{{end}}
+ {{end}}
+ </div>
+ </div>
+ <div class="flex items-center gap-2 flex-shrink-0">
+ <span class="text-sm font-medium {{if ge .UptimePercent 99.0}}text-emerald-600 dark:text-emerald-400{{else if ge .UptimePercent 95.0}}text-yellow-600 dark:text-yellow-400{{else}}text-red-600 dark:text-red-400{{end}}">{{formatUptime .UptimePercent}}</span>
+ </div>
+ </div>
+ <!-- History Bar -->
+ <div class="mt-3 flex gap-px">
+ {{range .Ticks}}
+ <div class="flex-1 h-6 rounded-sm {{tickColor .}}" data-tooltip='{{tickTooltipData . $.TickMode $.Timezone}}'></div>
+ {{else}}
+ {{range seq $.TickCount}}
+ <div class="flex-1 h-6 rounded-sm bg-neutral-200 dark:bg-neutral-800" data-tooltip='{"header":"No data"}'></div>
+ {{end}}
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </section>
+ {{end}}
+ </div>
+
+ <!-- Incidents -->
+ {{if .Incidents}}
+ <section class="mt-8">
+ <h2 class="text-lg font-semibold mb-4">Incidents</h2>
+ <div class="space-y-4">
+ {{range .Incidents}}
+ <div class="border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
+ <div class="p-4 {{if .IsActive}}bg-yellow-50 dark:bg-yellow-950/20{{else}}bg-neutral-50 dark:bg-neutral-900/50{{end}}">
+ <div class="flex items-start justify-between gap-4">
+ <div>
+ <div class="flex items-center gap-2 mb-1">
+ {{if eq .Status "resolved"}}
+ <svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
+ </svg>
+ {{else if eq .Status "scheduled"}}
+ <svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
+ </svg>
+ {{else}}
+ <svg class="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ {{end}}
+ <span class="font-medium">{{.Title}}</span>
+ </div>
+ <p class="text-sm text-neutral-600 dark:text-neutral-400">{{.Message}}</p>
+ {{if .IsScheduled}}
+ <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
+ Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}}
+ </p>
+ {{end}}
+ </div>
+ <div class="flex-shrink-0">
+ <span class="text-xs px-2 py-1 rounded-full {{if eq .Status "resolved"}}bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300{{else if eq .Status "scheduled"}}bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300{{else if eq .Status "investigating"}}bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300{{else if eq .Status "identified"}}bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300{{else}}bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300{{end}} capitalize">
+ {{.Status}}
+ </span>
+ </div>
+ </div>
+ </div>
+ {{if .Updates}}
+ <div class="border-t border-neutral-200 dark:border-neutral-800 p-4 space-y-3 bg-white dark:bg-neutral-950">
+ {{range .Updates}}
+ <div class="text-sm">
+ <div class="flex items-center gap-2 text-neutral-500 dark:text-neutral-400 mb-1">
+ <span class="capitalize font-medium">{{.Status}}</span>
+ <span class="text-xs">{{formatTime .Time}}</span>
+ </div>
+ <p class="text-neutral-700 dark:text-neutral-300">{{.Message}}</p>
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ </section>
+ {{end}}
+
+ <!-- Footer -->
+ <footer class="mt-12 pt-6 border-t border-neutral-200 dark:border-neutral-800">
+ <div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400">
+ <span data-tooltip='{{.LastUpdatedTooltip}}'>Updated {{timeAgo .LastUpdated}}</span>
+ <span>Powered by <a href="https://github.com/Fuwn/kaze" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline underline-offset-2">Kaze</a></span>
+ </div>
+ </footer>
+ </div>
+
+ <!-- Tooltip container -->
+ <div id="tooltip" class="tooltip"></div>
+
+ <script>
+ function toggleTheme() {
+ if (document.documentElement.classList.contains('dark')) {
+ document.documentElement.classList.remove('dark');
+ localStorage.theme = 'light';
+ } else {
+ document.documentElement.classList.add('dark');
+ localStorage.theme = 'dark';
+ }
+ }
+
+ // Group collapse/expand functionality
+ function toggleGroup(groupName) {
+ const content = document.querySelector('[data-group-content="' + groupName + '"]');
+ const icon = document.querySelector('[data-group-icon="' + groupName + '"]');
+
+ if (!content || !icon) return;
+
+ const isCollapsed = content.classList.contains('collapsed');
+
+ if (isCollapsed) {
+ content.classList.remove('collapsed');
+ icon.classList.remove('rotated');
+ localStorage.setItem('group-' + groupName, 'expanded');
+ } else {
+ content.classList.add('collapsed');
+ icon.classList.add('rotated');
+ localStorage.setItem('group-' + groupName, 'collapsed');
+ }
+ }
+
+ // Initialize group states on page load
+ (function initGroupStates() {
+ document.querySelectorAll('[data-group-content]').forEach(function(content) {
+ const groupName = content.getAttribute('data-group-content');
+ const defaultCollapsed = content.getAttribute('data-default-collapsed') === 'true';
+ const savedState = localStorage.getItem('group-' + groupName);
+ const icon = document.querySelector('[data-group-icon="' + groupName + '"]');
+
+ // Determine initial state: localStorage > config default > expanded
+ let shouldCollapse = false;
+ if (savedState !== null) {
+ shouldCollapse = savedState === 'collapsed';
+ } else {
+ shouldCollapse = defaultCollapsed;
+ }
+
+ if (shouldCollapse) {
+ content.classList.add('collapsed');
+ if (icon) icon.classList.add('rotated');
+ }
+ });
+ })();
+
+ // Custom tooltip handling
+ (function() {
+ const tooltip = document.getElementById('tooltip');
+ let currentTarget = null;
+ let hideTimeout = null;
+
+ function renderTooltip(data) {
+ let html = '<span class="tooltip-header">' + data.header + '</span>';
+ if (data.error) {
+ html += '<div style="white-space:normal;max-width:260px;margin-top:0.25rem">' + data.error + '</div>';
+ } else if (data.rows) {
+ data.rows.forEach(function(row) {
+ html += '<div class="tooltip-row">';
+ html += '<span class="tooltip-label">' + row.label + '</span>';
+ html += '<span class="tooltip-value ' + (row.class || '') + '">' + row.value + '</span>';
+ html += '</div>';
+ });
+ }
+ return html;
+ }
+
+ function showTooltip(e) {
+ const target = e.target.closest('[data-tooltip]');
+ if (!target) return;
+
+ clearTimeout(hideTimeout);
+ currentTarget = target;
+
+ // Parse and render tooltip content
+ try {
+ const data = JSON.parse(target.getAttribute('data-tooltip'));
+ tooltip.innerHTML = renderTooltip(data);
+ } catch (err) {
+ tooltip.innerHTML = target.getAttribute('data-tooltip');
+ }
+
+ // Make visible to calculate dimensions
+ tooltip.classList.add('visible');
+
+ // Position tooltip
+ const rect = target.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+
+ // Calculate horizontal position (center above the element)
+ let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
+
+ // Keep tooltip within viewport horizontally
+ const padding = 8;
+ if (left < padding) {
+ left = padding;
+ } else if (left + tooltipRect.width > window.innerWidth - padding) {
+ left = window.innerWidth - tooltipRect.width - padding;
+ }
+
+ // Calculate vertical position (above element by default)
+ let top = rect.top - tooltipRect.height - 8;
+
+ // If not enough space above, show below
+ if (top < padding) {
+ top = rect.bottom + 8;
+ tooltip.classList.add('tooltip-top');
+ } else {
+ tooltip.classList.remove('tooltip-top');
+ }
+
+ tooltip.style.left = left + 'px';
+ tooltip.style.top = top + 'px';
+ }
+
+ function hideTooltip() {
+ hideTimeout = setTimeout(() => {
+ tooltip.classList.remove('visible');
+ currentTarget = null;
+ }, 100);
+ }
+
+ // Event delegation for tooltip triggers
+ document.addEventListener('mouseenter', showTooltip, true);
+ document.addEventListener('mouseleave', function(e) {
+ if (e.target.closest('[data-tooltip]')) {
+ hideTooltip();
+ }
+ }, true);
+
+ // Handle touch devices
+ document.addEventListener('touchstart', function(e) {
+ const target = e.target.closest('[data-tooltip]');
+ if (target) {
+ if (currentTarget === target) {
+ hideTooltip();
+ } else {
+ showTooltip(e);
+ }
+ } else {
+ hideTooltip();
+ }
+ }, { passive: true });
+ })();
+
+ // Auto-refresh every 30 seconds
+ setTimeout(() => location.reload(), 30000);
+ </script>
+</body>
+</html>