diff options
| author | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-17 23:17:49 -0800 |
| commit | 4bc6165258cd7b5b76ccb01aa75c7cefdc35d143 (patch) | |
| tree | e7c3bb335a1efd48f82d365169e8b4a66b7abe1d /internal/server | |
| download | kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.tar.xz kaze-4bc6165258cd7b5b76ccb01aa75c7cefdc35d143.zip | |
feat: Initial commit
Diffstat (limited to 'internal/server')
| -rw-r--r-- | internal/server/server.go | 839 | ||||
| -rw-r--r-- | internal/server/static/style.css | 417 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 357 |
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> |