mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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>
101 lines
2.4 KiB
Go
101 lines
2.4 KiB
Go
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)
|
|
}
|