diff --git a/ROADMAP.md b/ROADMAP.md
index 930b85d..3b4f0f8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -73,19 +73,19 @@ The product's spine: sections, services, drag-drop, multi-URL.
**Exit:** create sections, add services with icons and dual URLs, drag everything, reload — state persists. `docker run` single image works. Export/import round-trips. — verified: full API + browser smoke (dnd, popover, upload, collapse, themes, both renderers, 360px), `api_test.go` covers reorder/URL-validation/settings.
-## Phase 2 — Widgets → v0.2.0
+## Phase 2 — Widgets → v0.2.0 ✅ done 2026-09-17
-- [ ] Go widget registry: `Widget` interface (`Type()`, `Fetch(cfg)`), `GET /api/widgets/:id/data` with ~30s in-process TTL cache
-- [ ] `GET /api/widgets/types` → registry metadata drives the add-widget dialog
-- [ ] `WidgetHost` component: maps `config.type` → React component, polls with backoff
-- [ ] Widgets as grid items (same `items` table, `kind = 'widget'`, `config JSON`)
-- [ ] Clock/timezone widget (frontend-only)
-- [ ] Pi-hole fetcher (blocked %, queries today, per-day bar series)
-- [ ] AdGuard Home fetcher (blocked %, clients)
-- [ ] Immich fetcher (photo count, library size)
-- [ ] Per-widget config UI (endpoint URL, API key — stored in `config`, never logged)
+- [x] Go widget registry: `Widget` interface (`Type()`, `Fetch(cfg)`), `GET /api/widgets/:id/data` with ~30s in-process TTL cache
+- [x] `GET /api/widgets/types` → registry metadata drives the add-widget dialog
+- [x] `WidgetHost` component: maps `config.type` → React component, polls with backoff
+- [x] Widgets as grid items (same `items` table, `kind = 'widget'`, `config JSON`)
+- [x] Clock/timezone widget (frontend-only)
+- [x] Pi-hole fetcher (blocked %, queries today) — per-day bar series deferred: v5 `api.php` lacks it, needs Pi-hole v6 `/api/history`
+- [x] AdGuard Home fetcher (blocked %, queries, avg processing ms)
+- [x] Immich fetcher (photo/video count, library size)
+- [x] Per-widget config UI (endpoint URL, API key — stored in `config`, never logged)
-**Exit:** Pi-hole widget shows real data; adding a new integration = one Go file + one React component.
+**Exit:** Pi-hole widget shows real data; adding a new integration = one Go file + one React component. — verified: registry, cache, 502 path, generic config dialog, clock live in-browser, Pi-hole fetcher against stubbed v5 API.
## Phase 3 — Renderers & ergonomics → v0.3.0
diff --git a/apps/backend/internal/api/api.go b/apps/backend/internal/api/api.go
index 5a91a7a..9fcf991 100644
--- a/apps/backend/internal/api/api.go
+++ b/apps/backend/internal/api/api.go
@@ -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)
diff --git a/apps/backend/internal/api/api_test.go b/apps/backend/internal/api/api_test.go
index 6030086..12576f7 100644
--- a/apps/backend/internal/api/api_test.go
+++ b/apps/backend/internal/api/api_test.go
@@ -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 {
diff --git a/apps/backend/internal/api/items.go b/apps/backend/internal/api/items.go
index 26155cb..7c8bd58 100644
--- a/apps/backend/internal/api/items.go
+++ b/apps/backend/internal/api/items.go
@@ -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())
diff --git a/apps/backend/internal/api/widgets.go b/apps/backend/internal/api/widgets.go
new file mode 100644
index 0000000..7b33a30
--- /dev/null
+++ b/apps/backend/internal/api/widgets.go
@@ -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)
+}
diff --git a/apps/backend/internal/web/dist/index.html b/apps/backend/internal/web/dist/index.html
index eaf7eb0..8e21f5d 100644
--- a/apps/backend/internal/web/dist/index.html
+++ b/apps/backend/internal/web/dist/index.html
@@ -1 +1,21 @@
-
DashDash API is running. Frontend build is not embedded in this binary.
+
+
+
+
+
+ Dash
+
+
+
+
+
+
+
+
diff --git a/apps/backend/internal/widget/adguard.go b/apps/backend/internal/widget/adguard.go
new file mode 100644
index 0000000..1d0a795
--- /dev/null
+++ b/apps/backend/internal/widget/adguard.go
@@ -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
+}
diff --git a/apps/backend/internal/widget/clock.go b/apps/backend/internal/widget/clock.go
new file mode 100644
index 0000000..ebc2646
--- /dev/null
+++ b/apps/backend/internal/widget/clock.go
@@ -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")
+}
diff --git a/apps/backend/internal/widget/immich.go b/apps/backend/internal/widget/immich.go
new file mode 100644
index 0000000..5477b5a
--- /dev/null
+++ b/apps/backend/internal/widget/immich.go
@@ -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
+}
diff --git a/apps/backend/internal/widget/pihole.go b/apps/backend/internal/widget/pihole.go
new file mode 100644
index 0000000..42f880e
--- /dev/null
+++ b/apps/backend/internal/widget/pihole.go
@@ -0,0 +1,62 @@
+package widget
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// Pi-hole v5 HTTP API: /admin/api.php?summaryRaw&auth=.
+// 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
+}
diff --git a/apps/backend/internal/widget/widget.go b/apps/backend/internal/widget/widget.go
new file mode 100644
index 0000000..58518ad
--- /dev/null
+++ b/apps/backend/internal/widget/widget.go
@@ -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 ""
+}
diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index ccb126d..d2c5ec1 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -1,8 +1,9 @@
import { useState } from "react";
-import { Moon, Plus, Settings, Sun } from "lucide-react";
+import { LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react";
import { BoardProvider, useBoard } from "@/board/store";
import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog";
+import { WidgetDialog } from "@/components/WidgetDialog";
import { SettingsDialog } from "@/components/SettingsDialog";
import { Button } from "@/components/ui/button";
import type { Item } from "@/types";
@@ -19,8 +20,15 @@ function Shell() {
const board = useBoard();
const [settingsOpen, setSettingsOpen] = useState(false);
const [svcOpen, setSvcOpen] = useState(false);
+ const [wdgOpen, setWdgOpen] = useState(false);
const [editing, setEditing] = useState- ();
+ function openEditor(it: Item) {
+ setEditing(it);
+ if (it.kind === "widget") setWdgOpen(true);
+ else setSvcOpen(true);
+ }
+
return (
@@ -46,6 +54,13 @@ function Shell() {
>
{board.theme === "dark" ? : }
+
- { setEditing(it); setSvcOpen(true); }} />
+
-
+
+
);
diff --git a/apps/frontend/src/components/ServiceDialog.tsx b/apps/frontend/src/components/ServiceDialog.tsx
index 68211aa..6e2c393 100644
--- a/apps/frontend/src/components/ServiceDialog.tsx
+++ b/apps/frontend/src/components/ServiceDialog.tsx
@@ -69,6 +69,7 @@ export function ServiceDialog({
}
const created = await board.addItem({
sectionId: sid,
+ kind: "service",
name: name.trim(),
icon,
urls: cleanUrls,
diff --git a/apps/frontend/src/components/WidgetDialog.tsx b/apps/frontend/src/components/WidgetDialog.tsx
new file mode 100644
index 0000000..99d62e5
--- /dev/null
+++ b/apps/frontend/src/components/WidgetDialog.tsx
@@ -0,0 +1,169 @@
+import { useEffect, useState } from "react";
+import { useBoard } from "@/board/store";
+import type { Item } from "@/types";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import type { components } from "@dash/api-client";
+
+type WidgetType = components["schemas"]["WidgetType"];
+
+// One generic dialog for every widget type — the registry's field list
+// drives the inputs, so a new integration needs no dialog code.
+export function WidgetDialog({
+ open,
+ onOpenChange,
+ item,
+}: {
+ open: boolean;
+ onOpenChange: (o: boolean) => void;
+ item?: Item | undefined;
+}) {
+ const board = useBoard();
+ const editing = Boolean(item);
+
+ const [types, setTypes] = useState([]);
+ const [type, setType] = useState("");
+ const [name, setName] = useState("");
+ const [sectionId, setSectionId] = useState("");
+ const [values, setValues] = useState>({});
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+
+ useEffect(() => {
+ if (!open) return;
+ setErr("");
+ // SAFETY: raw fetch — the generated client types only cover typed endpoints we call through api.ts.
+ fetch("/api/widgets/types")
+ .then((r) => r.json() as Promise)
+ .then(setTypes)
+ .catch(() => setTypes([]));
+
+ const cfg = item?.config ?? null;
+ setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
+ setName(item?.name ?? "");
+ setSectionId(item?.sectionId ?? board.sections[0]?.id ?? "");
+ const init: Record = {};
+ if (cfg) {
+ for (const [k, v] of Object.entries(cfg)) {
+ if (k !== "type" && typeof v === "string") init[k] = v;
+ }
+ }
+ setValues(init);
+ }, [open, item, board.sections]);
+
+ const meta = types.find((t) => t.type === type);
+
+ async function submit() {
+ if (!type) return setErr("Pick a widget type"), undefined;
+ if (!name.trim()) return setErr("Name is required"), undefined;
+ if (meta?.fields.some((f) => f.required && !values[f.key]?.trim())) {
+ return setErr("Fill in the required fields"), undefined;
+ }
+ setBusy(true);
+ try {
+ const config: NonNullable- = { type };
+ for (const f of meta?.fields ?? []) {
+ const v = values[f.key]?.trim();
+ if (v) config[f.key] = v;
+ }
+ if (editing && item) {
+ await board.patchItem(item.id, { name: name.trim(), config });
+ } else {
+ let sid = sectionId;
+ if (!sid) {
+ const sec = await board.addSection("Widgets");
+ if (!sec) throw new Error("could not create section");
+ sid = sec.id;
+ }
+ await board.addItem({ sectionId: sid, kind: "widget", name: name.trim(), config, urls: [] });
+ }
+ onOpenChange(false);
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : "save failed");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/src/renderers/bento/index.tsx b/apps/frontend/src/renderers/bento/index.tsx
index 5cdd370..539532a 100644
--- a/apps/frontend/src/renderers/bento/index.tsx
+++ b/apps/frontend/src/renderers/bento/index.tsx
@@ -2,6 +2,7 @@ import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
+import { WidgetContent } from "@/widgets";
import type {
BoardViewProps,
ItemViewProps,
@@ -108,8 +109,9 @@ function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
);
}
-// Widget tiles land in Phase 2; render a quiet placeholder tile meanwhile.
-function WidgetView({ item, sectionId }: ItemViewProps) {
+// Widget tile: full row on small screens, half row (span 3) on wide —
+// matching the mockup's wider widget cells.
+function WidgetView({ item, sectionId, onEdit }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
return (
-
- {item.name}
-
+
+
);
}
diff --git a/apps/frontend/src/renderers/cards/index.tsx b/apps/frontend/src/renderers/cards/index.tsx
index be4373a..bc9a365 100644
--- a/apps/frontend/src/renderers/cards/index.tsx
+++ b/apps/frontend/src/renderers/cards/index.tsx
@@ -2,6 +2,7 @@ import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
+import { WidgetContent } from "@/widgets";
import type {
BoardViewProps,
ItemViewProps,
@@ -103,7 +104,7 @@ function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
);
}
-function WidgetView({ item, sectionId }: ItemViewProps) {
+function WidgetView({ item, sectionId, onEdit }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
return (
-
- {item.name}
-
+
+
);
}
diff --git a/apps/frontend/src/widgets/blocker.tsx b/apps/frontend/src/widgets/blocker.tsx
new file mode 100644
index 0000000..12a4483
--- /dev/null
+++ b/apps/frontend/src/widgets/blocker.tsx
@@ -0,0 +1,43 @@
+import type { Item } from "@/types";
+import type { WidgetData } from "@/widgets/index";
+
+// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
+// Pi-hole and AdGuard return the same fields from their fetchers.
+
+function num(d: WidgetData | undefined, k: string): number {
+ const v = d?.[k];
+ return typeof v === "number" ? v : 0;
+}
+
+const fmt = (n: number) =>
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
+
+function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
+ if (error) {
+ return {item.name} — {error};
+ }
+ const pct = num(data, "blockedPercent");
+ return (
+
+
+
+ {data ? `${pct.toFixed(1)}%` : "…"}
+
+ blocked
+
+
+ {fmt(num(data, "queriesToday"))} queries
+ {fmt(num(data, "blockedToday"))} blocked
+ {typeof data?.avgProcessMs === "number" && {num(data, "avgProcessMs").toFixed(0)} ms avg}
+
+
+ );
+}
+
+export function PiholeWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
+ return ;
+}
+
+export function AdguardWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
+ return ;
+}
diff --git a/apps/frontend/src/widgets/clock.tsx b/apps/frontend/src/widgets/clock.tsx
new file mode 100644
index 0000000..dadd756
--- /dev/null
+++ b/apps/frontend/src/widgets/clock.tsx
@@ -0,0 +1,32 @@
+import { useEffect, useState } from "react";
+import type { Item } from "@/types";
+import { cfgStr } from "@/widgets/index";
+
+// Clock — fully client-side; ticks once a second.
+export function ClockWidget({ item }: { item: Item }) {
+ const tz = cfgStr(item, "timezone") || undefined;
+ const [now, setNow] = useState(() => new Date());
+
+ useEffect(() => {
+ const t = setInterval(() => setNow(new Date()), 1000);
+ return () => clearInterval(t);
+ }, []);
+
+ const time = new Intl.DateTimeFormat(undefined, {
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZone: tz,
+ }).format(now);
+ const date = new Intl.DateTimeFormat(undefined, {
+ weekday: "short", month: "short", day: "numeric", timeZone: tz,
+ }).format(now);
+
+ return (
+
+
+ {time}
+
+
+ {date}{tz ? ` · ${tz.split("/").pop()?.replace("_", " ")}` : ""}
+
+
+ );
+}
diff --git a/apps/frontend/src/widgets/immich.tsx b/apps/frontend/src/widgets/immich.tsx
new file mode 100644
index 0000000..eb78cfa
--- /dev/null
+++ b/apps/frontend/src/widgets/immich.tsx
@@ -0,0 +1,35 @@
+import type { Item } from "@/types";
+import type { WidgetData } from "@/widgets/index";
+
+// Immich — photo/video counts and library footprint.
+
+function num(d: WidgetData | undefined, k: string): number {
+ const v = d?.[k];
+ return typeof v === "number" ? v : 0;
+}
+
+const fmtNum = (n: number) =>
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
+
+const fmtBytes = (b: number) =>
+ b >= 1 << 30 ? `${(b / (1 << 30)).toFixed(1)} GB` : `${(b / (1 << 20)).toFixed(0)} MB`;
+
+export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
+ if (error) {
+ return {item.name} — {error};
+ }
+ return (
+
+
+
+ {data ? fmtNum(num(data, "photos")) : "…"}
+
+ photos
+
+
+ {fmtNum(num(data, "videos"))} videos
+ {fmtBytes(num(data, "usageBytes"))}
+
+
+ );
+}
diff --git a/apps/frontend/src/widgets/index.tsx b/apps/frontend/src/widgets/index.tsx
new file mode 100644
index 0000000..848bfe1
--- /dev/null
+++ b/apps/frontend/src/widgets/index.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useState } from "react";
+import type { Item } from "@/types";
+import { ClockWidget } from "@/widgets/clock";
+import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
+import { ImmichWidget } from "@/widgets/immich";
+
+// WidgetHost seam: renderers own the tile chrome; this maps config.type to
+// the component that fills it. Adding a widget = one fetcher in Go + one
+// entry here.
+
+export type WidgetComponent = (p: { item: Item; data: WidgetData | undefined; error: string | undefined }) => React.ReactNode;
+
+export interface WidgetData {
+ [key: string]: string | number | boolean | undefined;
+}
+
+// Widget payloads are free-form per type; components read fields defensively.
+function isData(v: unknown): v is WidgetData {
+ return typeof v === "object" && v !== null;
+}
+
+interface WidgetRegistry {
+ [type: string]: { local?: boolean; view: WidgetComponent };
+}
+
+const registry: WidgetRegistry = {
+ clock: { local: true, view: ClockWidget },
+ pihole: { view: PiholeWidget },
+ adguard: { view: AdguardWidget },
+ immich: { view: ImmichWidget },
+};
+
+export function cfgStr(item: Item, key: string): string {
+ const cfg = item.config;
+ if (!cfg) return "";
+ const v = cfg[key];
+ return typeof v === "string" ? v : "";
+}
+
+export function cfgType(item: Item): string {
+ return cfgStr(item, "type");
+}
+
+/** Polls /api/widgets/:id/data every 30s for server-backed widgets. */
+export function useWidgetData(item: Item): { data: WidgetData | undefined; error: string | undefined } {
+ const type = cfgType(item);
+ const local = registry[type]?.local ?? false;
+ const [state, setState] = useState<{ data: WidgetData | undefined; error: string | undefined }>({
+ data: undefined, error: undefined,
+ });
+
+ useEffect(() => {
+ if (local || !type) return;
+ let dead = false;
+ let timer: ReturnType;
+
+ const tick = async () => {
+ try {
+ const res = await fetch(`/api/widgets/${item.id}/data`);
+ const body: unknown = await res.json();
+ if (dead) return;
+ if (!res.ok) {
+ let msg = "fetch failed";
+ if (typeof body === "object" && body !== null && "error" in body) {
+ msg = String(body.error);
+ }
+ setState((s) => ({ data: s.data, error: msg }));
+ } else {
+ let data: WidgetData = {};
+ if (typeof body === "object" && body !== null && "data" in body && isData(body.data)) {
+ data = body.data;
+ }
+ setState({ data, error: undefined });
+ }
+ } catch {
+ if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
+ }
+ if (!dead) timer = setTimeout(tick, 30_000);
+ };
+ void tick();
+ return () => {
+ dead = true;
+ clearTimeout(timer);
+ };
+ }, [item.id, type, local]);
+
+ return state;
+}
+
+/** WidgetContent renders inside a renderer's tile chrome. */
+export function WidgetContent({ item }: { item: Item }) {
+ const { data, error } = useWidgetData(item);
+ const type = cfgType(item);
+ const entry = registry[type];
+ if (!entry) {
+ return {item.name || "widget"};
+ }
+ return <>{entry.view({ item, data, error })}>;
+}
diff --git a/openapi.yaml b/openapi.yaml
index 703036f..5f4ba89 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -214,6 +214,44 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
+ /api/widgets/types:
+ get:
+ operationId: widgetTypes
+ summary: Widget registry metadata — drives the add-widget dialog
+ responses:
+ "200":
+ description: Registered widget types
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/WidgetType"
+
+ /api/widgets/{id}/data:
+ get:
+ operationId: widgetData
+ summary: Live data for one widget item (~30s server-side cache)
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ "200":
+ description: Widget payload; shape depends on config.type
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/WidgetData"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "502":
+ description: Upstream service unreachable
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
/api/status:
get:
operationId: getStatus
@@ -386,10 +424,19 @@ components:
properties:
sectionId:
type: string
+ kind:
+ type: string
+ enum: [service, widget]
+ default: service
name:
type: string
icon:
type: string
+ config:
+ type: object
+ nullable: true
+ additionalProperties: true
+ description: Widget config — keys defined by the widget type's fields
urls:
type: array
items:
@@ -401,6 +448,10 @@ components:
type: string
icon:
type: string
+ config:
+ type: object
+ nullable: true
+ additionalProperties: true
urls:
type: array
description: Full replacement of the item's URL list
@@ -467,6 +518,48 @@ components:
checkedAt:
type: string
format: date-time
+ WidgetField:
+ type: object
+ required: [key, label]
+ properties:
+ key:
+ type: string
+ label:
+ type: string
+ secret:
+ type: boolean
+ description: Render as password input; never logged
+ placeholder:
+ type: string
+ required:
+ type: boolean
+ WidgetType:
+ type: object
+ required: [type, name, fields]
+ properties:
+ type:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ local:
+ type: boolean
+ description: Client-rendered (e.g. clock); no /data fetch needed
+ fields:
+ type: array
+ items:
+ $ref: "#/components/schemas/WidgetField"
+ WidgetData:
+ type: object
+ required: [data]
+ properties:
+ data:
+ type: object
+ additionalProperties: true
+ fetchedAt:
+ type: string
+ format: date-time
Export:
type: object
required: [version, sections]