mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: phase 2 widgets — registry, fetchers, clock, config dialog
Backend: internal/widget registry (type metadata + Fetcher), Pi-hole v5 / AdGuard / Immich fetchers, /api/widgets/types + /api/widgets/:id/data with 30s TTL cache (failures cached too), kind+config on item create/patch. Config secrets are never logged or echoed in errors. Frontend: WidgetContent host maps config.type to a component, polls /data every 30s for server-backed types; clock renders client-side. Generic WidgetDialog builds its form from the registry's field list. Widget tiles are sortable items in both renderers. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6004105038
commit
8afc7df53b
21 files changed
+1072
-40
No files matched your search
@@ -15,16 +15,22 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server holds handler dependencies: DB, icon dir, status cache.
|
||||
// Server holds handler dependencies: DB, icon dir, status + widget caches.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
iconsDir string
|
||||
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 {
|
||||
s := &Server{db: db, iconsDir: iconsDir, status: newStatusCache(60 * time.Second)}
|
||||
s := &Server{
|
||||
db: db,
|
||||
iconsDir: iconsDir,
|
||||
status: newStatusCache(60 * time.Second),
|
||||
widgets: newWidgetCache(30 * time.Second),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(accessLog(logger), gin.Recovery())
|
||||
@@ -46,6 +52,8 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
v1.GET("/icons/:file", s.getIcon)
|
||||
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/db"
|
||||
@@ -122,6 +123,61 @@ func TestSettingsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetEndpoints(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// Types registry serves all integrations incl. the local clock.
|
||||
resp, err := http.Get(srv.URL + "/api/widgets/types")
|
||||
if err != nil {
|
||||
t.Fatalf("get types: %v", err)
|
||||
}
|
||||
var types []struct {
|
||||
Type string `json:"type"`
|
||||
Local bool `json:"local"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&types); err != nil {
|
||||
t.Fatalf("decode types: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
got := map[string]bool{}
|
||||
for _, w := range types {
|
||||
got[w.Type] = w.Local
|
||||
}
|
||||
for _, want := range []string{"clock", "pihole", "adguard", "immich"} {
|
||||
if _, ok := got[want]; !ok {
|
||||
t.Fatalf("missing widget type %s in %v", want, got)
|
||||
}
|
||||
}
|
||||
if !got["clock"] {
|
||||
t.Fatalf("clock must be local, got %v", got)
|
||||
}
|
||||
|
||||
// Widget data on a service item -> 400; missing -> 404.
|
||||
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Svc"}`)
|
||||
res, _ := do(t, "GET", srv.URL+"/api/widgets/"+it["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("service item data: %d", res.StatusCode)
|
||||
}
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/i_missing/data", "")
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("missing item: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Widget item with dead upstream -> 502, then cached (second call fast).
|
||||
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"PH","config":{"type":"pihole","endpoint":"http://127.0.0.1:1","token":"x"}}`)
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("dead upstream: %d", res.StatusCode)
|
||||
}
|
||||
start := time.Now()
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadGateway || time.Since(start) > time.Second {
|
||||
t.Fatalf("expected cached 502, got %d in %v", res.StatusCode, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidpoint(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
if got := midpoint(nil, nil); got != 1024 {
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
)
|
||||
|
||||
type itemInput struct {
|
||||
SectionID string `json:"sectionId"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLs []URLInput `json:"urls"`
|
||||
SectionID string `json:"sectionId"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
URLs []URLInput `json:"urls"`
|
||||
}
|
||||
|
||||
func (s *Server) createItem(c *gin.Context) {
|
||||
@@ -21,6 +23,14 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "sectionId and name required")
|
||||
return
|
||||
}
|
||||
kind := in.Kind
|
||||
if kind == "" {
|
||||
kind = "service"
|
||||
}
|
||||
if kind != "service" && kind != "widget" {
|
||||
fail(c, http.StatusBadRequest, "kind must be service or widget")
|
||||
return
|
||||
}
|
||||
for _, u := range in.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
@@ -35,8 +45,8 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: "service", Name: in.Name, Icon: in.Icon,
|
||||
Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
|
||||
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: kind, Name: in.Name, Icon: in.Icon,
|
||||
Config: in.Config, Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -45,8 +55,12 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO items (id, section_id, name, icon, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
it.ID, it.SectionID, it.Name, it.Icon, it.Position); err != nil {
|
||||
var cfg any
|
||||
if in.Config != nil {
|
||||
cfg = string(*in.Config)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ID, it.SectionID, it.Kind, it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -76,9 +90,10 @@ func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error {
|
||||
|
||||
func (s *Server) updateItem(c *gin.Context) {
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
URLs *[]URLInput `json:"urls"`
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
URLs *[]URLInput `json:"urls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
@@ -113,6 +128,12 @@ func (s *Server) updateItem(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Config != nil {
|
||||
if _, err := tx.Exec(`UPDATE items SET config = ? WHERE id = ?`, string(*in.Config), id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.URLs != nil {
|
||||
if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/widget"
|
||||
)
|
||||
|
||||
// widgetEntry is one cached fetch result.
|
||||
type widgetEntry struct {
|
||||
Data any `json:"data"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
err error
|
||||
}
|
||||
|
||||
// widgetCache is a TTL map of item id -> fetch result. Local widgets never
|
||||
// reach this — the frontend renders them without a fetch.
|
||||
type widgetCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
entries map[string]widgetEntry
|
||||
}
|
||||
|
||||
func newWidgetCache(ttl time.Duration) *widgetCache {
|
||||
return &widgetCache{ttl: ttl, entries: map[string]widgetEntry{}}
|
||||
}
|
||||
|
||||
func (s *Server) widgetTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, widget.Types())
|
||||
}
|
||||
|
||||
// widgetData resolves the item's config.type, fetches via the registry,
|
||||
// and caches per item for ~30s. Fetch errors become 502 with only the error
|
||||
// message — config values (API keys) must never leak into a response.
|
||||
func (s *Server) widgetData(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
s.widgets.mu.Lock()
|
||||
if e, ok := s.widgets.entries[id]; ok && time.Since(e.FetchedAt) < s.widgets.ttl {
|
||||
s.widgets.mu.Unlock()
|
||||
if e.err != nil {
|
||||
c.JSON(http.StatusBadGateway, e.Data)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, e)
|
||||
return
|
||||
}
|
||||
s.widgets.mu.Unlock()
|
||||
|
||||
it, err := s.loadItem(id)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if it.Kind != "widget" || it.Config == nil {
|
||||
fail(c, http.StatusBadRequest, "not a widget item")
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(*it.Config, &cfg); err != nil || cfg.Type == "" {
|
||||
fail(c, http.StatusBadRequest, "widget config missing type")
|
||||
return
|
||||
}
|
||||
f, ok := widget.Get(cfg.Type)
|
||||
if !ok {
|
||||
fail(c, http.StatusBadRequest, "unknown widget type: "+cfg.Type)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := f.Fetch(c.Request.Context(), *it.Config)
|
||||
e := widgetEntry{FetchedAt: time.Now()}
|
||||
if err != nil {
|
||||
// Cache failures briefly too — a dead upstream should not turn
|
||||
// every poll into a live request.
|
||||
e.Data = gin.H{"error": err.Error()}
|
||||
e.err = err
|
||||
} else {
|
||||
e.Data = data
|
||||
}
|
||||
|
||||
s.widgets.mu.Lock()
|
||||
s.widgets.entries[id] = e
|
||||
s.widgets.mu.Unlock()
|
||||
|
||||
if e.err != nil {
|
||||
c.JSON(http.StatusBadGateway, e.Data)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, e)
|
||||
}
|
||||
Reference in new issue
Block a user