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