init work of uptime
This commit is contained in:
342
internal/web/handler.go
Normal file
342
internal/web/handler.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arclineit/arcline-uptime/internal/config"
|
||||
"arclineit/arcline-uptime/internal/store"
|
||||
)
|
||||
|
||||
// MonitorStatus is the view model for the main dashboard page.
|
||||
type MonitorStatus struct {
|
||||
Name string
|
||||
Up bool
|
||||
StatusCode int
|
||||
ResponseMS int64
|
||||
Error string
|
||||
CheckedAt time.Time
|
||||
Uptime24h float64
|
||||
Uptime7d float64
|
||||
Uptime30d float64
|
||||
}
|
||||
|
||||
// SparklineData is the view model for the history page.
|
||||
type SparklineData struct {
|
||||
Name string
|
||||
Points string // SVG polyline points for all checks
|
||||
DownDotXs []int // X coordinates of failed checks (rendered as red dots at y=55)
|
||||
MaxMS int64
|
||||
AvgMS float64
|
||||
Count int
|
||||
}
|
||||
|
||||
// IncidentView is the view model for the incidents page.
|
||||
type IncidentView struct {
|
||||
MonitorName string
|
||||
StartedAt time.Time
|
||||
ResolvedAt time.Time
|
||||
Duration string
|
||||
Ongoing bool
|
||||
}
|
||||
|
||||
// NewServer creates the HTTP server for the dashboard.
|
||||
func NewServer(cfg *config.Config, s *store.Store) *http.Server {
|
||||
h := &handler{store: s, cfg: cfg}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/metrics", h.handleMetrics) // no auth — Prometheus scraping
|
||||
mux.HandleFunc("/status", h.handlePublicStatus) // no auth — public status page
|
||||
mux.HandleFunc("/incidents", h.auth(h.handleIncidents))
|
||||
mux.HandleFunc("/history", h.auth(h.handleHistory))
|
||||
mux.HandleFunc("/", h.auth(h.handleIndex))
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.Dashboard.Listen,
|
||||
Handler: mux,
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
store *store.Store
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// auth wraps a handler with HTTP Basic Auth if credentials are configured.
|
||||
func (h *handler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
if h.cfg.Dashboard.Username == "" {
|
||||
return next
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(h.cfg.Dashboard.Username))
|
||||
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(h.cfg.Dashboard.Password))
|
||||
if !ok || userOK != 1 || passOK != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="arcline-uptime"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
func (h *handler) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
names := h.monitorNames()
|
||||
now := time.Now()
|
||||
|
||||
statuses := make([]MonitorStatus, 0, len(names))
|
||||
for _, name := range names {
|
||||
ms := MonitorStatus{Name: name}
|
||||
if row, err := h.store.LatestResult(name); err == nil && row != nil {
|
||||
ms.Up = row.Up
|
||||
ms.StatusCode = row.StatusCode
|
||||
ms.ResponseMS = row.ResponseMS
|
||||
ms.Error = row.Error
|
||||
ms.CheckedAt = row.CheckedAt
|
||||
}
|
||||
ms.Uptime24h, _ = h.store.UptimePercent(name, now.Add(-24*time.Hour))
|
||||
ms.Uptime7d, _ = h.store.UptimePercent(name, now.AddDate(0, 0, -7))
|
||||
ms.Uptime30d, _ = h.store.UptimePercent(name, now.AddDate(0, 0, -30))
|
||||
statuses = append(statuses, ms)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Now time.Time
|
||||
Monitors []MonitorStatus
|
||||
}{Now: now.UTC(), Monitors: statuses}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := indexTmpl.Execute(w, data); err != nil {
|
||||
slog.Error("render index", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
names := h.monitorNames()
|
||||
|
||||
sparklines := make([]SparklineData, 0, len(names))
|
||||
for _, name := range names {
|
||||
rows, err := h.store.RecentChecks(name, 100)
|
||||
if err != nil {
|
||||
slog.Error("fetch history", "monitor", name, "error", err)
|
||||
continue
|
||||
}
|
||||
sparklines = append(sparklines, buildSparkline(name, rows))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := historyTmpl.Execute(w, sparklines); err != nil {
|
||||
slog.Error("render history", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleIncidents(w http.ResponseWriter, r *http.Request) {
|
||||
incidents, err := h.store.Incidents(100)
|
||||
if err != nil {
|
||||
slog.Error("fetch incidents", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]IncidentView, 0, len(incidents))
|
||||
for _, inc := range incidents {
|
||||
v := IncidentView{
|
||||
MonitorName: inc.MonitorName,
|
||||
StartedAt: inc.StartedAt,
|
||||
ResolvedAt: inc.ResolvedAt,
|
||||
Ongoing: inc.Ongoing(),
|
||||
}
|
||||
if !inc.Ongoing() {
|
||||
v.Duration = formatDuration(inc.Duration())
|
||||
} else {
|
||||
v.Duration = formatDuration(inc.Duration()) + " (ongoing)"
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := incidentsTmpl.Execute(w, views); err != nil {
|
||||
slog.Error("render incidents", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handlePublicStatus(w http.ResponseWriter, r *http.Request) {
|
||||
names := h.monitorNames()
|
||||
now := time.Now()
|
||||
|
||||
type publicStatus struct {
|
||||
Name string
|
||||
Up bool
|
||||
Uptime24h float64
|
||||
}
|
||||
|
||||
statuses := make([]publicStatus, 0, len(names))
|
||||
allUp := true
|
||||
for _, name := range names {
|
||||
ps := publicStatus{Name: name}
|
||||
if row, err := h.store.LatestResult(name); err == nil && row != nil {
|
||||
ps.Up = row.Up
|
||||
if !row.Up {
|
||||
allUp = false
|
||||
}
|
||||
}
|
||||
ps.Uptime24h, _ = h.store.UptimePercent(name, now.Add(-24*time.Hour))
|
||||
statuses = append(statuses, ps)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
AllUp bool
|
||||
Monitors []publicStatus
|
||||
UpdatedAt time.Time
|
||||
}{AllUp: allUp, Monitors: statuses, UpdatedAt: now.UTC()}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := statusTmpl.Execute(w, data); err != nil {
|
||||
slog.Error("render status", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
names := h.monitorNames()
|
||||
now := time.Now()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# HELP arcline_uptime_up Whether the last check succeeded (1=up, 0=down)\n")
|
||||
sb.WriteString("# TYPE arcline_uptime_up gauge\n")
|
||||
for _, name := range names {
|
||||
val := 0
|
||||
if row, err := h.store.LatestResult(name); err == nil && row != nil && row.Up {
|
||||
val = 1
|
||||
}
|
||||
fmt.Fprintf(&sb, "arcline_uptime_up{monitor=%q} %d\n", name, val)
|
||||
}
|
||||
|
||||
sb.WriteString("# HELP arcline_uptime_response_ms Response time of the last check in milliseconds\n")
|
||||
sb.WriteString("# TYPE arcline_uptime_response_ms gauge\n")
|
||||
for _, name := range names {
|
||||
var ms int64
|
||||
if row, err := h.store.LatestResult(name); err == nil && row != nil {
|
||||
ms = row.ResponseMS
|
||||
}
|
||||
fmt.Fprintf(&sb, "arcline_uptime_response_ms{monitor=%q} %d\n", name, ms)
|
||||
}
|
||||
|
||||
sb.WriteString("# HELP arcline_uptime_uptime_24h Uptime percentage over the last 24 hours\n")
|
||||
sb.WriteString("# TYPE arcline_uptime_uptime_24h gauge\n")
|
||||
for _, name := range names {
|
||||
pct, _ := h.store.UptimePercent(name, now.Add(-24*time.Hour))
|
||||
fmt.Fprintf(&sb, "arcline_uptime_uptime_24h{monitor=%q} %.4f\n", name, pct)
|
||||
}
|
||||
|
||||
sb.WriteString("# HELP arcline_uptime_uptime_7d Uptime percentage over the last 7 days\n")
|
||||
sb.WriteString("# TYPE arcline_uptime_uptime_7d gauge\n")
|
||||
for _, name := range names {
|
||||
pct, _ := h.store.UptimePercent(name, now.AddDate(0, 0, -7))
|
||||
fmt.Fprintf(&sb, "arcline_uptime_uptime_7d{monitor=%q} %.4f\n", name, pct)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
fmt.Fprint(w, sb.String())
|
||||
}
|
||||
|
||||
// monitorNames returns configured monitors first (in order), then any DB-only names.
|
||||
func (h *handler) monitorNames() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
for _, m := range h.cfg.Monitors {
|
||||
if !seen[m.Name] {
|
||||
seen[m.Name] = true
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
if dbNames, err := h.store.AllMonitorNames(); err == nil {
|
||||
for _, n := range dbNames {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// buildSparkline converts a slice of CheckRows into SVG data.
|
||||
// Rows are expected newest-first; they are reversed to plot oldest→newest left→right.
|
||||
func buildSparkline(name string, rows []store.CheckRow) SparklineData {
|
||||
sd := SparklineData{Name: name}
|
||||
if len(rows) == 0 {
|
||||
return sd
|
||||
}
|
||||
|
||||
n := len(rows)
|
||||
rev := make([]store.CheckRow, n)
|
||||
for i, r := range rows {
|
||||
rev[n-1-i] = r
|
||||
}
|
||||
|
||||
var total, maxMS int64
|
||||
for i, r := range rev {
|
||||
total += r.ResponseMS
|
||||
if r.ResponseMS > maxMS {
|
||||
maxMS = r.ResponseMS
|
||||
}
|
||||
x := 0
|
||||
if n > 1 {
|
||||
x = i * 400 / (n - 1)
|
||||
}
|
||||
if !r.Up {
|
||||
sd.DownDotXs = append(sd.DownDotXs, x)
|
||||
}
|
||||
}
|
||||
sd.MaxMS = maxMS
|
||||
sd.AvgMS = float64(total) / float64(n)
|
||||
sd.Count = n
|
||||
|
||||
const (
|
||||
svgW = 400
|
||||
svgH = 60
|
||||
padding = 5
|
||||
)
|
||||
|
||||
var pts strings.Builder
|
||||
for i, r := range rev {
|
||||
x := 0
|
||||
if n > 1 {
|
||||
x = i * svgW / (n - 1)
|
||||
}
|
||||
var y int
|
||||
if maxMS > 0 {
|
||||
norm := float64(r.ResponseMS) / float64(maxMS)
|
||||
y = padding + int((1.0-norm)*float64(svgH-2*padding))
|
||||
} else {
|
||||
y = svgH - padding
|
||||
}
|
||||
fmt.Fprintf(&pts, "%d,%d ", x, y)
|
||||
}
|
||||
sd.Points = strings.TrimSpace(pts.String())
|
||||
return sd
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
s := int(d.Seconds()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||
}
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm %ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
234
internal/web/templates.go
Normal file
234
internal/web/templates.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"fmtTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"uptimeClass": func(pct float64) string {
|
||||
switch {
|
||||
case pct >= 99:
|
||||
return "good"
|
||||
case pct >= 95:
|
||||
return "warn"
|
||||
default:
|
||||
return "bad"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
indexTmpl = template.Must(template.New("index").Funcs(funcMap).Parse(indexHTML))
|
||||
historyTmpl = template.Must(template.New("history").Funcs(funcMap).Parse(historyHTML))
|
||||
incidentsTmpl = template.Must(template.New("incidents").Funcs(funcMap).Parse(incidentsHTML))
|
||||
statusTmpl = template.Must(template.New("status").Funcs(funcMap).Parse(statusHTML))
|
||||
)
|
||||
|
||||
const sharedCSS = `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Courier New', monospace; background: #0f0f0f; color: #d4d4d4; padding: 2rem; }
|
||||
h1 { color: #e2e2e2; margin-bottom: 0.25rem; font-size: 1.4rem; letter-spacing: 0.05em; }
|
||||
.subtitle { color: #666; font-size: 0.8rem; margin-bottom: 1.5rem; }
|
||||
nav { margin-bottom: 1.5rem; }
|
||||
nav a { color: #6b9edb; text-decoration: none; margin-right: 1.5rem; font-size: 0.85rem; }
|
||||
nav a:hover { color: #90bfff; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 0.875rem; }
|
||||
th { text-align: left; padding: 0.5rem 1rem; color: #888; font-weight: normal; border-bottom: 1px solid #2a2a2a; }
|
||||
td { padding: 0.5rem 1rem; border-bottom: 1px solid #1e1e1e; }
|
||||
tr:hover td { background: #161616; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: bold; letter-spacing: 0.05em; }
|
||||
.badge-up { background: #1a3a1a; color: #4caf50; border: 1px solid #2d5a2d; }
|
||||
.badge-down { background: #3a1a1a; color: #f44336; border: 1px solid #5a2d2d; }
|
||||
.good { color: #4caf50; }
|
||||
.warn { color: #ff9800; }
|
||||
.bad { color: #f44336; }
|
||||
.dim { color: #555; font-size: 0.8rem; }`
|
||||
|
||||
const sharedNav = `<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/history">History</a>
|
||||
<a href="/incidents">Incidents</a>
|
||||
<a href="/metrics">Metrics</a>
|
||||
</nav>`
|
||||
|
||||
const indexHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>arcline-uptime</title>
|
||||
<style>` + sharedCSS + `</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>arcline-uptime</h1>
|
||||
<p class="subtitle">Auto-refreshes every 30s — {{fmtTime .Now}} UTC</p>
|
||||
` + sharedNav + `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monitor</th>
|
||||
<th>Status</th>
|
||||
<th>Response</th>
|
||||
<th>24h</th>
|
||||
<th>7d</th>
|
||||
<th>30d</th>
|
||||
<th>Last Check</th>
|
||||
<th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Monitors}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="badge {{if .Up}}badge-up{{else}}badge-down{{end}}">{{if .Up}}UP{{else}}DOWN{{end}}</span></td>
|
||||
<td>{{if .ResponseMS}}{{.ResponseMS}}ms{{else}}<span class="dim">—</span>{{end}}</td>
|
||||
<td class="{{uptimeClass .Uptime24h}}">{{printf "%.2f" .Uptime24h}}%</td>
|
||||
<td class="{{uptimeClass .Uptime7d}}">{{printf "%.2f" .Uptime7d}}%</td>
|
||||
<td class="{{uptimeClass .Uptime30d}}">{{printf "%.2f" .Uptime30d}}%</td>
|
||||
<td class="dim">{{fmtTime .CheckedAt}}</td>
|
||||
<td class="dim">{{if .Error}}{{.Error}}{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const historyHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>arcline-uptime — History</title>
|
||||
<style>` + sharedCSS + `
|
||||
h2 { color: #bbb; font-size: 1rem; margin: 2rem 0 0.5rem; font-weight: normal; }
|
||||
.sparkline-wrap { background: #161616; border: 1px solid #2a2a2a; border-radius: 4px; padding: 0.5rem; display: inline-block; }
|
||||
svg polyline { fill: none; stroke: #4caf5088; stroke-width: 1.5; }
|
||||
svg circle.down { fill: #f44336; }
|
||||
.stats { color: #666; font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
.none { color: #444; font-style: italic; font-size: 0.875rem; }</style>
|
||||
</head>
|
||||
<body>
|
||||
` + sharedNav + `
|
||||
<h1>Response Time History <span style="color:#555;font-size:0.8rem">(last 100 checks per monitor)</span></h1>
|
||||
{{range .}}
|
||||
<h2>{{.Name}}</h2>
|
||||
{{if .Points}}
|
||||
<div class="sparkline-wrap">
|
||||
<svg width="400" height="60" viewBox="0 0 400 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<polyline points="{{.Points}}"/>
|
||||
{{range .DownDotXs}}
|
||||
<circle class="down" cx="{{.}}" cy="55" r="2.5"/>
|
||||
{{end}}
|
||||
</svg>
|
||||
</div>
|
||||
<p class="stats">max: {{.MaxMS}}ms avg: {{printf "%.0f" .AvgMS}}ms checks: {{.Count}}</p>
|
||||
{{else}}
|
||||
<p class="none">No data yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const incidentsHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>arcline-uptime — Incidents</title>
|
||||
<style>` + sharedCSS + `
|
||||
.ongoing { color: #f44336; font-weight: bold; }
|
||||
.resolved { color: #555; }</style>
|
||||
</head>
|
||||
<body>
|
||||
` + sharedNav + `
|
||||
<h1>Incident Log</h1>
|
||||
<p class="subtitle" style="margin-bottom:1rem">All outage periods, newest first.</p>
|
||||
{{if .}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monitor</th>
|
||||
<th>Started</th>
|
||||
<th>Resolved</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td>{{.MonitorName}}</td>
|
||||
<td class="dim">{{fmtTime .StartedAt}}</td>
|
||||
<td class="dim">{{if .Ongoing}}—{{else}}{{fmtTime .ResolvedAt}}{{end}}</td>
|
||||
<td>{{.Duration}}</td>
|
||||
<td>{{if .Ongoing}}<span class="ongoing">ONGOING</span>{{else}}<span class="resolved">resolved</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p style="color:#555;margin-top:1rem">No incidents recorded.</p>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const statusHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<title>Service Status</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #fafafa; color: #333; max-width: 700px; margin: 3rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.banner { padding: 1rem 1.5rem; border-radius: 6px; margin: 1.5rem 0; font-weight: 600; font-size: 1.1rem; }
|
||||
.banner-ok { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.banner-down { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; color: #888; font-weight: 500; border-bottom: 1px solid #e5e7eb; font-size: 0.85rem; }
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6; font-size: 0.9rem; }
|
||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot-up { background: #22c55e; }
|
||||
.dot-down { background: #ef4444; }
|
||||
.uptime { color: #888; font-size: 0.85rem; }
|
||||
.footer { margin-top: 2rem; font-size: 0.75rem; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Service Status</h1>
|
||||
{{if .AllUp}}
|
||||
<div class="banner banner-ok">✓ All systems operational</div>
|
||||
{{else}}
|
||||
<div class="banner banner-down">⚠ Some services are experiencing issues</div>
|
||||
{{end}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Service</th><th>Status</th><th>Uptime (24h)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Monitors}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>
|
||||
<span class="dot {{if .Up}}dot-up{{else}}dot-down{{end}}"></span>
|
||||
{{if .Up}}Operational{{else}}Disrupted{{end}}
|
||||
</td>
|
||||
<td class="uptime">{{printf "%.2f" .Uptime24h}}%</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="footer">Last updated: {{fmtTime .UpdatedAt}} UTC</p>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user