From f0db70c84035024265e64671bd755b2fb099a48c Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sun, 22 Mar 2026 11:30:31 -0500 Subject: [PATCH] init work of uptime --- cmd/arcline-uptime/main.go | 183 +++++++++++++++++ go.mod | 20 ++ go.sum | 55 +++++ internal/alert/alerter.go | 94 +++++++++ internal/alert/discord.go | 40 ++++ internal/alert/email.go | 34 ++++ internal/alert/gotify.go | 59 ++++++ internal/alert/ntfy.go | 47 +++++ internal/config/config.go | 260 ++++++++++++++++++++++++ internal/monitor/dns.go | 69 +++++++ internal/monitor/http.go | 101 ++++++++++ internal/monitor/monitor.go | 59 ++++++ internal/monitor/tcp.go | 46 +++++ internal/monitor/tls.go | 68 +++++++ internal/scheduler/scheduler.go | 198 ++++++++++++++++++ internal/store/store.go | 343 ++++++++++++++++++++++++++++++++ internal/web/handler.go | 342 +++++++++++++++++++++++++++++++ internal/web/templates.go | 234 ++++++++++++++++++++++ 18 files changed, 2252 insertions(+) create mode 100644 cmd/arcline-uptime/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/alert/alerter.go create mode 100644 internal/alert/discord.go create mode 100644 internal/alert/email.go create mode 100644 internal/alert/gotify.go create mode 100644 internal/alert/ntfy.go create mode 100644 internal/config/config.go create mode 100644 internal/monitor/dns.go create mode 100644 internal/monitor/http.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/monitor/tcp.go create mode 100644 internal/monitor/tls.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/store/store.go create mode 100644 internal/web/handler.go create mode 100644 internal/web/templates.go diff --git a/cmd/arcline-uptime/main.go b/cmd/arcline-uptime/main.go new file mode 100644 index 0000000..2eb4832 --- /dev/null +++ b/cmd/arcline-uptime/main.go @@ -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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1cdab5c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aab183e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/alert/alerter.go b/internal/alert/alerter.go new file mode 100644 index 0000000..5b93431 --- /dev/null +++ b/internal/alert/alerter.go @@ -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) +} diff --git a/internal/alert/discord.go b/internal/alert/discord.go new file mode 100644 index 0000000..d058ac8 --- /dev/null +++ b/internal/alert/discord.go @@ -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 +} diff --git a/internal/alert/email.go b/internal/alert/email.go new file mode 100644 index 0000000..71594ad --- /dev/null +++ b/internal/alert/email.go @@ -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)) +} diff --git a/internal/alert/gotify.go b/internal/alert/gotify.go new file mode 100644 index 0000000..910fe72 --- /dev/null +++ b/internal/alert/gotify.go @@ -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 +} diff --git a/internal/alert/ntfy.go b/internal/alert/ntfy.go new file mode 100644 index 0000000..89f0229 --- /dev/null +++ b/internal/alert/ntfy.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..551e439 --- /dev/null +++ b/internal/config/config.go @@ -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:00–02: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 +} diff --git a/internal/monitor/dns.go b/internal/monitor/dns.go new file mode 100644 index 0000000..1733331 --- /dev/null +++ b/internal/monitor/dns.go @@ -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 +} diff --git a/internal/monitor/http.go b/internal/monitor/http.go new file mode 100644 index 0000000..74f7352 --- /dev/null +++ b/internal/monitor/http.go @@ -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 +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..8c0b857 --- /dev/null +++ b/internal/monitor/monitor.go @@ -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) + } +} diff --git a/internal/monitor/tcp.go b/internal/monitor/tcp.go new file mode 100644 index 0000000..a527cdc --- /dev/null +++ b/internal/monitor/tcp.go @@ -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 +} diff --git a/internal/monitor/tls.go b/internal/monitor/tls.go new file mode 100644 index 0000000..4c3df00 --- /dev/null +++ b/internal/monitor/tls.go @@ -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 +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..5ddf2b1 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c7546ec --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..3f01366 --- /dev/null +++ b/internal/web/handler.go @@ -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) +} diff --git a/internal/web/templates.go b/internal/web/templates.go new file mode 100644 index 0000000..ea7b948 --- /dev/null +++ b/internal/web/templates.go @@ -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 = `` + +const indexHTML = ` + + + + + + arcline-uptime + + + +

arcline-uptime

+

Auto-refreshes every 30s — {{fmtTime .Now}} UTC

+ ` + sharedNav + ` + + + + + + + + + + + + + + + {{range .Monitors}} + + + + + + + + + + + {{end}} + +
MonitorStatusResponse24h7d30dLast CheckInfo
{{.Name}}{{if .Up}}UP{{else}}DOWN{{end}}{{if .ResponseMS}}{{.ResponseMS}}ms{{else}}{{end}}{{printf "%.2f" .Uptime24h}}%{{printf "%.2f" .Uptime7d}}%{{printf "%.2f" .Uptime30d}}%{{fmtTime .CheckedAt}}{{if .Error}}{{.Error}}{{else}}—{{end}}
+ +` + +const historyHTML = ` + + + + + arcline-uptime — History + + + + ` + sharedNav + ` +

Response Time History (last 100 checks per monitor)

+ {{range .}} +

{{.Name}}

+ {{if .Points}} +
+ + + {{range .DownDotXs}} + + {{end}} + +
+

max: {{.MaxMS}}ms   avg: {{printf "%.0f" .AvgMS}}ms   checks: {{.Count}}

+ {{else}} +

No data yet.

+ {{end}} + {{end}} + +` + +const incidentsHTML = ` + + + + + arcline-uptime — Incidents + + + + ` + sharedNav + ` +

Incident Log

+

All outage periods, newest first.

+ {{if .}} + + + + + + + + + + + + {{range .}} + + + + + + + + {{end}} + +
MonitorStartedResolvedDurationStatus
{{.MonitorName}}{{fmtTime .StartedAt}}{{if .Ongoing}}—{{else}}{{fmtTime .ResolvedAt}}{{end}}{{.Duration}}{{if .Ongoing}}ONGOING{{else}}resolved{{end}}
+ {{else}} +

No incidents recorded.

+ {{end}} + +` + +const statusHTML = ` + + + + + + Service Status + + + +

Service Status

+ {{if .AllUp}} + + {{else}} + + {{end}} + + + + + + {{range .Monitors}} + + + + + + {{end}} + +
ServiceStatusUptime (24h)
{{.Name}} + + {{if .Up}}Operational{{else}}Disrupted{{end}} + {{printf "%.2f" .Uptime24h}}%
+ + +`