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

183
cmd/arcline-uptime/main.go Normal file
View File

@@ -0,0 +1,183 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"arclineit/arcline-uptime/internal/alert"
"arclineit/arcline-uptime/internal/config"
"arclineit/arcline-uptime/internal/monitor"
"arclineit/arcline-uptime/internal/scheduler"
"arclineit/arcline-uptime/internal/store"
"arclineit/arcline-uptime/internal/web"
)
var version = "dev"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "start":
cmdStart(os.Args[2:])
case "check":
cmdCheck(os.Args[2:])
case "list":
cmdList(os.Args[2:])
case "version":
fmt.Println(version)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Fprintf(os.Stderr, "arcline-uptime %s\n\nUsage:\n", version)
fmt.Fprintf(os.Stderr, " arcline-uptime start [--config FILE] [--db FILE]\n")
fmt.Fprintf(os.Stderr, " arcline-uptime check [--config FILE] [--monitor NAME]\n")
fmt.Fprintf(os.Stderr, " arcline-uptime list [--config FILE]\n")
fmt.Fprintf(os.Stderr, " arcline-uptime version\n")
}
func cmdStart(args []string) {
fs := flag.NewFlagSet("start", flag.ExitOnError)
configPath := fs.String("config", "uptime.yaml", "path to config file")
dbPath := fs.String("db", "uptime.db", "path to SQLite database")
fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
// Configure structured logging as early as possible.
setupLogging(cfg.Global.LogFormat)
db, err := store.Open(*dbPath)
if err != nil {
slog.Error("open database", "error", err)
os.Exit(1)
}
defer db.Close()
checkers, err := monitor.BuildCheckers(cfg.Monitors, time.Duration(cfg.Global.Timeout)*time.Second)
if err != nil {
slog.Error("build monitors", "error", err)
os.Exit(1)
}
alerters, err := alert.BuildNamedAlerters(cfg.Alerts)
if err != nil {
slog.Error("build alerters", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if cfg.Dashboard.Enabled {
srv := web.NewServer(cfg, db)
go func() {
slog.Info("dashboard listening", "addr", cfg.Dashboard.Listen)
if err := srv.ListenAndServe(); err != nil {
slog.Error("dashboard server", "error", err)
}
}()
}
sched := scheduler.New(checkers, alerters, cfg.Monitors, db, cfg.Global)
slog.Info("starting",
"monitors", len(checkers),
"interval", cfg.Global.CheckInterval,
"retention_days", cfg.Global.RetentionDays,
)
sched.Start(ctx)
<-ctx.Done()
slog.Info("shutting down")
sched.Wait()
}
func cmdCheck(args []string) {
fs := flag.NewFlagSet("check", flag.ExitOnError)
configPath := fs.String("config", "uptime.yaml", "path to config file")
monitorName := fs.String("monitor", "", "monitor name (omit to check all)")
fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
checkers, err := monitor.BuildCheckers(cfg.Monitors, time.Duration(cfg.Global.Timeout)*time.Second)
if err != nil {
fmt.Fprintf(os.Stderr, "monitors: %v\n", err)
os.Exit(1)
}
exitCode := 0
for _, c := range checkers {
if *monitorName != "" && c.Name() != *monitorName {
continue
}
r := c.Check()
if r.Up {
fmt.Printf("[OK] %-30s %dms\n", r.MonitorName, r.ResponseTime.Milliseconds())
} else {
fmt.Printf("[DOWN] %-30s %s\n", r.MonitorName, r.Error)
exitCode = 1
}
}
os.Exit(exitCode)
}
func cmdList(args []string) {
fs := flag.NewFlagSet("list", flag.ExitOnError)
configPath := fs.String("config", "uptime.yaml", "path to config file")
fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
fmt.Printf("%-30s %-6s %-10s %-40s\n", "NAME", "TYPE", "INTERVAL", "TARGET")
fmt.Printf("%s %s %s %s\n",
strings.Repeat("-", 30), strings.Repeat("-", 6),
strings.Repeat("-", 10), strings.Repeat("-", 40))
for _, m := range cfg.Monitors {
target := m.URL
if target == "" {
target = fmt.Sprintf("%s:%d", m.Host, m.Port)
}
interval := fmt.Sprintf("%ds", m.Interval)
fmt.Printf("%-30s %-6s %-10s %-40s\n", m.Name, m.Type, interval, target)
}
}
func setupLogging(format string) {
var h slog.Handler
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
if format == "json" {
h = slog.NewJSONHandler(os.Stdout, opts)
} else {
h = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(h))
}

20
go.mod Normal file
View File

@@ -0,0 +1,20 @@
module arclineit/arcline-uptime
go 1.25.7
require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

55
go.sum Normal file
View File

@@ -0,0 +1,55 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

94
internal/alert/alerter.go Normal file
View File

@@ -0,0 +1,94 @@
package alert
import (
"fmt"
"time"
"arclineit/arcline-uptime/internal/config"
"arclineit/arcline-uptime/internal/monitor"
)
// Alerter sends a notification with a subject and body.
type Alerter interface {
Send(subject, body string) error
}
// NamedAlerter wraps an Alerter with its configured name for per-monitor routing.
type NamedAlerter struct {
Name string
Alerter Alerter
}
// BuildNamedAlerters constructs one NamedAlerter per AlertConfig entry.
func BuildNamedAlerters(alerts []config.AlertConfig) ([]NamedAlerter, error) {
out := make([]NamedAlerter, 0, len(alerts))
for _, a := range alerts {
var al Alerter
switch a.Type {
case "discord", "slack":
al = NewDiscordAlerter(a.WebhookURL)
case "email":
al = NewEmailAlerter(a)
case "ntfy":
al = NewNtfyAlerter(a.URL, a.Token)
case "gotify":
al = NewGotifyAlerter(a.URL, a.Token, a.Priority)
default:
return nil, fmt.Errorf("unknown alerter type %q", a.Type)
}
out = append(out, NamedAlerter{Name: a.Name, Alerter: al})
}
return out, nil
}
// BuildAlerters is a convenience shim for callers that don't need routing.
func BuildAlerters(alerts []config.AlertConfig) ([]Alerter, error) {
named, err := BuildNamedAlerters(alerts)
if err != nil {
return nil, err
}
out := make([]Alerter, len(named))
for i, na := range named {
out[i] = na.Alerter
}
return out, nil
}
// FormatDownMessage returns the subject and body for a failed check.
func FormatDownMessage(r monitor.Result) (subject, body string) {
subject = fmt.Sprintf("[DOWN] %s", r.MonitorName)
body = subject + "\n"
if r.Error != "" {
body += r.Error + "\n"
}
if r.StatusCode != 0 {
body += fmt.Sprintf("Status code: %d\n", r.StatusCode)
}
body += fmt.Sprintf("Checked at %s UTC\n", r.CheckedAt.UTC().Format("2006-01-02 15:04:05"))
body += fmt.Sprintf("Response time: %dms", r.ResponseTime.Milliseconds())
return
}
// FormatUpMessage returns the subject and body for a recovery alert.
// downSince is the time the last DOWN alert was sent.
func FormatUpMessage(r monitor.Result, downSince time.Time) (subject, body string) {
subject = fmt.Sprintf("[UP] %s is back up", r.MonitorName)
body = subject + "\n"
body += fmt.Sprintf("Was down for %s\n", FormatDuration(time.Since(downSince)))
body += fmt.Sprintf("Recovered at %s UTC", r.CheckedAt.UTC().Format("2006-01-02 15:04:05"))
return
}
// FormatDuration renders a duration as human-readable text, omitting leading zero units.
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)
}

