go uptime monitor

init commit

seiso.moe 93a78a22

Changed files
+246
check
cmd
hestia
config
scheduler
storage
web
.gitignore

This is a binary file and will not be displayed.

+7
README.md
···
+
Hestia
+
+
An uptime monitor.
+
+
+
# Building
+
`go build -o hestia ./cmd/hestia/main.go`
+37
check/check.go
···
+
package check
+
+
import (
+
"fmt"
+
"io"
+
"net/http"
+
"regexp"
+
"time"
+
)
+
+
type Check struct {
+
Name string `toml:"name"`
+
Type string `toml:"type"`
+
URL string `toml:"url,omitempty"`
+
Expect string `toml:"expect"`
+
Interval int `toml:"interval"`
+
}
+
+
func CheckHTTP(c Check) (string, string, time.Duration) {
+
start := time.Now()
+
resp, err := http.Get(c.URL)
+
duration := time.Since(start)
+
+
if err != nil {
+
return "fail", err.Error(), duration
+
}
+
defer resp.Body.Close()
+
+
body, _ := io.ReadAll(resp.Body)
+
matched, _ := regexp.Match(c.Expect, body)
+
+
if resp.StatusCode == 200 && matched {
+
return "ok", "matched", duration
+
}
+
+
return "fail", fmt.Sprintf("Status: %d, matched: %v", resp.StatusCode, matched), duration
+
}
+33
cmd/hestia/main.go
···
+
package main
+
+
import (
+
"database/sql"
+
_ "github.com/mattn/go-sqlite3"
+
"log"
+
"net/http"
+
"time"
+
+
"tangled.sh/seiso.moe/hestia/config"
+
"tangled.sh/seiso.moe/hestia/scheduler"
+
"tangled.sh/seiso.moe/hestia/storage"
+
"tangled.sh/seiso.moe/hestia/web"
+
)
+
+
func main() {
+
cfg, err := config.LoadConfig("/home/blu/git/kiri/hestia/config.toml")
+
if err != nil {
+
log.Fatal(err)
+
}
+
db, _ := sql.Open("sqlite3", "file:checks.db")
+
_ = storage.InitDB(db)
+
+
go scheduler.StartScheduler(cfg, db)
+
go func() {
+
for {
+
time.Sleep(time.Hour)
+
storage.CleanupOld(db, cfg.Retention)
+
}
+
}()
+
+
http.ListenAndServe(":8080", web.Routes(db))
+
}
+21
config/config.go
···
+
package config
+
+
import (
+
"github.com/BurntSushi/toml"
+
+
"tangled.sh/seiso.moe/hestia/check"
+
)
+
+
type Config struct {
+
Checks []check.Check `toml:"check"`
+
Retention int `toml:"retention"`
+
}
+
+
func LoadConfig(path string) (*Config, error) {
+
var cfg Config
+
if _, err := toml.DecodeFile(path, &cfg); err != nil {
+
return nil, err
+
}
+
+
return &cfg, nil
+
}
+10
go.mod
···
+
module tangled.sh/seiso.moe/hestia
+
+
go 1.24.4
+
+
require (
+
github.com/BurntSushi/toml v1.5.0
+
github.com/go-chi/chi/v5 v5.2.2
+
)
+
+
require github.com/mattn/go-sqlite3 v1.14.28
+6
go.sum
···
+
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
+
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
+
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+30
scheduler/scheduler.go
···
+
package scheduler
+
+
import (
+
"database/sql"
+
_ "github.com/mattn/go-sqlite3"
+
"log"
+
"time"
+
+
"tangled.sh/seiso.moe/hestia/check"
+
"tangled.sh/seiso.moe/hestia/config"
+
"tangled.sh/seiso.moe/hestia/storage"
+
)
+
+
func StartScheduler(cfg *config.Config, db *sql.DB) {
+
for _, ck := range cfg.Checks {
+
go func(c check.Check) {
+
ticker := time.NewTicker(time.Duration(c.Interval) * time.Second)
+
for range ticker.C {
+
log.Printf("running check: %s\n", c.Name)
+
var status, message string
+
var dur time.Duration
+
+
status, message, dur = check.CheckHTTP(c)
+
log.Printf("%s, %s, %s\n", status, message, dur)
+
+
storage.StoreResult(db, c.Name, status, message, dur)
+
}
+
}(ck)
+
}
+
}
+34
storage/sqlite.go
···
+
package storage
+
+
import (
+
"database/sql"
+
"fmt"
+
"time"
+
)
+
+
func InitDB(db *sql.DB) error {
+
_, err := db.Exec(`
+
CREATE TABLE IF NOT EXISTS check_results (
+
id INTEGER PRIMARY KEY,
+
name TEXT,
+
status TEXT,
+
message TEXT,
+
timestamp DATETIME,
+
duration_ms INTEGER
+
);
+
`)
+
return err
+
}
+
+
func CleanupOld(db *sql.DB, hours int) error {
+
_, err := db.Exec(`DELETE FROM check_results WHERE timestamp < datetime('now', ?)`, fmt.Sprintf("-%dh", hours))
+
return err
+
}
+
+
func StoreResult(db *sql.DB, name, status, message string, duration time.Duration) error {
+
_, err := db.Exec(`
+
INSERT INTO check_results (name, status, message, timestamp, duration_ms)
+
VALUES (?, ?, ?, ?, ?)
+
`, name, status, message, time.Now(), duration.Milliseconds())
+
return err
+
}
+68
web/handlers.go
···
+
package web
+
+
import (
+
"database/sql"
+
"fmt"
+
"net/http"
+
"strings"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
func Routes(db *sql.DB) http.Handler {
+
r := chi.NewRouter()
+
+
r.Get("/", HandleDashboard(db))
+
+
return r
+
}
+
+
func HandleDashboard(db *sql.DB) http.HandlerFunc {
+
return func(w http.ResponseWriter, r *http.Request) {
+
type Result struct {
+
Name string
+
Statuses []string
+
}
+
+
rows, err := db.Query(`
+
SELECT name, status
+
FROM check_results
+
ORDER BY timestamp DESC
+
LIMIT 1000
+
`)
+
if err != nil {
+
http.Error(w, "DB error", 500)
+
return
+
}
+
defer rows.Close()
+
+
grouped := map[string][]string{}
+
+
for rows.Next() {
+
var name, status string
+
if err := rows.Scan(&name, &status); err == nil {
+
grouped[name] = append(grouped[name], status)
+
}
+
}
+
+
var html strings.Builder
+
html.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Hestia</title></head><body style="font-family: sans-serif">`)
+
html.WriteString(`<h1>Health Checks</h1>`)
+
+
for name, statuses := range grouped {
+
html.WriteString(fmt.Sprintf("<h2>%s</h2><div style='display: flex; gap: 2px;'>", name))
+
for _, status := range statuses {
+
color := "#4caf50"
+
if status != "ok" {
+
color = "#f44336"
+
}
+
html.WriteString(fmt.Sprintf(`<div title="%s" style="width: 10px; height: 20px; background: %s;"></div>`, status, color))
+
}
+
html.WriteString("</div>")
+
}
+
+
html.WriteString(`</body></html>`)
+
w.Header().Set("Content-Type", "text/html")
+
w.Write([]byte(html.String()))
+
}
+
}