diff options
| author | Fuwn <[email protected]> | 2026-01-20 06:32:58 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-20 06:32:58 -0800 |
| commit | a5e4fa5f372c6b2fb6e6194094015e5744c410dc (patch) | |
| tree | 27378a93fdc8d27cebf79ca2c0e11480af68a8b6 | |
| parent | feat: Add custom_head option for injecting HTML into head (diff) | |
| download | kaze-a5e4fa5f372c6b2fb6e6194094015e5744c410dc.tar.xz kaze-a5e4fa5f372c6b2fb6e6194094015e5744c410dc.zip | |
feat: Add uptime tooltip showing last failure time and reason
| -rw-r--r-- | internal/server/server.go | 122 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 2 | ||||
| -rw-r--r-- | internal/storage/sqlite.go | 32 |
3 files changed, 111 insertions, 45 deletions
diff --git a/internal/server/server.go b/internal/server/server.go index 4217dd2..499ca31 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -162,12 +162,15 @@ type MonitorData struct { ResponseTime int64 HidePing bool // Hide response time from display UptimePercent float64 + UptimeTooltip string // JSON data for uptime tooltip with last failure info 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 + LastFailure *time.Time // Time of last failure + LastFailureError string // Error from last failure DisablePingTooltips bool } @@ -269,6 +272,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { md.SSLTooltip = formatSSLTooltip(*stat.SSLExpiry, stat.SSLDaysLeft, s.config.Display.Timezone) } + // Set last failure info and uptime tooltip + md.LastFailure = stat.LastFailure + md.LastFailureError = stat.LastFailureError + md.UptimeTooltip = formatUptimeTooltip(stat.UptimePercent, stat.TotalChecks, stat.LastFailure, stat.LastFailureError, s.config.Display.Timezone) + // Track most recent check time for footer if stat.LastCheck.After(mostRecentCheck) { mostRecentCheck = stat.LastCheck @@ -559,50 +567,7 @@ func templateFuncs() template.FuncMap { } return "bg-neutral-200 dark:bg-neutral-800" }, - "simplifyError": func(err string) string { - if err == "" { - return "" - } - // Common error patterns to simplify - switch { - case strings.Contains(err, "no such host"): - return "DNS lookup failed" - case strings.Contains(err, "connection refused"): - return "Connection refused" - case strings.Contains(err, "connection reset"): - return "Connection reset" - case strings.Contains(err, "timeout"): - return "Timeout" - case strings.Contains(err, "certificate"): - return "SSL/TLS error" - case strings.Contains(err, "EOF"): - return "Connection closed" - case strings.Contains(err, "status code"): - // Extract status code if present - if idx := strings.Index(err, "status code"); idx != -1 { - rest := err[idx:] - // Try to find the number - for i, c := range rest { - if c >= '0' && c <= '9' { - end := i - for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' { - end++ - } - return "HTTP " + rest[i:end] - } - } - } - return "Unexpected status" - case strings.Contains(err, "expected content"): - return "Content mismatch" - case strings.Contains(err, "i/o timeout"): - return "I/O timeout" - case strings.Contains(err, "network is unreachable"): - return "Network unreachable" - default: - return "Error" - } - }, + "simplifyError": simplifyErrorMessage, "tickTooltipData": func(tick *storage.TickData, mode, timezone string, hidePing bool) string { if tick == nil { data := map[string]interface{}{"header": "No data"} @@ -912,6 +877,75 @@ func formatLastUpdatedTooltip(t time.Time, timezone string) string { return string(b) } +// formatUptimeTooltip creates JSON data for uptime percentage tooltip +func formatUptimeTooltip(uptimePercent float64, totalChecks int64, lastFailure *time.Time, lastFailureError string, timezone string) string { + loc := time.Local + + if timezone != "" && timezone != "Local" && timezone != "Browser" { + if l, err := time.LoadLocation(timezone); err == nil { + loc = l + } + } + + rows := []map[string]string{ + {"label": "Uptime", "value": fmt.Sprintf("%.2f%%", uptimePercent), "class": ""}, + {"label": "Total Checks", "value": fmt.Sprintf("%d", totalChecks), "class": ""}, + } + + if lastFailure != nil { + t := lastFailure.In(loc) + failureTime := t.Format("Jan 2, 2006 15:04:05") + rows = append(rows, map[string]string{"label": "Last Failure", "value": failureTime, "class": "error"}) + + if lastFailureError != "" { + // Simplify the error for display + simplifiedError := simplifyErrorMessage(lastFailureError) + rows = append(rows, map[string]string{"label": "Failure Reason", "value": simplifiedError, "class": ""}) + } + } else { + rows = append(rows, map[string]string{"label": "Last Failure", "value": "Never", "class": "success"}) + } + + data := map[string]interface{}{ + "header": "Uptime Statistics", + "rows": rows, + } + + b, _ := json.Marshal(data) + return string(b) +} + +// simplifyErrorMessage simplifies error messages for display +func simplifyErrorMessage(err string) string { + if err == "" { + return "" + } + switch { + case strings.Contains(err, "no such host"): + return "DNS lookup failed" + case strings.Contains(err, "connection refused"): + return "Connection refused" + case strings.Contains(err, "connection reset"): + return "Connection reset" + case strings.Contains(err, "timeout"): + return "Timeout" + case strings.Contains(err, "certificate"): + return "SSL/TLS error" + case strings.Contains(err, "EOF"): + return "Connection closed" + case strings.Contains(err, "status code"): + return "Unexpected status" + case strings.Contains(err, "expected content"): + return "Content mismatch" + case strings.Contains(err, "i/o timeout"): + return "I/O timeout" + case strings.Contains(err, "network is unreachable"): + return "Network unreachable" + default: + return "Error" + } +} + // statusToClass converts a status to a CSS class func statusToClass(status string) string { switch status { diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index cd37584..c192a66 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -104,7 +104,7 @@ </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> + <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}}"{{if not $monitor.DisablePingTooltips}} data-tooltip='{{.UptimeTooltip}}'{{end}}>{{formatUptime .UptimePercent}}</span> </div> </div> <!-- History Bar --> diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 48cf432..ce62e67 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -52,6 +52,8 @@ type MonitorStats struct { SSLExpiry *time.Time SSLDaysLeft int TotalChecks int64 + LastFailure *time.Time // Time of last failure + LastFailureError string // Error message from last failure } // TickData represents aggregated data for one tick in the history bar @@ -375,6 +377,36 @@ func (s *Storage) GetAllMonitorStats(ctx context.Context) (map[string]*MonitorSt } } + // Get last failure for each monitor + rows, err = s.db.QueryContext(ctx, ` + SELECT monitor_name, timestamp, error + FROM check_results + WHERE status != 'up' AND error != '' + AND (monitor_name, timestamp) IN ( + SELECT monitor_name, MAX(timestamp) + FROM check_results + WHERE status != 'up' AND error != '' + GROUP BY monitor_name + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to query last failures: %w", err) + } + defer rows.Close() + + for rows.Next() { + var name string + var timestamp time.Time + var errorMsg string + if err := rows.Scan(&name, ×tamp, &errorMsg); err != nil { + return nil, fmt.Errorf("failed to scan last failure: %w", err) + } + if ms, ok := stats[name]; ok { + ms.LastFailure = ×tamp + ms.LastFailureError = errorMsg + } + } + return stats, nil } |