Files
Dash/apps/backend/internal/api/monitors.go
T
Tomas DvorakandDevin 1ff1b29ae7 feat: external importers + uptime monitors (merge plan M1)
Two tracks from the Beszel/Domain-Locker merge plan in ROADMAP.md:

- Importers: POST /api/import/external sniffs Homepage services.yml,
  Homarr JSON, and Dashy conf.yml into sections/items; SettingsDialog
  import now accepts all four formats.
- Monitors (M1): monitors + heartbeats schema, pure checkers for
  http/tcp/ping/dns/keyword/json-query/push, scheduler on app ctx,
  CRUD + check-now + push ingest API, /monitors + /monitors/:id pages
  (wouter), monitor board widget, ServiceDialog monitor opt-in.

Authored By: TDvorak <[email protected]>

Generated with Devin (https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 18:14:36 +02:00

550 lines
16 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/monitor"
"github.com/tdvorak/dash/internal/widget"
"go.uber.org/zap"
)
// ---------- model ----------
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, status, push_token, tags, notes, position, last_check, created_at`
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
var m monitor.Monitor
var tags string
var invert, active int
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
if err != nil {
return nil, err
}
m.KeywordInvert = invert != 0
m.Active = active != 0
_ = json.Unmarshal([]byte(tags), &m.Tags)
if m.Tags == nil {
m.Tags = []string{}
}
return &m, nil
}
func (s *Server) loadMonitor(id string) (*monitor.Monitor, error) {
row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, id)
return scanMonitor(row)
}
func (s *Server) loadMonitorByToken(token string) (*monitor.Monitor, error) {
row := s.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE push_token = ? AND push_token != ''`, token)
return scanMonitor(row)
}
func (s *Server) listMonitors() ([]monitor.Monitor, error) {
rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors ORDER BY position, name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []monitor.Monitor
for rows.Next() {
m, err := scanMonitor(rows)
if err != nil {
return nil, err
}
out = append(out, *m)
}
return out, rows.Err()
}
// ---------- uptime stats ----------
type uptimeStats struct {
Up int `json:"up"`
Down int `json:"down"`
Uptime24h float64 `json:"uptime24h"`
Uptime30d float64 `json:"uptime30d"`
AvgPing24h int `json:"avgPing24h"`
}
func (s *Server) monitorStats(id string) uptimeStats {
var st uptimeStats
pct := func(hours int) float64 {
var up, down int
_ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0)
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?)`,
id, "-"+strconv.Itoa(hours)+" hours").Scan(&up, &down)
if up+down == 0 {
return 100
}
return float64(up) / float64(up+down) * 100
}
st.Uptime24h = pct(24)
st.Uptime30d = pct(720)
_ = s.db.QueryRow(`SELECT COALESCE(AVG(NULLIF(ping_ms,0)),0) FROM heartbeats
WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`, id).Scan(&st.AvgPing24h)
_ = s.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0)
FROM heartbeats WHERE monitor_id = ?`, id).Scan(&st.Up, &st.Down)
return st
}
// monitorView is the API shape: the row plus live uptime stats.
type monitorView struct {
*monitor.Monitor
Stats uptimeStats `json:"stats"`
}
// ---------- handlers ----------
func (s *Server) listMonitorsH(c *gin.Context) {
ms, err := s.listMonitors()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out := make([]monitorView, 0, len(ms))
for i := range ms {
out = append(out, monitorView{Monitor: &ms[i], Stats: s.monitorStats(ms[i].ID)})
}
c.JSON(http.StatusOK, out)
}
// monitorInput is the create/update payload — all fields optional on PATCH.
type monitorInput struct {
Name *string `json:"name"`
Type *string `json:"type"`
URL *string `json:"url"`
Hostname *string `json:"hostname"`
Port *int `json:"port"`
Method *string `json:"method"`
Headers *string `json:"headers"`
Body *string `json:"body"`
Keyword *string `json:"keyword"`
KeywordInvert *bool `json:"keywordInvert"`
JSONQuery *string `json:"jsonQuery"`
Expected *string `json:"expected"`
DNSType *string `json:"dnsType"`
IntervalS *int `json:"intervalS"`
TimeoutS *int `json:"timeoutS"`
Retries *int `json:"retries"`
Active *bool `json:"active"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
}
func validMonitorType(t string) bool {
for _, v := range monitor.Types() {
if v == t {
return true
}
}
return false
}
func strOr(p *string) string {
if p == nil {
return ""
}
return *p
}
func strOr2(p *string, def string) string {
if p == nil || *p == "" {
return def
}
return *p
}
func intOr(p *int) int {
if p == nil {
return 0
}
return *p
}
func intOr2(p *int, def int) int {
if p == nil || *p <= 0 {
return def
}
return *p
}
func boolOr(p *bool) bool { return p != nil && *p }
func boolOr2(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
func (s *Server) createMonitor(c *gin.Context) {
var in monitorInput
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || in.Type == nil {
fail(c, http.StatusBadRequest, "name and type required")
return
}
if !validMonitorType(*in.Type) {
fail(c, http.StatusBadRequest, "invalid type")
return
}
id := newID("m")
token := ""
if *in.Type == "push" {
token = newID("push")
}
tags, _ := json.Marshal(in.Tags)
if in.Tags == nil {
tags = []byte("[]")
}
_, err := s.db.Exec(`INSERT INTO monitors
(id, name, type, url, hostname, port, method, headers, body, keyword,
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, push_token, tags, notes, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
m, err := s.loadMonitor(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, monitorView{Monitor: m, Stats: s.monitorStats(id)})
}
func (s *Server) getMonitor(c *gin.Context) {
m, err := s.loadMonitor(c.Param("id"))
if notFound(err) {
fail(c, http.StatusNotFound, "monitor not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)})
}
// patchMonitor applies a sparse update; `active` flips pause/resume.
func (s *Server) patchMonitor(c *gin.Context) {
id := c.Param("id")
if _, err := s.loadMonitor(id); notFound(err) {
fail(c, http.StatusNotFound, "monitor not found")
return
}
var in monitorInput
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.Type != nil && !validMonitorType(*in.Type) {
fail(c, http.StatusBadRequest, "invalid type")
return
}
setStr := func(col string, p *string) {
if p != nil {
_, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id)
}
}
setStr("name", in.Name)
setStr("type", in.Type)
setStr("url", in.URL)
setStr("hostname", in.Hostname)
setStr("method", in.Method)
setStr("headers", in.Headers)
setStr("body", in.Body)
setStr("keyword", in.Keyword)
setStr("json_query", in.JSONQuery)
setStr("expected", in.Expected)
setStr("dns_type", in.DNSType)
setStr("notes", in.Notes)
setInt := func(col string, p *int) {
if p != nil {
_, _ = s.db.Exec(`UPDATE monitors SET `+col+` = ? WHERE id = ?`, *p, id)
}
}
setInt("port", in.Port)
setInt("interval_s", in.IntervalS)
setInt("timeout_s", in.TimeoutS)
setInt("retries", in.Retries)
if in.KeywordInvert != nil {
_, _ = s.db.Exec(`UPDATE monitors SET keyword_invert = ? WHERE id = ?`, *in.KeywordInvert, id)
}
if in.Tags != nil {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Active != nil {
// pause/resume flips the stored status back to pending for a fresh probe
status := "pending"
if !*in.Active {
status = "paused"
}
_, _ = s.db.Exec(`UPDATE monitors SET active = ?, status = ? WHERE id = ?`, *in.Active, status, id)
}
m, err := s.loadMonitor(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(id)})
}
func (s *Server) deleteMonitor(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM monitors WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "monitor not found")
return
}
c.Status(http.StatusNoContent)
}
// heartbeats returns the recent check history for charts/logs.
func (s *Server) monitorHeartbeats(c *gin.Context) {
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
if hours <= 0 || hours > 24*30 {
hours = 24
}
rows, err := s.db.Query(`SELECT id, status, ping_ms, msg, cert_expiry, checked_at
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now', ?)
ORDER BY checked_at`, c.Param("id"), "-"+strconv.Itoa(hours)+" hours")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type hb struct {
ID int `json:"id"`
Status string `json:"status"`
PingMs int `json:"pingMs"`
Msg string `json:"msg"`
CertExpiry int `json:"certExpiry"`
CheckedAt string `json:"checkedAt"`
}
out := []hb{}
for rows.Next() {
var h hb
if err := rows.Scan(&h.ID, &h.Status, &h.PingMs, &h.Msg, &h.CertExpiry, &h.CheckedAt); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, h)
}
c.JSON(http.StatusOK, out)
}
// checkNow runs one check synchronously and returns the refreshed view.
func (s *Server) checkNow(c *gin.Context) {
m, err := s.loadMonitor(c.Param("id"))
if notFound(err) {
fail(c, http.StatusNotFound, "monitor not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
s.runCheck(m)
m, err = s.loadMonitor(m.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, monitorView{Monitor: m, Stats: s.monitorStats(m.ID)})
}
// pushIngest is the push-monitor endpoint: any hit records an up heartbeat.
// ?msg= and ?ping= are optional annotations.
func (s *Server) pushIngest(c *gin.Context) {
m, err := s.loadMonitorByToken(c.Param("token"))
if err != nil {
fail(c, http.StatusNotFound, "unknown push token")
return
}
msg := c.Query("msg")
ping, _ := strconv.Atoi(c.Query("ping"))
s.writeHeartbeat(m, &monitor.Result{Up: true, PingMs: ping, Msg: msg, CertExpiry: -1})
_, _ = s.db.Exec(`UPDATE monitors SET status = 'up', last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, m.ID)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ---------- scheduler ----------
// runScheduler sweeps for due monitors every tick; each check runs in a
// bounded worker. Push monitors go stale -> down when silent too long.
func (s *Server) runScheduler(done <-chan struct{}) {
tick := time.NewTicker(10 * time.Second)
prune := time.NewTicker(time.Hour)
defer tick.Stop()
defer prune.Stop()
for {
select {
case <-done:
return
case <-tick.C:
s.sweep()
case <-prune.C:
if _, err := s.db.Exec(`DELETE FROM heartbeats WHERE checked_at < datetime('now','-30 days')`); err != nil {
s.log.Error("heartbeat prune", zap.Error(err))
}
}
}
}
var checkSem = make(chan struct{}, 8)
func (s *Server) sweep() {
rows, err := s.db.Query(`SELECT ` + monitorCols + ` FROM monitors
WHERE active = 1 AND type != 'push'
AND (last_check IS NULL OR datetime(last_check, '+' || interval_s || ' seconds') <= datetime('now'))`)
if err != nil {
s.log.Error("monitor sweep", zap.Error(err))
return
}
var due []monitor.Monitor
for rows.Next() {
if m, err := scanMonitor(rows); err == nil {
due = append(due, *m)
}
}
rows.Close()
for i := range due {
m := &due[i]
checkSem <- struct{}{}
go func() {
defer func() { <-checkSem }()
s.runCheck(m)
}()
}
// Push monitors: mark down when no push arrived within 2x interval.
if _, err := s.db.Exec(`UPDATE monitors SET status = 'down',
last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE type = 'push' AND active = 1
AND last_check IS NOT NULL
AND datetime(last_check, '+' || (2 * interval_s) || ' seconds') <= datetime('now')
AND status != 'down'`); err != nil {
s.log.Error("push stale sweep", zap.Error(err))
}
}
// runCheck executes one monitor check (with retries) and persists the result.
func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
var res *monitor.Result
for attempt := 0; attempt <= m.Retries; attempt++ {
res = monitor.Check(context.Background(), m)
if res.Up || attempt == m.Retries {
break
}
time.Sleep(2 * time.Second)
}
s.writeHeartbeat(m, res)
prev := m.Status
next := "down"
if res.Up {
next = "up"
}
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
next, m.ID); err != nil {
s.log.Error("monitor update", zap.Error(err))
}
if prev != next && prev != "pending" {
s.onMonitorTransition(m, prev, next)
}
return res
}
func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) {
status := "down"
if res.Up {
status = "up"
}
if _, err := s.db.Exec(`INSERT INTO heartbeats (monitor_id, status, ping_ms, msg, cert_expiry)
VALUES (?,?,?,?,?)`, m.ID, status, res.PingMs, truncate(res.Msg, 300), res.CertExpiry); err != nil {
s.log.Error("heartbeat write", zap.Error(err))
}
}
// onMonitorTransition is the alert seam — M2 wires notifications here.
func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
s.log.Info("monitor transition",
zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next))
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---------- widget fetcher ----------
// monitorWidget serves board tiles bound to a monitor via config.monitorId.
type monitorWidget struct{ db *sql.DB }
func (w *monitorWidget) Meta() widget.Type {
return widget.Type{
Type: "monitor", Name: "Monitor",
Description: "Live status of an uptime monitor",
Fields: []widget.Field{
{Key: "monitorId", Label: "Monitor", Required: true},
},
}
}
func (w *monitorWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
var c struct {
MonitorID string `json:"monitorId"`
}
if err := json.Unmarshal(cfg, &c); err != nil || c.MonitorID == "" {
return nil, errors.New("config.monitorId required")
}
row := w.db.QueryRow(`SELECT `+monitorCols+` FROM monitors WHERE id = ?`, c.MonitorID)
m, err := scanMonitor(row)
if err != nil {
return nil, errors.New("monitor not found")
}
var up, down int
var avgPing float64
_ = w.db.QueryRow(`SELECT COALESCE(SUM(status='up'),0), COALESCE(SUM(status='down'),0),
COALESCE(AVG(NULLIF(ping_ms,0)),0)
FROM heartbeats WHERE monitor_id = ? AND checked_at >= datetime('now','-24 hours')`,
m.ID).Scan(&up, &down, &avgPing)
uptime := 100.0
if up+down > 0 {
uptime = float64(up) / float64(up+down) * 100
}
return map[string]any{
"name": m.Name,
"status": m.Status,
"uptime24": uptime,
"ping": int(avgPing),
"type": m.Type,
}, nil
}