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
@@ -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 ""
|
||||
}
|
||||
Reference in new issue
Block a user