40
internal/alert/discord.go Normal file
View File

@@ -0,0 +1,40 @@
package alert
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type DiscordAlerter struct {
webhookURL string
client *http.Client
}
func NewDiscordAlerter(webhookURL string) *DiscordAlerter {
return &DiscordAlerter{
webhookURL: webhookURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (d *DiscordAlerter) Send(subject, body string) error {
payload, err := json.Marshal(map[string]string{"content": body})
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
resp, err := d.client.Post(d.webhookURL, "application/json", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("post webhook: %w", err)
}
defer resp.Body.Close()
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("webhook returned %d", resp.StatusCode)
}
return nil
}

34
internal/alert/email.go Normal file
View File

@@ -0,0 +1,34 @@
package alert
import (
"fmt"
"net/smtp"
"strings"
"arclineit/arcline-uptime/internal/config"
)
type EmailAlerter struct {
cfg config.AlertConfig
}
func NewEmailAlerter(cfg config.AlertConfig) *EmailAlerter {
return &EmailAlerter{cfg: cfg}
}
// Send delivers an email via STARTTLS on the configured SMTP server.
// Port 587 with STARTTLS is expected; port 465 (SMTPS) is not supported.
func (e *EmailAlerter) Send(subject, body string) error {
if len(e.cfg.To) == 0 {
return fmt.Errorf("email alerter: no recipients configured")
}
auth := smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.SMTPHost)
toHeader := strings.Join(e.cfg.To, ", ")
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
e.cfg.From, toHeader, subject, body,
)
addr := fmt.Sprintf("%s:%d", e.cfg.SMTPHost, e.cfg.SMTPPort)
return smtp.SendMail(addr, auth, e.cfg.From, []string(e.cfg.To), []byte(msg))
}

59
internal/alert/gotify.go Normal file
View File

