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:
Tomas Dvorak committed 2026-09-17 20:29:22 +02:00
1 parent 6004105038
commit 8afc7df53b
21 files changed
+1072 -40

No files matched your search

+10 -2
View File
@@ -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)
+56
View File
@@ -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 {
+32 -11
View File
@@ -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())
+100
View File
@@ -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)
}
+21 -1
View File
@@ -1 +1,21 @@
<!doctype html><meta charset="utf-8"><title>Dash</title><p>Dash API is running. Frontend build is not embedded in this binary.</p>
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dash</title>
<script>
// Pre-paint theme: localStorage mirrors the server setting, applied
// before first paint so there is no flash of the wrong theme.
try {
var t = localStorage.getItem("dash.theme");
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
} catch (e) {}
</script>
<script type="module" crossorigin src="/assets/index-LtBmkZex.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoYED22n.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
package widget
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// AdGuard Home REST API: GET /control/stats with HTTP basic auth.
type adguard struct{}
func init() { register(adguard{}) }
func (adguard) Meta() Type {
return Type{
Type: "adguard",
Name: "AdGuard Home",
Description: "Queries and block rate",
Fields: []Field{
{Key: "endpoint", Label: "Endpoint", Placeholder: "http://adguard.local", Required: true},
{Key: "user", Label: "Username", Required: true},
{Key: "pass", Label: "Password", Secret: true, Required: true},
},
}
}
type adguardData struct {
QueriesToday int `json:"queriesToday"`
BlockedToday int `json:"blockedToday"`
BlockedPercent float64 `json:"blockedPercent"`
AvgProcessMs float64 `json:"avgProcessMs"`
}
func (adguard) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/")
user, pass := cfgStr(cfg, "user"), cfgStr(cfg, "pass")
if endpoint == "" || user == "" {
return nil, fmt.Errorf("endpoint and user required")
}
var raw struct {
NumDNSQueries int `json:"num_dns_queries"`
NumBlockedFiltering int `json:"num_blocked_filtering"`
AvgProcessingTimeSec float64 `json:"avg_processing_time_sec"`
}
// AdGuard uses basic auth; encode via the URL userinfo so the helper
// stays header-free — and the secret never touches a log line.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/control/stats", nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(user, pass)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("upstream unreachable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
var pct float64
if raw.NumDNSQueries > 0 {
pct = float64(raw.NumBlockedFiltering) / float64(raw.NumDNSQueries) * 100
}
return adguardData{
QueriesToday: raw.NumDNSQueries,
BlockedToday: raw.NumBlockedFiltering,
BlockedPercent: pct,
AvgProcessMs: raw.AvgProcessingTimeSec * 1000,
}, nil
}
+29
View File
@@ -0,0 +1,29 @@
package widget
import (
"context"
"encoding/json"
"fmt"
)
// Clock renders client-side — it registers only so /api/widgets/types lists
// it and the add-widget dialog can offer it like any other type.
type clock struct{}
func init() { register(clock{}) }
func (clock) Meta() Type {
return Type{
Type: "clock",
Name: "Clock",
Description: "Local time in any timezone",
Local: true,
Fields: []Field{
{Key: "timezone", Label: "Timezone", Placeholder: "Europe/Prague — blank = local"},
},
}
}
func (clock) Fetch(context.Context, json.RawMessage) (any, error) {
return nil, fmt.Errorf("clock renders client-side")
}
+53
View File
@@ -0,0 +1,53 @@
package widget
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Immich REST API: GET /api/statistics with an x-api-key header.
type immich struct{}
func init() { register(immich{}) }
func (immich) Meta() Type {
return Type{
Type: "immich",
Name: "Immich",
Description: "Library size and photo count",
Fields: []Field{
{Key: "endpoint", Label: "Endpoint", Placeholder: "http://immich.local:2283", Required: true},
{Key: "apiKey", Label: "API key", Secret: true, Required: true},
},
}
}
type immichData struct {
Photos int `json:"photos"`
Videos int `json:"videos"`
UsageB int64 `json:"usageBytes"`
}
func (immich) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/")
key := cfgStr(cfg, "apiKey")
if endpoint == "" || key == "" {
return nil, fmt.Errorf("endpoint and apiKey required")
}
var raw struct {
Images int `json:"images"`
Videos int `json:"videos"`
Usage int64 `json:"usage"`
}
if err := getJSON(ctx, endpoint+"/api/statistics",
map[string]string{"x-api-key": key}, &raw); err != nil {
return nil, err
}
return immichData{
Photos: raw.Images,
Videos: raw.Videos,
UsageB: raw.Usage,
}, nil
}
+62
View File
@@ -0,0 +1,62 @@
package widget
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Pi-hole v5 HTTP API: /admin/api.php?summaryRaw&auth=<token>.
// jarvis: v5 only for now — v6 (/api/...) lands when someone runs it.
type pihole struct{}
func init() { register(pihole{}) }
func (pihole) Meta() Type {
return Type{
Type: "pihole",
Name: "Pi-hole",
Description: "Queries and block rate",
Fields: []Field{
{Key: "endpoint", Label: "Endpoint", Placeholder: "http://pi.hole", Required: true},
{Key: "token", Label: "API token", Secret: true, Placeholder: "web password / api token"},
},
}
}
type piholeData struct {
QueriesToday int `json:"queriesToday"`
BlockedToday int `json:"blockedToday"`
BlockedPercent float64 `json:"blockedPercent"`
GravitySize int `json:"gravitySize"`
Status string `json:"status"`
}
func (pihole) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
endpoint := strings.TrimRight(cfgStr(cfg, "endpoint"), "/")
if endpoint == "" {
return nil, fmt.Errorf("endpoint required")
}
u := endpoint + "/admin/api.php?summaryRaw"
if tok := cfgStr(cfg, "token"); tok != "" {
u += "&auth=" + tok
}
var raw struct {
DNSQueriesToday int `json:"dns_queries_today"`
AdsBlockedToday int `json:"ads_blocked_today"`
AdsPercentToday float64 `json:"ads_percentage_today"`
DomainsBlocked int `json:"domains_being_blocked"`
Status string `json:"status"`
}
if err := getJSON(ctx, u, nil, &raw); err != nil {
return nil, err
}
return piholeData{
QueriesToday: raw.DNSQueriesToday,
BlockedToday: raw.AdsBlockedToday,
BlockedPercent: raw.AdsPercentToday,
GravitySize: raw.DomainsBlocked,
Status: raw.Status,
}, nil
}
+95
View File
@@ -0,0 +1,95 @@
// Package widget is the integration registry: each widget type declares its
// config fields and a Fetcher that pulls live data from the target service.
// Adding an integration = one file here + one React component.
package widget
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"time"
)
// Field describes one config input rendered by the add-widget dialog.
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Secret bool `json:"secret,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required,omitempty"`
}
// Type is registry metadata for one widget, served at /api/widgets/types.
type Type struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Local bool `json:"local,omitempty"`
Fields []Field `json:"fields"`
}
// Fetcher pulls live data for one configured widget item. cfg is the item's
// config JSON — never log it, it may hold API keys.
type Fetcher interface {
Meta() Type
Fetch(ctx context.Context, cfg json.RawMessage) (any, error)
}
var registry = map[string]Fetcher{}
func register(f Fetcher) { registry[f.Meta().Type] = f }
// Get resolves a config.type to its fetcher.
func Get(t string) (Fetcher, bool) {
f, ok := registry[t]
return f, ok
}
// Types lists all registered types, sorted for a stable dialog order.
func Types() []Type {
out := make([]Type, 0, len(registry))
for _, f := range registry {
out = append(out, f.Meta())
}
sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type })
return out
}
var client = &http.Client{Timeout: 6 * time.Second}
// getJSON fetches url and decodes the body into out. headers adds auth etc.
// The caller's config is never logged — errors carry only status codes.
func getJSON(ctx context.Context, url string, headers map[string]string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("upstream unreachable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return fmt.Errorf("upstream returned %d", resp.StatusCode)
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
// cfgStr reads a string field from a config blob.
func cfgStr(cfg json.RawMessage, key string) string {
var m map[string]any
if err := json.Unmarshal(cfg, &m); err != nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
return ""
}