From 8afc7df53bfb15962c3eb799050ac36a11ed3eca Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 16:21:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=202=20widgets=20=E2=80=94=20regis?= =?UTF-8?q?try,=20fetchers,=20clock,=20config=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 22 +-- apps/backend/internal/api/api.go | 12 +- apps/backend/internal/api/api_test.go | 56 ++++++ apps/backend/internal/api/items.go | 43 +++-- apps/backend/internal/api/widgets.go | 100 +++++++++++ apps/backend/internal/web/dist/index.html | 22 ++- apps/backend/internal/widget/adguard.go | 75 ++++++++ apps/backend/internal/widget/clock.go | 29 +++ apps/backend/internal/widget/immich.go | 53 ++++++ apps/backend/internal/widget/pihole.go | 62 +++++++ apps/backend/internal/widget/widget.go | 95 ++++++++++ apps/frontend/src/App.tsx | 22 ++- .../frontend/src/components/ServiceDialog.tsx | 1 + apps/frontend/src/components/WidgetDialog.tsx | 169 ++++++++++++++++++ apps/frontend/src/renderers/bento/index.tsx | 27 ++- apps/frontend/src/renderers/cards/index.tsx | 22 ++- apps/frontend/src/widgets/blocker.tsx | 43 +++++ apps/frontend/src/widgets/clock.tsx | 32 ++++ apps/frontend/src/widgets/immich.tsx | 35 ++++ apps/frontend/src/widgets/index.tsx | 99 ++++++++++ openapi.yaml | 93 ++++++++++ 21 files changed, 1072 insertions(+), 40 deletions(-) create mode 100644 apps/backend/internal/api/widgets.go create mode 100644 apps/backend/internal/widget/adguard.go create mode 100644 apps/backend/internal/widget/clock.go create mode 100644 apps/backend/internal/widget/immich.go create mode 100644 apps/backend/internal/widget/pihole.go create mode 100644 apps/backend/internal/widget/widget.go create mode 100644 apps/frontend/src/components/WidgetDialog.tsx create mode 100644 apps/frontend/src/widgets/blocker.tsx create mode 100644 apps/frontend/src/widgets/clock.tsx create mode 100644 apps/frontend/src/widgets/immich.tsx create mode 100644 apps/frontend/src/widgets/index.tsx 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 @@ -Dash

Dash 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 ( + + + + {editing ? "Edit widget" : "Add widget"} + + {editing ? "Update widget settings." : "Pick a type and point it at your service."} + + + +
+
+
+ + setName(e.target.value)} placeholder="Pi-hole" autoFocus /> +
+ {!editing && board.sections.length > 0 && ( +
+ + +
+ )} +
+ +
+ + + {meta?.description ? ( +

{meta.description}

+ ) : null} +
+ + {meta?.fields.map((f) => ( +
+ + setValues((v) => ({ ...v, [f.key]: e.target.value }))} + className="font-mono text-[12px]" + /> +
+ ))} + + {err ?

{err}

: null} +
+ + + + + +
+
+ ); +} 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]