mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: phase 2 widgets — registry, fetchers, clock, config dialog
Backend: internal/widget registry (type metadata + Fetcher), Pi-hole v5 / AdGuard / Immich fetchers, /api/widgets/types + /api/widgets/:id/data with 30s TTL cache (failures cached too), kind+config on item create/patch. Config secrets are never logged or echoed in errors. Frontend: WidgetContent host maps config.type to a component, polls /data every 30s for server-backed types; clock renders client-side. Generic WidgetDialog builds its form from the registry's field list. Widget tiles are sortable items in both renderers. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6004105038
commit
8afc7df53b
21 files changed
+1072
-40
No files matched your search
+11
-11
@@ -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
|
||||
|
||||
|
||||
@@ -15,16 +15,22 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server holds handler dependencies: DB, icon dir, status cache.
|
||||
// Server holds handler dependencies: DB, icon dir, status + widget caches.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
iconsDir string
|
||||
status *statusCache
|
||||
widgets *widgetCache
|
||||
}
|
||||
|
||||
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
||||
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
s := &Server{db: db, iconsDir: iconsDir, status: newStatusCache(60 * time.Second)}
|
||||
s := &Server{
|
||||
db: db,
|
||||
iconsDir: iconsDir,
|
||||
status: newStatusCache(60 * time.Second),
|
||||
widgets: newWidgetCache(30 * time.Second),
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(accessLog(logger), gin.Recovery())
|
||||
@@ -46,6 +52,8 @@ func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
v1.GET("/icons/:file", s.getIcon)
|
||||
|
||||
v1.GET("/status", s.getStatus)
|
||||
v1.GET("/widgets/types", s.widgetTypes)
|
||||
v1.GET("/widgets/:id/data", s.widgetData)
|
||||
v1.GET("/settings", s.getSettings)
|
||||
v1.PUT("/settings", s.putSettings)
|
||||
v1.GET("/export", s.exportBoard)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/db"
|
||||
@@ -122,6 +123,61 @@ func TestSettingsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetEndpoints(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
// Types registry serves all integrations incl. the local clock.
|
||||
resp, err := http.Get(srv.URL + "/api/widgets/types")
|
||||
if err != nil {
|
||||
t.Fatalf("get types: %v", err)
|
||||
}
|
||||
var types []struct {
|
||||
Type string `json:"type"`
|
||||
Local bool `json:"local"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&types); err != nil {
|
||||
t.Fatalf("decode types: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
got := map[string]bool{}
|
||||
for _, w := range types {
|
||||
got[w.Type] = w.Local
|
||||
}
|
||||
for _, want := range []string{"clock", "pihole", "adguard", "immich"} {
|
||||
if _, ok := got[want]; !ok {
|
||||
t.Fatalf("missing widget type %s in %v", want, got)
|
||||
}
|
||||
}
|
||||
if !got["clock"] {
|
||||
t.Fatalf("clock must be local, got %v", got)
|
||||
}
|
||||
|
||||
// Widget data on a service item -> 400; missing -> 404.
|
||||
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Svc"}`)
|
||||
res, _ := do(t, "GET", srv.URL+"/api/widgets/"+it["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("service item data: %d", res.StatusCode)
|
||||
}
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/i_missing/data", "")
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("missing item: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Widget item with dead upstream -> 502, then cached (second call fast).
|
||||
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"PH","config":{"type":"pihole","endpoint":"http://127.0.0.1:1","token":"x"}}`)
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("dead upstream: %d", res.StatusCode)
|
||||
}
|
||||
start := time.Now()
|
||||
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
|
||||
if res.StatusCode != http.StatusBadGateway || time.Since(start) > time.Second {
|
||||
t.Fatalf("expected cached 502, got %d in %v", res.StatusCode, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidpoint(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
if got := midpoint(nil, nil); got != 1024 {
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
)
|
||||
|
||||
type itemInput struct {
|
||||
SectionID string `json:"sectionId"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLs []URLInput `json:"urls"`
|
||||
SectionID string `json:"sectionId"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
URLs []URLInput `json:"urls"`
|
||||
}
|
||||
|
||||
func (s *Server) createItem(c *gin.Context) {
|
||||
@@ -21,6 +23,14 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "sectionId and name required")
|
||||
return
|
||||
}
|
||||
kind := in.Kind
|
||||
if kind == "" {
|
||||
kind = "service"
|
||||
}
|
||||
if kind != "service" && kind != "widget" {
|
||||
fail(c, http.StatusBadRequest, "kind must be service or widget")
|
||||
return
|
||||
}
|
||||
for _, u := range in.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
@@ -35,8 +45,8 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: "service", Name: in.Name, Icon: in.Icon,
|
||||
Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
|
||||
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: kind, Name: in.Name, Icon: in.Icon,
|
||||
Config: in.Config, Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -45,8 +55,12 @@ func (s *Server) createItem(c *gin.Context) {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO items (id, section_id, name, icon, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
it.ID, it.SectionID, it.Name, it.Icon, it.Position); err != nil {
|
||||
var cfg any
|
||||
if in.Config != nil {
|
||||
cfg = string(*in.Config)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ID, it.SectionID, it.Kind, it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -76,9 +90,10 @@ func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error {
|
||||
|
||||
func (s *Server) updateItem(c *gin.Context) {
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
URLs *[]URLInput `json:"urls"`
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
URLs *[]URLInput `json:"urls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
@@ -113,6 +128,12 @@ func (s *Server) updateItem(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Config != nil {
|
||||
if _, err := tx.Exec(`UPDATE items SET config = ? WHERE id = ?`, string(*in.Config), id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.URLs != nil {
|
||||
if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/widget"
|
||||
)
|
||||
|
||||
// widgetEntry is one cached fetch result.
|
||||
type widgetEntry struct {
|
||||
Data any `json:"data"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
err error
|
||||
}
|
||||
|
||||
// widgetCache is a TTL map of item id -> fetch result. Local widgets never
|
||||
// reach this — the frontend renders them without a fetch.
|
||||
type widgetCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
entries map[string]widgetEntry
|
||||
}
|
||||
|
||||
func newWidgetCache(ttl time.Duration) *widgetCache {
|
||||
return &widgetCache{ttl: ttl, entries: map[string]widgetEntry{}}
|
||||
}
|
||||
|
||||
func (s *Server) widgetTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, widget.Types())
|
||||
}
|
||||
|
||||
// widgetData resolves the item's config.type, fetches via the registry,
|
||||
// and caches per item for ~30s. Fetch errors become 502 with only the error
|
||||
// message — config values (API keys) must never leak into a response.
|
||||
func (s *Server) widgetData(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
s.widgets.mu.Lock()
|
||||
if e, ok := s.widgets.entries[id]; ok && time.Since(e.FetchedAt) < s.widgets.ttl {
|
||||
s.widgets.mu.Unlock()
|
||||
if e.err != nil {
|
||||
c.JSON(http.StatusBadGateway, e.Data)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, e)
|
||||
return
|
||||
}
|
||||
s.widgets.mu.Unlock()
|
||||
|
||||
it, err := s.loadItem(id)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if it.Kind != "widget" || it.Config == nil {
|
||||
fail(c, http.StatusBadRequest, "not a widget item")
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(*it.Config, &cfg); err != nil || cfg.Type == "" {
|
||||
fail(c, http.StatusBadRequest, "widget config missing type")
|
||||
return
|
||||
}
|
||||
f, ok := widget.Get(cfg.Type)
|
||||
if !ok {
|
||||
fail(c, http.StatusBadRequest, "unknown widget type: "+cfg.Type)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := f.Fetch(c.Request.Context(), *it.Config)
|
||||
e := widgetEntry{FetchedAt: time.Now()}
|
||||
if err != nil {
|
||||
// Cache failures briefly too — a dead upstream should not turn
|
||||
// every poll into a live request.
|
||||
e.Data = gin.H{"error": err.Error()}
|
||||
e.err = err
|
||||
} else {
|
||||
e.Data = data
|
||||
}
|
||||
|
||||
s.widgets.mu.Lock()
|
||||
s.widgets.entries[id] = e
|
||||
s.widgets.mu.Unlock()
|
||||
|
||||
if e.err != nil {
|
||||
c.JSON(http.StatusBadGateway, e.Data)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, e)
|
||||
}
|
||||
+21
-1
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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<Item | undefined>();
|
||||
|
||||
function openEditor(it: Item) {
|
||||
setEditing(it);
|
||||
if (it.kind === "widget") setWdgOpen(true);
|
||||
else setSvcOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-sans">
|
||||
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
|
||||
@@ -46,6 +54,13 @@ function Shell() {
|
||||
>
|
||||
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" aria-label="Add widget"
|
||||
onClick={() => { setEditing(undefined); setWdgOpen(true); }}
|
||||
>
|
||||
<LayoutGrid size={13} strokeWidth={2} />
|
||||
Widget
|
||||
</Button>
|
||||
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
Add service
|
||||
@@ -53,9 +68,10 @@ function Shell() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Board onEditItem={(it) => { setEditing(it); setSvcOpen(true); }} />
|
||||
<Board onEditItem={openEditor} />
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing} />
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ export function ServiceDialog({
|
||||
}
|
||||
const created = await board.addItem({
|
||||
sectionId: sid,
|
||||
kind: "service",
|
||||
name: name.trim(),
|
||||
icon,
|
||||
urls: cleanUrls,
|
||||
|
||||
@@ -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<WidgetType[]>([]);
|
||||
const [type, setType] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [sectionId, setSectionId] = useState("");
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
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<WidgetType[]>)
|
||||
.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<string, string> = {};
|
||||
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<Item["config"]> = { 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit widget" : "Add widget"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing ? "Update widget settings." : "Pick a type and point it at your service."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-[1fr_150px] gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="wdg-name">Name</Label>
|
||||
<Input id="wdg-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
|
||||
</div>
|
||||
{!editing && board.sections.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Section</Label>
|
||||
<Select value={sectionId} onValueChange={setSectionId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{board.sections.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{types.map((t) => (
|
||||
<SelectItem key={t.type} value={t.type}>{t.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{meta?.description ? (
|
||||
<p className="text-[11px] text-text-faint">{meta.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{meta?.fields.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`wdg-${f.key}`}>
|
||||
{f.label}{f.required ? "" : " (optional)"}
|
||||
</Label>
|
||||
<Input
|
||||
id={`wdg-${f.key}`}
|
||||
type={f.secret ? "password" : "text"}
|
||||
value={values[f.key] ?? ""}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "Saving…" : editing ? "Save" : "Add widget"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -118,14 +120,25 @@ function WidgetView({ item, sectionId }: ItemViewProps) {
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
className={cn(
|
||||
"col-span-1 flex min-h-[70px] items-center rounded-[10px] border border-dashed border-border-strong px-[15px] py-[13px]",
|
||||
"min-[620px]:col-span-2",
|
||||
"group/tile relative col-span-2 flex min-h-[70px] cursor-grab items-center gap-3",
|
||||
"rounded-[10px] border border-border bg-surface px-[15px] py-[13px]",
|
||||
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||
"min-[620px]:col-span-2 min-[900px]:col-span-3",
|
||||
dnd.isDragging && "opacity-35",
|
||||
)}
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-faint">
|
||||
{item.name}
|
||||
</span>
|
||||
<WidgetContent item={item} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit ${item.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="absolute right-2 top-2 rounded p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/tile:opacity-100"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -112,13 +113,24 @@ function WidgetView({ item, sectionId }: ItemViewProps) {
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
className={cn(
|
||||
"flex items-center rounded-[10px] border border-dashed border-border-strong bg-surface px-4 py-3",
|
||||
"group/card relative col-span-2 flex cursor-grab items-center gap-3",
|
||||
"rounded-[10px] border border-border bg-surface px-3.5 py-3",
|
||||
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||
dnd.isDragging && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-text-faint">
|
||||
{item.name}
|
||||
</span>
|
||||
<WidgetContent item={item} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit ${item.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="absolute -right-1 -top-1 rounded-full border border-border bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/card:opacity-100"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
|
||||
}
|
||||
const pct = num(data, "blockedPercent");
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||
{data ? `${pct.toFixed(1)}%` : "…"}
|
||||
</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||
<span>{fmt(num(data, "queriesToday"))} queries</span>
|
||||
<span>{fmt(num(data, "blockedToday"))} blocked</span>
|
||||
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PiholeWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
|
||||
return <BlockerWidget {...p} />;
|
||||
}
|
||||
|
||||
export function AdguardWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
|
||||
return <BlockerWidget {...p} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-w-0 flex-col justify-center gap-0.5">
|
||||
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||
{time}
|
||||
</span>
|
||||
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
|
||||
{date}{tz ? ` · ${tz.split("/").pop()?.replace("_", " ")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||
{data ? fmtNum(num(data, "photos")) : "…"}
|
||||
</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||
<span>{fmtNum(num(data, "videos"))} videos</span>
|
||||
<span>{fmtBytes(num(data, "usageBytes"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof setTimeout>;
|
||||
|
||||
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 <span className="text-[11px] uppercase tracking-[0.1em] text-text-faint">{item.name || "widget"}</span>;
|
||||
}
|
||||
return <>{entry.view({ item, data, error })}</>;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user