@@ -0,0 +1,59 @@
package alert
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// GotifyAlerter sends notifications to a Gotify server.
type GotifyAlerter struct {
baseURL string
token string
priority int
client *http.Client
}
func NewGotifyAlerter(baseURL, token string, priority int) *GotifyAlerter {
if priority == 0 {
priority = 5
}
return &GotifyAlerter{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
priority: priority,
client: &http.Client{Timeout: 10 * time.Second},
}
}
type gotifyPayload struct {
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
}
func (g *GotifyAlerter) Send(subject, body string) error {
payload, err := json.Marshal(gotifyPayload{
Title: subject,
Message: body,
Priority: g.priority,
})
if err != nil {
return fmt.Errorf("marshal gotify payload: %w", err)
}
url := fmt.Sprintf("%s/message?token=%s", g.baseURL, g.token)
resp, err := g.client.Post(url, "application/json", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("post gotify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("gotify returned %d", resp.StatusCode)
}
return nil
}

47
internal/alert/ntfy.go Normal file
View File

@@ -0,0 +1,47 @@
package alert
import (
"fmt"
"net/http"
"strings"
"time"
)
// NtfyAlerter sends notifications via ntfy.sh or a self-hosted ntfy server.
// The URL should be the full topic URL, e.g. "https://ntfy.sh/my-topic".
type NtfyAlerter struct {
url string
token string // optional Bearer token
client *http.Client
}
func NewNtfyAlerter(url, token string) *NtfyAlerter {
return &NtfyAlerter{
url: url,
token: token,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (n *NtfyAlerter) Send(subject, body string) error {
req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build ntfy request: %w", err)
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Title", subject)
if n.token != "" {
req.Header.Set("Authorization", "Bearer "+n.token)
}
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("post ntfy: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %d", resp.StatusCode)
}
return nil
}

260
internal/config/config.go Normal file
View File

@@ -0,0 +1,260 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Global GlobalConfig `yaml:"global"`
Alerts []AlertConfig `yaml:"alerts"`
Monitors []MonitorConfig `yaml:"monitors"`
Dashboard DashboardConfig `yaml:"dashboard"`
}
type GlobalConfig struct {
CheckInterval int `yaml:"check_interval"` // seconds
Timeout int `yaml:"timeout"` // seconds
AlertCooldown int `yaml:"alert_cooldown"` // seconds
RetentionDays int `yaml:"retention_days"` // 0 = keep forever
LogFormat string `yaml:"log_format"` // "text" (default) | "json"
}
// StringSlice accepts either a single YAML string or a YAML sequence.
type StringSlice []string
func (s *StringSlice) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
*s = StringSlice{value.Value}
return nil
}
var strs []string
if err := value.Decode(&strs); err != nil {
return err
}
*s = strs
return nil
}
type AlertConfig struct {
Name string `yaml:"name"` // optional; used for per-monitor routing
Type string `yaml:"type"` // "discord" | "slack" | "email" | "ntfy" | "gotify"
WebhookURL string `yaml:"webhook_url"` // discord / slack
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
From string `yaml:"from"`
To StringSlice `yaml:"to"` // one or more email addresses
Username string `yaml:"username"`
Password string `yaml:"password"`
URL string `yaml:"url"` // ntfy topic URL or gotify server base URL
Token string `yaml:"token"` // ntfy bearer token or gotify app token
Priority int `yaml:"priority"` // gotify priority; default 5
}
// MaintenanceWindow suppresses alerts during a recurring time range.
type MaintenanceWindow struct {
Days []string `yaml:"days"` // abbreviated weekday names ("mon"…"sun"), or "*" for all
Start string `yaml:"start"` // "HH:MM" in server local time
End string `yaml:"end"` // "HH:MM"
}
// Active reports whether the window covers t.
func (w *MaintenanceWindow) Active(t time.Time) bool {
if len(w.Days) > 0 {
day := strings.ToLower(t.Weekday().String()[:3])
found := false
for _, d := range w.Days {
if d == "*" || strings.ToLower(d) == day {
found = true
break
}
}
if !found {
return false
}
}
startH, startM, err1 := parseHHMM(w.Start)
endH, endM, err2 := parseHHMM(w.End)
if err1 != nil || err2 != nil {
return false
}
now := t.Hour()*60 + t.Minute()
start := startH*60 + startM
end := endH*60 + endM
if start <= end {
return now >= start && now < end
}
// Crosses midnight (e.g. 23:0002:00)
return now >= start || now < end
}
func parseHHMM(s string) (int, int, error) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid time %q", s)
}
h, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, err
}
m, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, err
}
return h, m, nil
}
type MonitorConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"` // "http" | "tcp" | "tls" | "dns"
// HTTP
URL string `yaml:"url"`
Method string `yaml:"method"` // default "GET"
Body string `yaml:"body"`
Headers map[string]string `yaml:"headers"`
ExpectedStatus int `yaml:"expected_status"` // default 200
Contains string `yaml:"contains"`
// TCP / TLS / DNS
Host string `yaml:"host"`
Port int `yaml:"port"`
// TLS
ExpiryWarningDays int `yaml:"expiry_warning_days"` // default 14
// DNS
ExpectedIP string `yaml:"expected_ip"` // optional; assert resolved IPs include this
// Thresholds
MaxResponseMS int64 `yaml:"max_response_ms"` // 0 = no threshold
// Per-monitor scheduling
Interval int `yaml:"interval"` // seconds; 0 = global default
Timeout int `yaml:"timeout"` // seconds; 0 = global default
// Alerting
AlertNames []string `yaml:"alert_names"` // empty = all alerters
Maintenance []MaintenanceWindow `yaml:"maintenance"`
}
type DashboardConfig struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(&cfg)
if err := validate(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func applyDefaults(cfg *Config) {
if cfg.Global.CheckInterval == 0 {
cfg.Global.CheckInterval = 60
}
if cfg.Global.Timeout == 0 {
cfg.Global.Timeout = 10
}
if cfg.Global.AlertCooldown == 0 {
cfg.Global.AlertCooldown = 300
}
if cfg.Global.LogFormat == "" {
cfg.Global.LogFormat = "text"
}
if cfg.Dashboard.Listen == "" {
cfg.Dashboard.Listen = ":8081"
}
for i := range cfg.Monitors {
m := &cfg.Monitors[i]
if m.Type == "http" {
if m.ExpectedStatus == 0 {
m.ExpectedStatus = 200
}
if m.Method == "" {
m.Method = "GET"
}
}
if m.Type == "tls" {
if m.ExpiryWarningDays == 0 {
m.ExpiryWarningDays = 14
}
if m.Port == 0 {
m.Port = 443
}
}
if m.Interval == 0 {
m.Interval = cfg.Global.CheckInterval
}
if m.Timeout == 0 {
m.Timeout = cfg.Global.Timeout
}
}
for i := range cfg.Alerts {
if cfg.Alerts[i].Type == "gotify" && cfg.Alerts[i].Priority == 0 {
cfg.Alerts[i].Priority = 5
}
}
}
func validate(cfg *Config) error {
names := make(map[string]bool)
for _, m := range cfg.Monitors {
if m.Name == "" {
return fmt.Errorf("monitor missing name")
}
if names[m.Name] {
return fmt.Errorf("duplicate monitor name: %q", m.Name)
}
names[m.Name] = true
switch m.Type {
case "http":
if m.URL == "" {
return fmt.Errorf("monitor %q: missing url", m.Name)
}
case "tcp", "tls":
if m.Host == "" || m.Port == 0 {
return fmt.Errorf("monitor %q: missing host or port", m.Name)
}
case "dns":
if m.Host == "" {
return fmt.Errorf("monitor %q: missing host", m.Name)
}
default:
return fmt.Errorf("monitor %q: unknown type %q", m.Name, m.Type)
}
}
// Build set of named alerters to validate monitor alert_names references.
alertNames := make(map[string]bool)
for _, a := range cfg.Alerts {
if a.Name != "" {
alertNames[a.Name] = true
}
}
for _, m := range cfg.Monitors {
for _, an := range m.AlertNames {
if !alertNames[an] {
return fmt.Errorf("monitor %q: alert_name %q not defined", m.Name, an)
}
}
}
return nil
}

69
internal/monitor/dns.go Normal file
View File

@@ -0,0 +1,69 @@
package monitor
import (
"context"
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
// DNSChecker resolves a hostname and optionally asserts a specific IP is returned.
type DNSChecker struct {
cfg config.MonitorConfig
timeout time.Duration
}
func NewDNSChecker(cfg config.MonitorConfig, timeout time.Duration) *DNSChecker {
return &DNSChecker{cfg: cfg, timeout: timeout}
}
func (d *DNSChecker) Name() string { return d.cfg.Name }
func (d *DNSChecker) Interval() time.Duration { return time.Duration(d.cfg.Interval) * time.Second }
func (d *DNSChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: d.cfg.Name,
CheckedAt: start,
}
ctx, cancel := context.WithTimeout(context.Background(), d.timeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, d.cfg.Host)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
if d.cfg.ExpectedIP != "" {
want := normalizeIP(d.cfg.ExpectedIP)
found := false
for _, addr := range addrs {
if normalizeIP(addr) == want {
found = true
break
}
}
if !found {
result.Error = fmt.Sprintf("expected IP %q not found in results %v", d.cfg.ExpectedIP, addrs)
return result
}
}
result.Up = true
applyThreshold(&result, d.cfg.MaxResponseMS)
return result
}
// normalizeIP parses and re-serialises an IP string so different representations
// of the same address compare equal (e.g. "::1" and "0:0:0:0:0:0:0:1").
func normalizeIP(s string) string {
if ip := net.ParseIP(s); ip != nil {
return ip.String()
}
return s
}

101
internal/monitor/http.go Normal file
View File

@@ -0,0 +1,101 @@
package monitor
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"arclineit/arcline-uptime/internal/config"
)
type HTTPChecker struct {
cfg config.MonitorConfig
client *http.Client
}
func NewHTTPChecker(cfg config.MonitorConfig, timeout time.Duration) *HTTPChecker {
return &HTTPChecker{
cfg: cfg,
client: &http.Client{
Timeout: timeout,
CheckRedirect: redirectPolicy(cfg.ExpectedStatus),
},
}
}
// redirectPolicy stops at the first redirect when the expected status is 3xx so
// the raw redirect code is returned. Otherwise follows up to 10 redirects.
func redirectPolicy(expected int) func(*http.Request, []*http.Request) error {
if expected >= 300 && expected < 400 {
return func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
}
return nil
}
func (h *HTTPChecker) Name() string { return h.cfg.Name }
func (h *HTTPChecker) Interval() time.Duration { return time.Duration(h.cfg.Interval) * time.Second }
func (h *HTTPChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: h.cfg.Name,
CheckedAt: start,
}
var bodyReader io.Reader
if h.cfg.Body != "" {
bodyReader = strings.NewReader(h.cfg.Body)
}
req, err := http.NewRequest(h.cfg.Method, h.cfg.URL, bodyReader)
if err != nil {
result.Error = fmt.Sprintf("build request: %v", err)
result.ResponseTime = time.Since(start)
return result
}
req.Header.Set("User-Agent", "arcline-uptime/1.0")
for k, v := range h.cfg.Headers {
req.Header.Set(k, v)
}
resp, err := h.client.Do(req)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.StatusCode = resp.StatusCode
// Response time threshold check — before reading body to fail fast.
applyThreshold(&result, h.cfg.MaxResponseMS)
if !result.Up && result.Error != "" {
return result
}
if resp.StatusCode != h.cfg.ExpectedStatus {
result.Error = fmt.Sprintf("expected status %d, got %d", h.cfg.ExpectedStatus, resp.StatusCode)
return result
}
if h.cfg.Contains != "" {
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
result.Error = fmt.Sprintf("read body: %v", err)
return result
}
if !strings.Contains(string(body), h.cfg.Contains) {
result.Error = fmt.Sprintf("body does not contain %q", h.cfg.Contains)
return result
}
}
result.Up = true
applyThreshold(&result, h.cfg.MaxResponseMS)
return result
}

