mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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>
550 lines
16 KiB
Go
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
|
|
}
|