Files
Dash/apps/backend/internal/api/api.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

178 lines
4.8 KiB
Go

// Package api wires the Gin router and /api handlers.
package api
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/widget"
"go.uber.org/zap"
)
// 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.
// 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())
v1 := r.Group("/api")
v1.GET("/healthz", s.healthz)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
v1.DELETE("/sections/:id", s.deleteSection)
v1.POST("/sections/reorder", s.reorderSection)
v1.POST("/items", s.createItem)
v1.PATCH("/items/:id", s.updateItem)
v1.DELETE("/items/:id", s.deleteItem)
v1.POST("/items/reorder", s.reorderItem)
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)
v1.GET("/settings", s.getSettings)
v1.PUT("/settings", s.putSettings)
v1.GET("/export", s.exportBoard)
v1.POST("/import", s.importBoard)
v1.POST("/import/external", s.importExternal)
return r
}
// ---------- shared types (mirror openapi.yaml) ----------
// URL is one launch target of an item (local, external, custom label).
type URL struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
Position float64 `json:"position"`
}
// URLInput is a URL as accepted on create/update (id/position assigned).
type URLInput struct {
URL string `json:"url"`
Label string `json:"label"`
}
// Item is a board cell: a service tile today, a widget in Phase 2.
type Item struct {
ID string `json:"id"`
SectionID string `json:"sectionId"`
Kind string `json:"kind"`
Name string `json:"name"`
Icon string `json:"icon"`
Position float64 `json:"position"`
Config *json.RawMessage `json:"config"`
URLs []URL `json:"urls"`
}
// Section is a named, collapsible group of items.
type Section struct {
ID string `json:"id"`
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
Items []Item `json:"items"`
}
// ---------- helpers ----------
func newID(prefix string) string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return prefix + "_" + hex.EncodeToString(b)
}
// midpoint returns a fractional position between two optional neighbors.
func midpoint(before, after *float64) float64 {
switch {
case before == nil && after == nil:
return 1024
case before == nil:
return *after - 1024
case after == nil:
return *before + 1024
default:
return (*before + *after) / 2
}
}
func fail(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"error": msg})
}
// validURL enforces http(s) launch targets — also keeps /api/status safe
// from file:// or internal-scheme abuse.
func validURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }
// healthz reports liveness and DB reachability.
func (s *Server) healthz(c *gin.Context) {
if err := s.db.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// accessLog is gin.Logger backed by zap: one structured line per request.
func accessLog(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", time.Since(start)),
)
}
}