View File

@@ -0,0 +1,59 @@
package monitor
import (
"fmt"
"time"
"arclineit/arcline-uptime/internal/config"
)
// Result is returned by every Checker after a single probe.
type Result struct {
MonitorName string
Up bool
StatusCode int // HTTP only; 0 for TCP/TLS/DNS
ResponseTime time.Duration
Error string // empty when Up == true
CheckedAt time.Time
}
// Checker probes one endpoint.
type Checker interface {
Name() string
Check() Result
Interval() time.Duration // per-monitor interval; 0 means use the global default
}
// BuildCheckers constructs one Checker per MonitorConfig entry.
// globalTimeout is the fallback when a monitor's own Timeout is 0.
func BuildCheckers(monitors []config.MonitorConfig, globalTimeout time.Duration) ([]Checker, error) {
checkers := make([]Checker, 0, len(monitors))
for _, m := range monitors {
timeout := globalTimeout
if m.Timeout > 0 {
timeout = time.Duration(m.Timeout) * time.Second
}
switch m.Type {
case "http":
checkers = append(checkers, NewHTTPChecker(m, timeout))
case "tcp":
checkers = append(checkers, NewTCPChecker(m, timeout))
case "tls":
checkers = append(checkers, NewTLSChecker(m, timeout))
case "dns":
checkers = append(checkers, NewDNSChecker(m, timeout))
default:
return nil, fmt.Errorf("unknown monitor type %q for %q", m.Type, m.Name)
}
}
return checkers, nil
}
// applyThreshold marks a successful result as failed if response time exceeds max.
func applyThreshold(r *Result, maxResponseMS int64) {
if maxResponseMS > 0 && r.Up && r.ResponseTime.Milliseconds() > maxResponseMS {
r.Up = false
r.Error = fmt.Sprintf("response time %dms exceeded threshold %dms",
r.ResponseTime.Milliseconds(), maxResponseMS)
}
}

