mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
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>
This commit is contained in:
1 parent
f428e721fe
commit
1ff1b29ae7
29 files changed
+3050
-98
No files matched your search
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
@@ -12,25 +13,33 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/widget"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server holds handler dependencies: DB, icon dir, status + widget caches.
|
||||
// Server holds handler dependencies: DB, icon dir, logger, caches.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
iconsDir string
|
||||
log *zap.Logger
|
||||
status *statusCache
|
||||
widgets *widgetCache
|
||||
}
|
||||
|
||||
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
||||
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
// A non-nil ctx starts the monitor scheduler; nil keeps it off (tests).
|
||||
func NewRouter(ctx context.Context, logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
s := &Server{
|
||||
db: db,
|
||||
iconsDir: iconsDir,
|
||||
log: logger,
|
||||
status: newStatusCache(60 * time.Second),
|
||||
widgets: newWidgetCache(30 * time.Second),
|
||||
}
|
||||
widget.Register(&monitorWidget{db: db})
|
||||
if ctx != nil {
|
||||
go s.runScheduler(ctx.Done())
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(accessLog(logger), gin.Recovery())
|
||||
@@ -51,6 +60,15 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
v1.POST("/items/:id/icon", s.uploadIcon)
|
||||
v1.GET("/icons/:file", s.getIcon)
|
||||
|
||||
v1.GET("/monitors", s.listMonitorsH)
|
||||
v1.POST("/monitors", s.createMonitor)
|
||||
v1.GET("/monitors/:id", s.getMonitor)
|
||||
v1.PATCH("/monitors/:id", s.patchMonitor)
|
||||
v1.DELETE("/monitors/:id", s.deleteMonitor)
|
||||
v1.GET("/monitors/:id/heartbeats", s.monitorHeartbeats)
|
||||
v1.POST("/monitors/:id/check", s.checkNow)
|
||||
v1.Any("/push/:token", s.pushIngest)
|
||||
|
||||
v1.GET("/status", s.getStatus)
|
||||
v1.GET("/widgets/types", s.widgetTypes)
|
||||
v1.GET("/widgets/:id/data", s.widgetData)
|
||||
@@ -58,6 +76,7 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
v1.PUT("/settings", s.putSettings)
|
||||
v1.GET("/export", s.exportBoard)
|
||||
v1.POST("/import", s.importBoard)
|
||||
v1.POST("/import/external", s.importExternal)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func testServer(t *testing.T) *httptest.Server {
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir()))
|
||||
return httptest.NewServer(NewRouter(nil, zap.NewNop(), d, t.TempDir()))
|
||||
}
|
||||
|
||||
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
|
||||
@@ -178,6 +178,209 @@ func TestWidgetEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExternalHomepage(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
body := `- Media:
|
||||
- Plex:
|
||||
href: http://plex.local:32400
|
||||
icon: plex
|
||||
- NoLink:
|
||||
description: widget-only entry
|
||||
- Apps:
|
||||
- Sonarr:
|
||||
href: https://sonarr.example.com
|
||||
icon: https://icons.example.com/sonarr.png
|
||||
`
|
||||
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("homepage import: %d", res.StatusCode)
|
||||
}
|
||||
var secs []Section
|
||||
resp, err := http.Get(srv.URL + "/api/sections")
|
||||
if err != nil {
|
||||
t.Fatalf("get sections: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(secs) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %+v", secs)
|
||||
}
|
||||
media := secs[0]
|
||||
if media.Name != "Media" || len(media.Items) != 1 {
|
||||
t.Fatalf("bad Media section: %+v", media)
|
||||
}
|
||||
if media.Items[0].Name != "Plex" || len(media.Items[0].URLs) != 1 {
|
||||
t.Fatalf("bad Plex item: %+v", media.Items[0])
|
||||
}
|
||||
if media.Items[0].Icon == "" {
|
||||
t.Fatal("expected resolved CDN icon")
|
||||
}
|
||||
apps := secs[1]
|
||||
if apps.Items[0].Icon != "https://icons.example.com/sonarr.png" {
|
||||
t.Fatalf("icon passthrough failed: %+v", apps.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExternalDashy(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
body := `appConfig:
|
||||
title: test
|
||||
sections:
|
||||
- name: Media
|
||||
items:
|
||||
- title: Plex
|
||||
url: http://plex.local
|
||||
icon: favicon
|
||||
- title: Broken
|
||||
url: "notaurl"
|
||||
`
|
||||
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dashy import: %d", res.StatusCode)
|
||||
}
|
||||
var secs []Section
|
||||
resp, err := http.Get(srv.URL + "/api/sections")
|
||||
if err != nil {
|
||||
t.Fatalf("get sections: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(secs) != 1 || len(secs[0].Items) != 1 {
|
||||
t.Fatalf("bad sections: %+v", secs)
|
||||
}
|
||||
if secs[0].Items[0].Icon != "http://plex.local/favicon.ico" {
|
||||
t.Fatalf("favicon resolution failed: %+v", secs[0].Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExternalHomarr(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
body := `{"apps":[{"name":"Plex","url":"http://plex.local","icon":"hl-plex","categoryId":"c1"},{"name":"Sonarr","url":"http://sonarr.local","categoryId":"c1"}],"categories":[{"id":"c1","name":"Media"}]}`
|
||||
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("homarr import: %d", res.StatusCode)
|
||||
}
|
||||
var secs []Section
|
||||
resp, err := http.Get(srv.URL + "/api/sections")
|
||||
if err != nil {
|
||||
t.Fatalf("get sections: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(secs) != 1 || secs[0].Name != "Media" || len(secs[0].Items) != 2 {
|
||||
t.Fatalf("bad sections: %+v", secs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExternalRejectsGarbage(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
res, body := do(t, "POST", srv.URL+"/api/import/external", `{"foo": 1}`)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorLifecycle(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// Target that returns 200.
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Site","type":"http","url":"`+up.URL+`","intervalS":60}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", res.StatusCode, mv)
|
||||
}
|
||||
id := mv["id"].(string)
|
||||
|
||||
// check-now -> up, heartbeat recorded.
|
||||
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
|
||||
if res.StatusCode != http.StatusOK || mv["status"] != "up" {
|
||||
t.Fatalf("check-now: %d %v", res.StatusCode, mv)
|
||||
}
|
||||
resp, err := http.Get(srv.URL + "/api/monitors/" + id + "/heartbeats")
|
||||
if err != nil {
|
||||
t.Fatalf("heartbeats: %v", err)
|
||||
}
|
||||
var hbs []map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&hbs)
|
||||
resp.Body.Close()
|
||||
if len(hbs) != 1 || hbs[0]["status"] != "up" {
|
||||
t.Fatalf("expected 1 up heartbeat, got %v", hbs)
|
||||
}
|
||||
|
||||
// pause -> paused + inactive
|
||||
res, mv = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"active":false}`)
|
||||
if res.StatusCode != http.StatusOK || mv["status"] != "paused" || mv["active"] != false {
|
||||
t.Fatalf("pause: %v", mv)
|
||||
}
|
||||
|
||||
// delete
|
||||
res, _ = do(t, "DELETE", srv.URL+"/api/monitors/"+id, "")
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", res.StatusCode)
|
||||
}
|
||||
res, _ = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorCheckDown(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Dead","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %v", mv)
|
||||
}
|
||||
id := mv["id"].(string)
|
||||
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
|
||||
if res.StatusCode != http.StatusOK || mv["status"] != "down" {
|
||||
t.Fatalf("expected down, got %v", mv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMonitor(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Backup","type":"push","intervalS":60}`)
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %v", mv)
|
||||
}
|
||||
token, _ := mv["pushToken"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("no push token issued")
|
||||
}
|
||||
res, _ = do(t, "GET", srv.URL+"/api/push/"+token+"?msg=done", "")
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("push: %d", res.StatusCode)
|
||||
}
|
||||
res, mv = do(t, "GET", srv.URL+"/api/monitors/"+mv["id"].(string), "")
|
||||
if mv["status"] != "up" {
|
||||
t.Fatalf("expected up after push, got %v", mv["status"])
|
||||
}
|
||||
res, _ = do(t, "GET", srv.URL+"/api/push/bogus", "")
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("bad token: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidpoint(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
if got := midpoint(nil, nil); got != 1024 {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// diBase mirrors the frontend's dashboard-icons CDN root (ServiceDialog).
|
||||
const diBase = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"
|
||||
|
||||
// importExternal ingests a foreign dashboard config — Homepage services.yml,
|
||||
// Homarr JSON export, or Dashy conf.yml — and replaces the board with it.
|
||||
// Format is sniffed from the body; settings are left untouched.
|
||||
func (s *Server) importExternal(c *gin.Context) {
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 8<<20))
|
||||
if err != nil || len(body) == 0 {
|
||||
fail(c, http.StatusBadRequest, "empty or unreadable body")
|
||||
return
|
||||
}
|
||||
sections, err := parseExternal(body)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.replaceBoard(sections, nil); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
board, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, board)
|
||||
}
|
||||
|
||||
// parseExternal sniffs the config format: JSON decodes as Homarr; a YAML
|
||||
// sequence is Homepage's grouped services list; a YAML mapping carrying
|
||||
// `sections` is a Dashy conf.yml.
|
||||
func parseExternal(body []byte) ([]Section, error) {
|
||||
var j any
|
||||
if json.Unmarshal(body, &j) == nil {
|
||||
return parseHomarr(j)
|
||||
}
|
||||
var y any
|
||||
if err := yaml.Unmarshal(body, &y); err != nil {
|
||||
return nil, errors.New("unreadable file — expected JSON or YAML")
|
||||
}
|
||||
switch root := y.(type) {
|
||||
case []any:
|
||||
return parseHomepage(root)
|
||||
case map[string]any:
|
||||
if _, ok := root["sections"]; ok {
|
||||
return parseDashy(body)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unrecognized config — expected a Homepage services.yml, Homarr JSON export, or Dashy conf.yml")
|
||||
}
|
||||
|
||||
// ---------- Homepage ----------
|
||||
|
||||
// parseHomepage walks a services.yml: a sequence of single-key maps
|
||||
// (group name -> sequence of single-key service maps). Bare single-key
|
||||
// entries at the top level land in an "Imported" section.
|
||||
func parseHomepage(groups []any) ([]Section, error) {
|
||||
var secs []Section
|
||||
loose := Section{Name: "Imported"}
|
||||
for _, g := range groups {
|
||||
gm, ok := g.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, v := range gm {
|
||||
switch val := v.(type) {
|
||||
case []any:
|
||||
sec := Section{Name: name, Items: homepageItems(val)}
|
||||
if len(sec.Items) > 0 {
|
||||
secs = append(secs, sec)
|
||||
}
|
||||
case map[string]any:
|
||||
if it, ok := homepageService(name, val); ok {
|
||||
loose.Items = append(loose.Items, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(loose.Items) > 0 {
|
||||
secs = append([]Section{loose}, secs...)
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("homepage: no importable services found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
func homepageItems(list []any) []Item {
|
||||
var items []Item
|
||||
for _, raw := range list {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, fields := range m {
|
||||
fm, _ := fields.(map[string]any)
|
||||
if it, ok := homepageService(name, fm); ok {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// homepageService maps one service entry. Entries without a reachable URL
|
||||
// (pure widget config, bare ping targets) are skipped rather than failing
|
||||
// the whole import.
|
||||
func homepageService(name string, fields map[string]any) (Item, bool) {
|
||||
u := strOf(fields, "href", "siteMonitor", "ping")
|
||||
if strings.TrimSpace(name) == "" || !validURL(u) {
|
||||
return Item{}, false
|
||||
}
|
||||
return importedItem(name, resolveIcon(strOf(fields, "icon"), u), u), true
|
||||
}
|
||||
|
||||
// ---------- Homarr ----------
|
||||
|
||||
// parseHomarr tolerates Homarr's shifting export shapes: `categories[].apps`,
|
||||
// a flat `apps` array grouped by category id, or board `sections[].items`.
|
||||
func parseHomarr(root any) ([]Section, error) {
|
||||
m, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("homarr: expected a JSON object")
|
||||
}
|
||||
var secs []Section
|
||||
seen := map[string]bool{}
|
||||
catNames := map[string]string{}
|
||||
|
||||
for _, c := range arrOf(m["categories"]) {
|
||||
cm, _ := c.(map[string]any)
|
||||
if name := strOf(cm, "name"); name != "" {
|
||||
catNames[strOf(cm, "id")] = name
|
||||
if s := sectionFromApps(name, arrOf(cm["apps"]), seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apps := arrOf(m["apps"]); len(apps) > 0 {
|
||||
var order []string
|
||||
byCat := map[string][]any{}
|
||||
for _, a := range apps {
|
||||
am, _ := a.(map[string]any)
|
||||
cat := strOf(am, "categoryId", "category")
|
||||
if _, ok := byCat[cat]; !ok {
|
||||
order = append(order, cat)
|
||||
}
|
||||
byCat[cat] = append(byCat[cat], a)
|
||||
}
|
||||
for _, cat := range order {
|
||||
name := catNames[cat]
|
||||
if name == "" {
|
||||
name = "Imported"
|
||||
}
|
||||
if s := sectionFromApps(name, byCat[cat], seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range arrOf(m["sections"]) {
|
||||
sm, _ := raw.(map[string]any)
|
||||
if s := sectionFromApps(strOf(sm, "name"), arrOf(sm["items"]), seen); len(s.Items) > 0 {
|
||||
secs = append(secs, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("homarr: no apps found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
func sectionFromApps(name string, apps []any, seen map[string]bool) Section {
|
||||
sec := Section{Name: orDefault(name, "Imported")}
|
||||
for _, a := range apps {
|
||||
am, _ := a.(map[string]any)
|
||||
it, ok := homarrItem(am)
|
||||
if !ok || seen[it.Name+"|"+it.URLs[0].URL] {
|
||||
continue
|
||||
}
|
||||
seen[it.Name+"|"+it.URLs[0].URL] = true
|
||||
sec.Items = append(sec.Items, it)
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func homarrItem(m map[string]any) (Item, bool) {
|
||||
name := strOf(m, "name", "title", "label")
|
||||
u := strOf(m, "url", "href", "appUrl", "externalUrl", "internalUrl")
|
||||
if name == "" || !validURL(u) {
|
||||
return Item{}, false
|
||||
}
|
||||
return importedItem(name, resolveIcon(strOf(m, "icon", "iconUrl"), u), u), true
|
||||
}
|
||||
|
||||
// ---------- Dashy ----------
|
||||
|
||||
type dashyConf struct {
|
||||
Sections []struct {
|
||||
Name string `yaml:"name"`
|
||||
Items []struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
} `yaml:"items"`
|
||||
} `yaml:"sections"`
|
||||
}
|
||||
|
||||
func parseDashy(body []byte) ([]Section, error) {
|
||||
var conf dashyConf
|
||||
if err := yaml.Unmarshal(body, &conf); err != nil {
|
||||
return nil, fmt.Errorf("dashy: malformed conf.yml")
|
||||
}
|
||||
var secs []Section
|
||||
for _, ds := range conf.Sections {
|
||||
sec := Section{Name: orDefault(ds.Name, "Imported")}
|
||||
for _, di := range ds.Items {
|
||||
if di.Title == "" || !validURL(di.URL) {
|
||||
continue
|
||||
}
|
||||
sec.Items = append(sec.Items, importedItem(di.Title, resolveIcon(di.Icon, di.URL), di.URL))
|
||||
}
|
||||
secs = append(secs, sec)
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
return nil, errors.New("dashy: no sections found")
|
||||
}
|
||||
return assignIDs(secs), nil
|
||||
}
|
||||
|
||||
// ---------- shared ----------
|
||||
|
||||
// assignIDs stamps fresh ids and 1024-spaced positions on an imported tree.
|
||||
func assignIDs(secs []Section) []Section {
|
||||
for si := range secs {
|
||||
secs[si].ID = newID("s")
|
||||
secs[si].Position = float64(si+1) * 1024
|
||||
for ii := range secs[si].Items {
|
||||
it := &secs[si].Items[ii]
|
||||
it.ID = newID("i")
|
||||
it.SectionID = secs[si].ID
|
||||
it.Kind = orDefault(it.Kind, "service")
|
||||
it.Position = float64(ii+1) * 1024
|
||||
for ui := range it.URLs {
|
||||
it.URLs[ui].ID = newID("u")
|
||||
it.URLs[ui].Position = float64(ui+1) * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
func importedItem(name, icon, u string) Item {
|
||||
return Item{Kind: "service", Name: name, Icon: icon, URLs: []URL{{URL: u}}}
|
||||
}
|
||||
|
||||
// resolveIcon maps a foreign icon reference to a usable URL: passthrough for
|
||||
// absolute URLs, favicon derivation for Dashy's `favicon`, the dashboard-icons
|
||||
// CDN for prefixed or bare slugs, and "" (letter tile) for local file paths
|
||||
// that only existed on the old dashboard.
|
||||
func resolveIcon(icon, itemURL string) string {
|
||||
icon = strings.TrimSpace(icon)
|
||||
switch {
|
||||
case icon == "":
|
||||
return ""
|
||||
case strings.HasPrefix(icon, "http://"), strings.HasPrefix(icon, "https://"):
|
||||
return icon
|
||||
case icon == "favicon", icon == "generalfavicon":
|
||||
return faviconOf(itemURL)
|
||||
case strings.ContainsAny(icon, "/.\\"):
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(icon)
|
||||
for _, p := range []string{"si-", "sh-", "hl-", "di-", "mdi-"} {
|
||||
if rest, ok := strings.CutPrefix(lower, p); ok {
|
||||
if rest == "" {
|
||||
return ""
|
||||
}
|
||||
return diBase + "/" + rest + ".png"
|
||||
}
|
||||
}
|
||||
return diBase + "/" + lower + ".png"
|
||||
}
|
||||
|
||||
func faviconOf(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/favicon.ico"
|
||||
}
|
||||
|
||||
func arrOf(v any) []any {
|
||||
a, _ := v.([]any)
|
||||
return a
|
||||
}
|
||||
|
||||
func strOf(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
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
|
||||
}
|
||||
@@ -2,11 +2,26 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// errBadInput marks payload validation failures so import handlers can map
|
||||
// them to 400 while genuine DB failures stay 500.
|
||||
var errBadInput = errors.New("invalid import payload")
|
||||
|
||||
// failImport maps replaceBoard errors: bad input -> 400, the rest -> 500.
|
||||
func failImport(c *gin.Context, err error) {
|
||||
if errors.Is(err, errBadInput) {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Export is the portable backup shape: version + settings + board tree.
|
||||
type Export struct {
|
||||
Version int `json:"version"`
|
||||
@@ -36,75 +51,10 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "invalid export payload")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
|
||||
failImport(c, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, sec := range in.Sections {
|
||||
if sec.ID == "" || sec.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "section missing id or name")
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
if it.ID == "" || it.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "item missing id or name")
|
||||
return
|
||||
}
|
||||
var cfg any
|
||||
if it.Config != nil {
|
||||
cfg = string(*it.Config)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, u := range it.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
return
|
||||
}
|
||||
uid := u.ID
|
||||
if uid == "" {
|
||||
uid = newID("u")
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range in.Settings {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sections, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
@@ -113,6 +63,66 @@ func (s *Server) importBoard(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, sections)
|
||||
}
|
||||
|
||||
// replaceBoard swaps all board rows inside one transaction — shared by the
|
||||
// Dash export import and the external-config importers.
|
||||
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sec := range sections {
|
||||
if sec.ID == "" || sec.Name == "" {
|
||||
return fmt.Errorf("%w: section missing id or name", errBadInput)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
if it.ID == "" || it.Name == "" {
|
||||
return fmt.Errorf("%w: item missing id or name", errBadInput)
|
||||
}
|
||||
var cfg any
|
||||
if it.Config != nil {
|
||||
cfg = string(*it.Config)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range it.URLs {
|
||||
if !validURL(u.URL) {
|
||||
return fmt.Errorf("%w: invalid url: %s", errBadInput, u.URL)
|
||||
}
|
||||
uid := u.ID
|
||||
if uid == "" {
|
||||
uid = newID("u")
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range settings {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
|
||||
Reference in new issue
Block a user