init work of uptime

This commit is contained in:
Blake Ridgway
2026-03-22 11:30:31 -05:00
parent 854cba4c24
commit f0db70c840
18 changed files with 2252 additions and 0 deletions

342
internal/web/handler.go Normal file
View 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
View 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 &mdash; {{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 &nbsp; avg: {{printf "%.0f" .AvgMS}}ms &nbsp; 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">&#x2713; All systems operational</div>
{{else}}
<div class="banner banner-down">&#x26A0; 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>`