46
internal/monitor/tcp.go Normal file
View File

@@ -0,0 +1,46 @@
package monitor
import (
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
type TCPChecker struct {
cfg config.MonitorConfig
addr string
timeout time.Duration
}
func NewTCPChecker(cfg config.MonitorConfig, timeout time.Duration) *TCPChecker {
return &TCPChecker{
cfg: cfg,
addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
timeout: timeout,
}
}
func (t *TCPChecker) Name() string { return t.cfg.Name }
func (t *TCPChecker) Interval() time.Duration { return time.Duration(t.cfg.Interval) * time.Second }
func (t *TCPChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: t.cfg.Name,
CheckedAt: start,
}
conn, err := net.DialTimeout("tcp", t.addr, t.timeout)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
conn.Close()
result.Up = true
applyThreshold(&result, t.cfg.MaxResponseMS)
return result
}

68
internal/monitor/tls.go Normal file
View File

@@ -0,0 +1,68 @@
package monitor
import (
"crypto/tls"
"fmt"
"net"
"time"
"arclineit/arcline-uptime/internal/config"
)
// TLSChecker dials a TLS endpoint and alerts when the certificate is about
// to expire (within ExpiryWarningDays days).
type TLSChecker struct {
cfg config.MonitorConfig
timeout time.Duration
}
func NewTLSChecker(cfg config.MonitorConfig, timeout time.Duration) *TLSChecker {
return &TLSChecker{cfg: cfg, timeout: timeout}
}
func (t *TLSChecker) Name() string { return t.cfg.Name }
func (t *TLSChecker) Interval() time.Duration { return time.Duration(t.cfg.Interval) * time.Second }
func (t *TLSChecker) Check() Result {
start := time.Now()
result := Result{
MonitorName: t.cfg.Name,
CheckedAt: start,
}
addr := fmt.Sprintf("%s:%d", t.cfg.Host, t.cfg.Port)
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: t.timeout},
Config: &tls.Config{ServerName: t.cfg.Host},
}
conn, err := dialer.Dial("tcp", addr)
result.ResponseTime = time.Since(start)
if err != nil {
result.Error = err.Error()
return result
}
defer conn.Close()
tlsConn := conn.(*tls.Conn)
state := tlsConn.ConnectionState()
// Find the earliest-expiring certificate in the chain.
var earliest time.Time
for _, cert := range state.PeerCertificates {
if earliest.IsZero() || cert.NotAfter.Before(earliest) {
earliest = cert.NotAfter
}
}
daysLeft := int(time.Until(earliest).Hours() / 24)
if daysLeft <= t.cfg.ExpiryWarningDays {
result.Error = fmt.Sprintf("certificate expires in %d day(s) on %s",
daysLeft, earliest.UTC().Format("2006-01-02"))
return result
}
result.Up = true
applyThreshold(&result, t.cfg.MaxResponseMS)
return result
}

View File

@@ -0,0 +1,198 @@
package scheduler
import (
"context"
"log/slog"
"slices"
"sync"
"time"
"arclineit/arcline-uptime/internal/alert"
"arclineit/arcline-uptime/internal/config"
"arclineit/arcline-uptime/internal/monitor"
"arclineit/arcline-uptime/internal/store"
)
// Scheduler runs each monitor on its configured interval and handles alerting.
type Scheduler struct {
checkers []monitor.Checker
alerters []alert.NamedAlerter
monitorCfgs map[string]config.MonitorConfig
store *store.Store
cfg config.GlobalConfig
wg sync.WaitGroup
}
func New(
checkers []monitor.Checker,
alerters []alert.NamedAlerter,
monitorCfgs []config.MonitorConfig,
s *store.Store,
cfg config.GlobalConfig,
) *Scheduler {
cfgMap := make(map[string]config.MonitorConfig, len(monitorCfgs))
for _, m := range monitorCfgs {
cfgMap[m.Name] = m
}
return &Scheduler{
checkers: checkers,
alerters: alerters,
monitorCfgs: cfgMap,
store: s,
cfg: cfg,
}
}
// Start launches one goroutine per checker plus an optional pruning goroutine.
// Each checker fires immediately, then repeats on its configured interval.
func (s *Scheduler) Start(ctx context.Context) {
for _, c := range s.checkers {
s.wg.Add(1)
go func(c monitor.Checker) {
defer s.wg.Done()
s.runChecker(ctx, c)
}(c)
}
if s.cfg.RetentionDays > 0 {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.runPruner(ctx)
}()
}
}
// Wait blocks until all goroutines have exited.
func (s *Scheduler) Wait() { s.wg.Wait() }
func (s *Scheduler) runChecker(ctx context.Context, c monitor.Checker) {
interval := time.Duration(s.cfg.CheckInterval) * time.Second
if ci := c.Interval(); ci > 0 {
interval = ci
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s.probe(c)
select {
case <-ticker.C:
case <-ctx.Done():
return
}
}
}
func (s *Scheduler) runPruner(ctx context.Context) {
s.prune()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.prune()
case <-ctx.Done():
return
}
}
}
func (s *Scheduler) prune() {
n, err := s.store.Prune(s.cfg.RetentionDays)
if err != nil {
slog.Error("prune failed", "error", err)
return
}
if n > 0 {
slog.Info("pruned old records", "deleted", n, "retention_days", s.cfg.RetentionDays)
}
}
func (s *Scheduler) probe(c monitor.Checker) {
mcfg := s.monitorCfgs[c.Name()]
if inMaintenanceWindow(mcfg.Maintenance) {
slog.Debug("skipping probe: maintenance window", "monitor", c.Name())
return
}
result := c.Check()
if err := s.store.SaveResult(result); err != nil {
slog.Error("save result", "monitor", result.MonitorName, "error", err)
}
s.handleAlerts(result, mcfg)
if result.Up {
slog.Info("check ok", "monitor", result.MonitorName, "ms", result.ResponseTime.Milliseconds())
} else {
slog.Warn("check failed", "monitor", result.MonitorName, "error", result.Error)
}
}
func (s *Scheduler) handleAlerts(r monitor.Result, mcfg config.MonitorConfig) {
cooldown := time.Duration(s.cfg.AlertCooldown) * time.Second
if !r.Up {
lastDown, err := s.store.LastAlertSent(r.MonitorName, "down")
if err != nil {
slog.Error("query last-down alert", "monitor", r.MonitorName, "error", err)
return
}
if lastDown.IsZero() || time.Since(lastDown) >= cooldown {
subject, body := alert.FormatDownMessage(r)
s.sendRouted(mcfg, "down", subject, body)
}
return
}
// Monitor is up — send recovery if the last alert was a down.
lastDown, err := s.store.LastAlertSent(r.MonitorName, "down")
if err != nil {
slog.Error("query last-down alert", "monitor", r.MonitorName, "error", err)
return
}
if lastDown.IsZero() {
return
}
lastUp, err := s.store.LastAlertSent(r.MonitorName, "up")
if err != nil {
slog.Error("query last-up alert", "monitor", r.MonitorName, "error", err)
return
}
if lastUp.Before(lastDown) {
subject, body := alert.FormatUpMessage(r, lastDown)
s.sendRouted(mcfg, "up", subject, body)
slog.Info("recovery alert sent", "monitor", r.MonitorName,
"down_duration", alert.FormatDuration(time.Since(lastDown)))
}
}
// sendRouted sends to alerters matching the monitor's alert_names filter.
// If alert_names is empty, sends to all alerters.
func (s *Scheduler) sendRouted(mcfg config.MonitorConfig, kind, subject, body string) {
for _, na := range s.alerters {
if len(mcfg.AlertNames) > 0 && !slices.Contains(mcfg.AlertNames, na.Name) {
continue
}
if err := na.Alerter.Send(subject, body); err != nil {
slog.Error("send alert", "kind", kind, "monitor", mcfg.Name, "alerter", na.Name, "error", err)
}
}
if err := s.store.RecordAlertSent(mcfg.Name, kind); err != nil {
slog.Error("record alert", "monitor", mcfg.Name, "error", err)
}
}
// inMaintenanceWindow reports whether any window in the list is currently active.
func inMaintenanceWindow(windows []config.MaintenanceWindow) bool {
now := time.Now()
for i := range windows {
if windows[i].Active(now) {
return true
}
}
return false
}

343
internal/store/store.go Normal file
View File

@@ -0,0 +1,343 @@
package store
import (
"database/sql"
"fmt"
"sort"
"time"
_ "modernc.org/sqlite"
"arclineit/arcline-uptime/internal/monitor"
)
// Store wraps the SQLite database.
type Store struct {
db *sql.DB
}
// CheckRow is a row read back from the checks table.
type CheckRow struct {
ID int64
MonitorName string
CheckedAt time.Time
Up bool
StatusCode int
ResponseMS int64
Error string
}
// AlertRow is a row from the alerts_sent table.
type AlertRow struct {
ID int64
MonitorName string
SentAt time.Time
Kind string // "down" | "up"
}
// Incident represents a period during which a monitor was down.
type Incident struct {
MonitorName string
StartedAt time.Time
ResolvedAt time.Time // zero if still ongoing
}
func (i Incident) Duration() time.Duration {
if i.ResolvedAt.IsZero() {
return time.Since(i.StartedAt)
}
return i.ResolvedAt.Sub(i.StartedAt)
}
func (i Incident) Ongoing() bool { return i.ResolvedAt.IsZero() }
// Open opens (or creates) the SQLite database at path and migrates the schema.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// SQLite does not support concurrent writers; serialise through one connection.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_name TEXT NOT NULL,
checked_at INTEGER NOT NULL,
up INTEGER NOT NULL,
status_code INTEGER NOT NULL DEFAULT 0,
response_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
ON checks (monitor_name, checked_at DESC);
CREATE TABLE IF NOT EXISTS alerts_sent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_name TEXT NOT NULL,
sent_at INTEGER NOT NULL,
kind TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alerts_monitor
ON alerts_sent (monitor_name, sent_at DESC);
`)
return err
}
// --- Writes ---
// SaveResult persists one check result.
func (s *Store) SaveResult(r monitor.Result) error {
_, err := s.db.Exec(
`INSERT INTO checks (monitor_name, checked_at, up, status_code, response_ms, error)
VALUES (?, ?, ?, ?, ?, ?)`,
r.MonitorName,
r.CheckedAt.Unix(),
boolToInt(r.Up),
r.StatusCode,
r.ResponseTime.Milliseconds(),
r.Error,
)
return err
}
// RecordAlertSent writes a row indicating an alert of kind ("down"|"up") was sent.
func (s *Store) RecordAlertSent(monitorName, kind string) error {
_, err := s.db.Exec(
`INSERT INTO alerts_sent (monitor_name, sent_at, kind) VALUES (?, ?, ?)`,
monitorName, time.Now().Unix(), kind,
)
return err
}
// Prune deletes check and alert rows older than retentionDays.
func (s *Store) Prune(retentionDays int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -retentionDays).Unix()
res, err := s.db.Exec(`DELETE FROM checks WHERE checked_at < ?`, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
res2, err := s.db.Exec(`DELETE FROM alerts_sent WHERE sent_at < ?`, cutoff)
if err != nil {
return n, err
}
n2, _ := res2.RowsAffected()
return n + n2, nil
}
// --- Single-monitor reads ---
// LatestResult returns the most recent check for the given monitor, or nil if none.
func (s *Store) LatestResult(monitorName string) (*CheckRow, error) {
row := s.db.QueryRow(
`SELECT id, monitor_name, checked_at, up, status_code, response_ms, error
FROM checks WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT 1`,
monitorName,
)
return scanCheckRow(row)
}
// LastAlertSent returns the time of the most recent alert of the given kind.
// Returns zero time if no such alert exists.
func (s *Store) LastAlertSent(monitorName, kind string) (time.Time, error) {
var ts int64
err := s.db.QueryRow(
`SELECT sent_at FROM alerts_sent WHERE monitor_name = ? AND kind = ?
ORDER BY sent_at DESC LIMIT 1`,
monitorName, kind,
).Scan(&ts)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, err
}
return time.Unix(ts, 0), nil
}
// RecentChecks returns the most recent limit checks for a monitor, newest first.
func (s *Store) RecentChecks(monitorName string, limit int) ([]CheckRow, error) {
rows, err := s.db.Query(
`SELECT id, monitor_name, checked_at, up, status_code, response_ms, error
FROM checks WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT ?`,
monitorName, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CheckRow
for rows.Next() {
r, err := scanCheckRowFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, *r)
}
return out, rows.Err()
}
// UptimePercent returns the percentage of successful checks since `since`.
// Returns 100.0 if no checks exist yet.
func (s *Store) UptimePercent(monitorName string, since time.Time) (float64, error) {
var total, upCount int64
err := s.db.QueryRow(
`SELECT COUNT(*), COALESCE(SUM(up), 0)
FROM checks WHERE monitor_name = ? AND checked_at >= ?`,
monitorName, since.Unix(),
).Scan(&total, &upCount)
if err != nil {
return 0, err
}
if total == 0 {
return 100.0, nil
}
return float64(upCount) / float64(total) * 100.0, nil
}
// --- Multi-monitor reads ---
// AllMonitorNames returns all distinct monitor names that have check records.
func (s *Store) AllMonitorNames() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT monitor_name FROM checks ORDER BY monitor_name`)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
// AlertsSent returns all alert rows since `since`, ordered oldest-first.
func (s *Store) AlertsSent(since time.Time) ([]AlertRow, error) {
rows, err := s.db.Query(
`SELECT id, monitor_name, sent_at, kind FROM alerts_sent
WHERE sent_at >= ? ORDER BY monitor_name, sent_at ASC`,
since.Unix(),
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AlertRow
for rows.Next() {
var a AlertRow
var ts int64
if err := rows.Scan(&a.ID, &a.MonitorName, &ts, &a.Kind); err != nil {
return nil, err
}
a.SentAt = time.Unix(ts, 0)
out = append(out, a)
}
return out, rows.Err()
}
// Incidents pairs down/up alert rows into Incident structs, sorted newest-first.
// Ongoing incidents (no matching up alert) are included with a zero ResolvedAt.
func (s *Store) Incidents(limit int) ([]Incident, error) {
rows, err := s.AlertsSent(time.Time{}) // all time
if err != nil {
return nil, err
}
type openIncident struct{ start time.Time }
open := make(map[string]*openIncident)
var incidents []Incident
for _, a := range rows {
switch a.Kind {
case "down":
if _, already := open[a.MonitorName]; !already {
open[a.MonitorName] = &openIncident{start: a.SentAt}
}
case "up":
if o, ok := open[a.MonitorName]; ok {
incidents = append(incidents, Incident{
MonitorName: a.MonitorName,
StartedAt: o.start,
ResolvedAt: a.SentAt,
})
delete(open, a.MonitorName)
}
}
}
// Remaining open entries are ongoing incidents.
for name, o := range open {
incidents = append(incidents, Incident{
MonitorName: name,
StartedAt: o.start,
})
}
// Sort newest-first.
sort.Slice(incidents, func(i, j int) bool {
return incidents[i].StartedAt.After(incidents[j].StartedAt)
})
if limit > 0 && len(incidents) > limit {
incidents = incidents[:limit]
}
return incidents, nil
}
// --- helpers ---
func scanCheckRow(row *sql.Row) (*CheckRow, error) {
var r CheckRow
var ts int64
var up int64
err := row.Scan(&r.ID, &r.MonitorName, &ts, &up, &r.StatusCode, &r.ResponseMS, &r.Error)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
r.CheckedAt = time.Unix(ts, 0)
r.Up = up != 0
return &r, nil
}
func scanCheckRowFromRows(rows *sql.Rows) (*CheckRow, error) {
var r CheckRow
var ts int64
var up int64
if err := rows.Scan(&r.ID, &r.MonitorName, &ts, &up, &r.StatusCode, &r.ResponseMS, &r.Error); err != nil {
return nil, err
}
r.CheckedAt = time.Unix(ts, 0)
r.Up = up != 0
return &r, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

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>`