mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-04 07:22:56 +00:00
🚀 Dash - Homelab Dashboard
A clean, customizable homelab dashboard inspired by CasaOS. Features: - Empty-first dashboard (no demo data) - 3 themes: Light, Dark, CasaOS glassmorphism - Widgets: Clock (multi-timezone), Pi-hole, Memos, Immich, Image - Drag & drop app organization - Grid + list view for apps - Groups with collapse/expand - Proper widget refresh handling - Visual timezone picker - Square app cards with hover effects Stack: Go + Gin + PostgreSQL + Next.js 15 + React 19 + Tailwind CSS + shadcn/ui
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
)
|
||||
|
||||
var allowedMIMEs = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
dir string
|
||||
baseURL string
|
||||
maxBytes int64
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func New(dir, publicBaseURL string, maxBytes int64, st *store.Store) *Service {
|
||||
return &Service{
|
||||
dir: dir,
|
||||
baseURL: strings.TrimRight(publicBaseURL, "/"),
|
||||
maxBytes: maxBytes,
|
||||
store: st,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SaveIcon(r *http.Request, header *multipart.FileHeader) (store.AssetFile, error) {
|
||||
if header.Size > s.maxBytes {
|
||||
return store.AssetFile{}, ErrTooLarge
|
||||
}
|
||||
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
limited := io.LimitReader(file, s.maxBytes+1)
|
||||
sniff := make([]byte, 512)
|
||||
n, err := limited.Read(sniff)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
mimeType := http.DetectContentType(sniff[:n])
|
||||
if strings.EqualFold(filepath.Ext(header.Filename), ".svg") {
|
||||
if !looksLikeSVG(sniff[:n]) {
|
||||
return store.AssetFile{}, ErrUnsupportedMedia
|
||||
}
|
||||
mimeType = "image/svg+xml"
|
||||
}
|
||||
ext, ok := allowedMIMEs[mimeType]
|
||||
if !ok {
|
||||
return store.AssetFile{}, ErrUnsupportedMedia
|
||||
}
|
||||
|
||||
storedName := uuid.NewString() + ext
|
||||
target := filepath.Join(s.dir, storedName)
|
||||
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := out.Write(sniff[:n]); err != nil {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
written, err := io.Copy(out, limited)
|
||||
if err != nil {
|
||||
return store.AssetFile{}, err
|
||||
}
|
||||
size := int64(n) + written
|
||||
if size > s.maxBytes {
|
||||
_ = os.Remove(target)
|
||||
return store.AssetFile{}, ErrTooLarge
|
||||
}
|
||||
|
||||
return s.store.CreateAsset(r.Context(), store.AssetFile{
|
||||
OriginalName: filepath.Base(header.Filename),
|
||||
StoredName: storedName,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: size,
|
||||
PublicPath: fmt.Sprintf("/uploads/icons/%s", storedName),
|
||||
})
|
||||
}
|
||||
|
||||
func looksLikeSVG(prefix []byte) bool {
|
||||
trimmed := bytes.TrimSpace(prefix)
|
||||
return bytes.HasPrefix(trimmed, []byte("<svg")) || bytes.HasPrefix(trimmed, []byte("<?xml"))
|
||||
}
|
||||
|
||||
type assetError string
|
||||
|
||||
func (e assetError) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
ErrTooLarge assetError = "upload too large"
|
||||
ErrUnsupportedMedia assetError = "unsupported media type"
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
DataDir string
|
||||
PublicBaseURL string
|
||||
WidgetFetchTimeout time.Duration
|
||||
WidgetCacheTTL time.Duration
|
||||
MaxIconUploadBytes int64
|
||||
AllowedOrigins []string
|
||||
MigrationsDir string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
AppEnv: env("APP_ENV", "development"),
|
||||
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: env("DATABASE_URL", ""),
|
||||
DataDir: env("DATA_DIR", "./data"),
|
||||
PublicBaseURL: env("PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
WidgetFetchTimeout: 5 * time.Second,
|
||||
WidgetCacheTTL: 60 * time.Second,
|
||||
MaxIconUploadBytes: 524288,
|
||||
AllowedOrigins: splitCSV(env("ALLOWED_ORIGINS", "http://localhost:3000")),
|
||||
MigrationsDir: env("MIGRATIONS_DIR", "../db/migrations"),
|
||||
}
|
||||
|
||||
var err error
|
||||
if raw := os.Getenv("WIDGET_FETCH_TIMEOUT"); raw != "" {
|
||||
cfg.WidgetFetchTimeout, err = time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("WIDGET_FETCH_TIMEOUT: %w", err)
|
||||
}
|
||||
}
|
||||
if raw := os.Getenv("WIDGET_CACHE_TTL"); raw != "" {
|
||||
cfg.WidgetCacheTTL, err = time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("WIDGET_CACHE_TTL: %w", err)
|
||||
}
|
||||
}
|
||||
if raw := os.Getenv("MAX_ICON_UPLOAD_BYTES"); raw != "" {
|
||||
cfg.MaxIconUploadBytes, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("MAX_ICON_UPLOAD_BYTES: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, errors.New("DATABASE_URL is required")
|
||||
}
|
||||
if cfg.MaxIconUploadBytes <= 0 {
|
||||
return Config{}, errors.New("MAX_ICON_UPLOAD_BYTES must be positive")
|
||||
}
|
||||
if cfg.WidgetFetchTimeout <= 0 {
|
||||
return Config{}, errors.New("WIDGET_FETCH_TIMEOUT must be positive")
|
||||
}
|
||||
if cfg.WidgetCacheTTL <= 0 {
|
||||
return Config{}, errors.New("WIDGET_CACHE_TTL must be positive")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(cfg.PublicBaseURL); err != nil {
|
||||
return Config{}, fmt.Errorf("PUBLIC_BASE_URL must be absolute URL: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) IconDir() string {
|
||||
return strings.TrimRight(c.DataDir, "/") + "/icons"
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"dash/backend/internal/assets"
|
||||
"dash/backend/internal/config"
|
||||
"dash/backend/internal/services"
|
||||
"dash/backend/internal/store"
|
||||
"dash/backend/internal/widgets"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
cfg config.Config
|
||||
log *zap.Logger
|
||||
store *store.Store
|
||||
assets *assets.Service
|
||||
widgets *widgets.Registry
|
||||
}
|
||||
|
||||
func NewRouter(cfg config.Config, log *zap.Logger, st *store.Store) *gin.Engine {
|
||||
if cfg.AppEnv == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(requestLogger(log))
|
||||
router.Use(jsonBodyLimit(1 << 20))
|
||||
if len(cfg.AllowedOrigins) > 0 {
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete, http.MethodOptions},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
}
|
||||
|
||||
api := &API{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
store: st,
|
||||
assets: assets.New(cfg.IconDir(), cfg.PublicBaseURL, cfg.MaxIconUploadBytes, st),
|
||||
widgets: widgets.NewRegistry(st, cfg.WidgetFetchTimeout, cfg.WidgetCacheTTL),
|
||||
}
|
||||
|
||||
router.StaticFS("/uploads/icons", http.Dir(cfg.IconDir()))
|
||||
router.GET("/health", api.health)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
v1.GET("/dashboard", api.dashboard)
|
||||
v1.GET("/groups", api.listGroups)
|
||||
v1.POST("/groups", api.createGroup)
|
||||
v1.GET("/groups/:groupId", api.getGroup)
|
||||
v1.PATCH("/groups/:groupId", api.patchGroup)
|
||||
v1.DELETE("/groups/:groupId", api.deleteGroup)
|
||||
|
||||
v1.GET("/services", api.listServices)
|
||||
v1.POST("/services", api.createService)
|
||||
v1.GET("/services/:serviceId", api.getService)
|
||||
v1.PATCH("/services/:serviceId", api.patchService)
|
||||
v1.DELETE("/services/:serviceId", api.deleteService)
|
||||
|
||||
v1.PUT("/layout", api.putLayout)
|
||||
v1.POST("/assets/icons", api.uploadIcon)
|
||||
|
||||
v1.GET("/widgets", api.listWidgets)
|
||||
v1.POST("/widgets", api.createWidget)
|
||||
v1.GET("/widgets/:widgetId", api.getWidget)
|
||||
v1.PATCH("/widgets/:widgetId", api.patchWidget)
|
||||
v1.DELETE("/widgets/:widgetId", api.deleteWidget)
|
||||
v1.GET("/widgets/:widgetId/data", api.widgetData)
|
||||
v1.POST("/widgets/:widgetId/refresh", api.refreshWidget)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func jsonBodyLimit(limit int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Body != nil && c.ContentType() == "application/json" {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) health(c *gin.Context) {
|
||||
if err := a.store.Ping(c.Request.Context()); err != nil {
|
||||
a.error(c, http.StatusServiceUnavailable, "internal_error", "database unavailable", nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) dashboard(c *gin.Context) {
|
||||
dashboard, err := a.store.Dashboard(c.Request.Context())
|
||||
if err != nil {
|
||||
a.internal(c, err)
|
||||
return
|
||||
}
|
||||
dashboard.Widgets = services.MaskWidgets(dashboard.Widgets)
|
||||
c.JSON(http.StatusOK, dashboard)
|
||||
}
|
||||
|
||||
func (a *API) listGroups(c *gin.Context) {
|
||||
groups, err := a.store.Groups(c.Request.Context())
|
||||
if err != nil {
|
||||
a.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
func (a *API) createGroup(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !bindJSON(c, &req, a) {
|
||||
return
|
||||
}
|
||||
name := req.Name
|
||||
input, err := services.NormalizeGroup(store.GroupInput{Name: &name}, true)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
group, err := a.store.CreateGroup(c.Request.Context(), *input.Name)
|
||||
if err != nil {
|
||||
a.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, group)
|
||||
}
|
||||
|
||||
func (a *API) getGroup(c *gin.Context) {
|
||||
group, err := a.store.Group(c.Request.Context(), c.Param("groupId"))
|
||||
a.respondOne(c, group, err)
|
||||
}
|
||||
|
||||
func (a *API) patchGroup(c *gin.Context) {
|
||||
var input store.GroupInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
normalized, err := services.NormalizeGroup(input, false)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
group, err := a.store.UpdateGroup(c.Request.Context(), c.Param("groupId"), normalized)
|
||||
a.respondOne(c, group, err)
|
||||
}
|
||||
|
||||
func (a *API) deleteGroup(c *gin.Context) {
|
||||
move := c.Query("moveServicesToUngrouped") == "true"
|
||||
err := a.store.DeleteGroup(c.Request.Context(), c.Param("groupId"), move)
|
||||
a.respondNoContent(c, err)
|
||||
}
|
||||
|
||||
func (a *API) listServices(c *gin.Context) {
|
||||
services, err := a.store.Services(c.Request.Context())
|
||||
if err != nil {
|
||||
a.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, services)
|
||||
}
|
||||
|
||||
func (a *API) createService(c *gin.Context) {
|
||||
var input store.ServiceInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
normalized, err := services.NormalizeService(input)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
service, err := a.store.CreateService(c.Request.Context(), normalized)
|
||||
a.respondCreated(c, service, err)
|
||||
}
|
||||
|
||||
func (a *API) getService(c *gin.Context) {
|
||||
service, err := a.store.Service(c.Request.Context(), c.Param("serviceId"))
|
||||
a.respondOne(c, service, err)
|
||||
}
|
||||
|
||||
func (a *API) patchService(c *gin.Context) {
|
||||
var input store.ServiceInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
normalized, err := services.NormalizeService(input)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
service, err := a.store.UpdateService(c.Request.Context(), c.Param("serviceId"), normalized)
|
||||
a.respondOne(c, service, err)
|
||||
}
|
||||
|
||||
func (a *API) deleteService(c *gin.Context) {
|
||||
err := a.store.DeleteService(c.Request.Context(), c.Param("serviceId"))
|
||||
a.respondNoContent(c, err)
|
||||
}
|
||||
|
||||
func (a *API) putLayout(c *gin.Context) {
|
||||
var input store.LayoutInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
dashboard, err := a.store.ApplyLayout(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
dashboard.Widgets = services.MaskWidgets(dashboard.Widgets)
|
||||
c.JSON(http.StatusOK, dashboard)
|
||||
}
|
||||
|
||||
func (a *API) uploadIcon(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.MaxIconUploadBytes+1024)
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
assetFile, err := a.assets.SaveIcon(c.Request, file)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, assets.ErrTooLarge):
|
||||
a.error(c, http.StatusRequestEntityTooLarge, "upload_too_large", "icon upload exceeds max size", nil)
|
||||
case errors.Is(err, assets.ErrUnsupportedMedia):
|
||||
a.error(c, http.StatusUnsupportedMediaType, "unsupported_media_type", "icon must be PNG, JPEG, WebP, or SVG", nil)
|
||||
default:
|
||||
a.internal(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, assetFile)
|
||||
}
|
||||
|
||||
func (a *API) listWidgets(c *gin.Context) {
|
||||
widgets, err := a.store.Widgets(c.Request.Context())
|
||||
if err != nil {
|
||||
a.internal(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, services.MaskWidgets(widgets))
|
||||
}
|
||||
|
||||
func (a *API) createWidget(c *gin.Context) {
|
||||
var input store.WidgetInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
normalized, err := services.NormalizeWidget(input, true)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
widget, err := a.store.CreateWidget(c.Request.Context(), normalized)
|
||||
if err == nil {
|
||||
widget = services.MaskWidget(widget)
|
||||
}
|
||||
a.respondCreated(c, widget, err)
|
||||
}
|
||||
|
||||
func (a *API) getWidget(c *gin.Context) {
|
||||
widget, err := a.store.Widget(c.Request.Context(), c.Param("widgetId"))
|
||||
if err == nil {
|
||||
widget = services.MaskWidget(widget)
|
||||
}
|
||||
a.respondOne(c, widget, err)
|
||||
}
|
||||
|
||||
func (a *API) patchWidget(c *gin.Context) {
|
||||
var input store.WidgetInput
|
||||
if !bindJSON(c, &input, a) {
|
||||
return
|
||||
}
|
||||
current, err := a.store.Widget(c.Request.Context(), c.Param("widgetId"))
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
normalized, err := services.NormalizeWidgetPatch(current, input)
|
||||
if err != nil {
|
||||
a.validation(c, err)
|
||||
return
|
||||
}
|
||||
widget, err := a.store.UpdateWidget(c.Request.Context(), c.Param("widgetId"), normalized)
|
||||
if err == nil {
|
||||
widget = services.MaskWidget(widget)
|
||||
}
|
||||
a.respondOne(c, widget, err)
|
||||
}
|
||||
|
||||
func (a *API) deleteWidget(c *gin.Context) {
|
||||
err := a.store.DeleteWidget(c.Request.Context(), c.Param("widgetId"))
|
||||
a.respondNoContent(c, err)
|
||||
}
|
||||
|
||||
func (a *API) widgetData(c *gin.Context) {
|
||||
widget, err := a.store.Widget(c.Request.Context(), c.Param("widgetId"))
|
||||
if err != nil {
|
||||
a.log.Warn("widget lookup failed", zap.String("widgetId", c.Param("widgetId")), zap.Error(err))
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
data, err := a.widgets.Data(c.Request.Context(), widget)
|
||||
if err != nil {
|
||||
a.log.Warn("widget data fetch failed", zap.String("widgetId", widget.ID), zap.String("type", widget.Type), zap.Error(err))
|
||||
}
|
||||
a.respondOne(c, data, err)
|
||||
}
|
||||
|
||||
func (a *API) refreshWidget(c *gin.Context) {
|
||||
widget, err := a.store.Widget(c.Request.Context(), c.Param("widgetId"))
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
data, err := a.widgets.Refresh(c.Request.Context(), widget)
|
||||
a.respondOne(c, data, err)
|
||||
}
|
||||
|
||||
func (a *API) respondCreated(c *gin.Context, value any, err error) {
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, value)
|
||||
}
|
||||
|
||||
func (a *API) respondOne(c *gin.Context, value any, err error) {
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, value)
|
||||
}
|
||||
|
||||
func (a *API) respondNoContent(c *gin.Context, err error) {
|
||||
if err != nil {
|
||||
a.respondErr(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) respondErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
a.error(c, http.StatusNotFound, "not_found", "resource not found", nil)
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
a.error(c, http.StatusConflict, "conflict", "operation conflicts with current state", nil)
|
||||
case errors.Is(err, store.ErrValidation):
|
||||
a.error(c, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
case isPostgresValidationError(err):
|
||||
a.error(c, http.StatusBadRequest, "validation_error", "request references invalid data", nil)
|
||||
default:
|
||||
a.internal(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func isPostgresValidationError(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
switch pgErr.Code {
|
||||
case "22P02", "23502", "23503", "23514":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) validation(c *gin.Context, err error) {
|
||||
a.error(c, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
}
|
||||
|
||||
func (a *API) internal(c *gin.Context, err error) {
|
||||
a.log.Error("request failed", zap.Error(err))
|
||||
a.error(c, http.StatusInternalServerError, "internal_error", "internal server error", nil)
|
||||
}
|
||||
|
||||
func (a *API) error(c *gin.Context, status int, code, message string, details any) {
|
||||
c.JSON(status, ErrorResponse{Code: code, Message: message, Details: details})
|
||||
}
|
||||
|
||||
func bindJSON(c *gin.Context, dst any, a *API) bool {
|
||||
if err := c.ShouldBindJSON(dst); err != nil {
|
||||
a.validation(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details"`
|
||||
}
|
||||
|
||||
func requestLogger(log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
status := c.Writer.Status()
|
||||
if status >= 500 {
|
||||
log.Error("http request",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status", status),
|
||||
zap.String("bytes", strconv.Itoa(c.Writer.Size())),
|
||||
)
|
||||
return
|
||||
}
|
||||
log.Debug("http request",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status", status),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
"dash/backend/internal/validation"
|
||||
"dash/backend/internal/widgets"
|
||||
)
|
||||
|
||||
func NormalizeService(input store.ServiceInput) (store.ServiceInput, error) {
|
||||
name, err := validation.Name(input.Name)
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
input.Name = name
|
||||
if input.GroupID != nil {
|
||||
if _, err := uuid.Parse(*input.GroupID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("groupId must be a UUID")
|
||||
}
|
||||
}
|
||||
if input.IconAssetID != nil {
|
||||
if _, err := uuid.Parse(*input.IconAssetID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("iconAssetId must be a UUID")
|
||||
}
|
||||
}
|
||||
if input.IconURL != nil && input.IconAssetID != nil {
|
||||
return store.ServiceInput{}, errors.New("iconUrl and iconAssetId are mutually exclusive")
|
||||
}
|
||||
|
||||
if input.IconURL != nil {
|
||||
iconURL, err := validation.OptionalAbsoluteHTTP(*input.IconURL, "iconUrl")
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
input.IconURL = iconURL
|
||||
}
|
||||
if len(input.URLs) == 0 {
|
||||
return store.ServiceInput{}, errors.New("service requires at least one URL")
|
||||
}
|
||||
|
||||
primaryCount := 0
|
||||
seenURLIDs := map[string]struct{}{}
|
||||
for i := range input.URLs {
|
||||
label, err := validation.Label(input.URLs[i].Label)
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
if err := validation.URLKind(input.URLs[i].Kind); err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
serviceURL, err := validation.AbsoluteHTTP(input.URLs[i].URL, "url")
|
||||
if err != nil {
|
||||
return store.ServiceInput{}, err
|
||||
}
|
||||
if input.URLs[i].ID != nil {
|
||||
if _, err := uuid.Parse(*input.URLs[i].ID); err != nil {
|
||||
return store.ServiceInput{}, errors.New("service URL id must be a UUID")
|
||||
}
|
||||
if _, ok := seenURLIDs[*input.URLs[i].ID]; ok {
|
||||
return store.ServiceInput{}, errors.New("duplicate service URL id")
|
||||
}
|
||||
seenURLIDs[*input.URLs[i].ID] = struct{}{}
|
||||
}
|
||||
input.URLs[i].Label = label
|
||||
input.URLs[i].URL = serviceURL
|
||||
if input.URLs[i].IsPrimary {
|
||||
primaryCount++
|
||||
}
|
||||
}
|
||||
if primaryCount > 1 {
|
||||
return store.ServiceInput{}, errors.New("only one primary URL allowed")
|
||||
}
|
||||
if primaryCount == 0 {
|
||||
input.URLs[0].IsPrimary = true
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeGroup(input store.GroupInput, requireName bool) (store.GroupInput, error) {
|
||||
if input.Name == nil {
|
||||
if requireName {
|
||||
return store.GroupInput{}, errors.New("name is required")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
name, err := validation.Name(*input.Name)
|
||||
if err != nil {
|
||||
return store.GroupInput{}, err
|
||||
}
|
||||
input.Name = &name
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeWidget(input store.WidgetInput, requireType bool) (store.WidgetInput, error) {
|
||||
if requireType || input.Type != "" {
|
||||
if err := validation.WidgetType(input.Type); err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
}
|
||||
if input.Title != "" {
|
||||
title, err := validation.Name(input.Title)
|
||||
if err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
input.Title = title
|
||||
} else if requireType {
|
||||
return store.WidgetInput{}, errors.New("title is required")
|
||||
}
|
||||
if len(input.Config) == 0 {
|
||||
input.Config = json.RawMessage(`{}`)
|
||||
}
|
||||
if !json.Valid(input.Config) {
|
||||
return store.WidgetInput{}, errors.New("config must be valid JSON")
|
||||
}
|
||||
if input.Type != "" {
|
||||
if err := ValidateWidgetConfig(input.Type, input.Config); err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func NormalizeWidgetPatch(current store.WidgetInstance, input store.WidgetInput) (store.WidgetInput, error) {
|
||||
widgetType := input.Type
|
||||
if widgetType == "" {
|
||||
widgetType = current.Type
|
||||
}
|
||||
title := input.Title
|
||||
if title == "" {
|
||||
title = current.Title
|
||||
}
|
||||
config := input.Config
|
||||
if len(config) == 0 {
|
||||
config = current.Config
|
||||
}
|
||||
normalized, err := NormalizeWidget(store.WidgetInput{
|
||||
Type: widgetType,
|
||||
Title: title,
|
||||
Enabled: input.Enabled,
|
||||
Config: config,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return store.WidgetInput{}, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func ValidateWidgetConfig(widgetType string, raw json.RawMessage) error {
|
||||
tmpl, ok := widgets.GetTemplate(widgetType)
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported widget type %q", widgetType)
|
||||
}
|
||||
if tmpl.Validate != nil {
|
||||
return tmpl.Validate(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaskWidget(widget store.WidgetInstance) store.WidgetInstance {
|
||||
if (widget.Type != "pihole" && widget.Type != "memos") || len(widget.Config) == 0 {
|
||||
return widget
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(widget.Config, &cfg); err != nil {
|
||||
widget.Config = json.RawMessage(`{}`)
|
||||
return widget
|
||||
}
|
||||
if _, ok := cfg["apiToken"]; ok {
|
||||
cfg["apiToken"] = "********"
|
||||
}
|
||||
masked, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
widget.Config = json.RawMessage(`{}`)
|
||||
return widget
|
||||
}
|
||||
widget.Config = masked
|
||||
return widget
|
||||
}
|
||||
|
||||
func MaskWidgets(widgets []store.WidgetInstance) []store.WidgetInstance {
|
||||
for i := range widgets {
|
||||
widgets[i] = MaskWidget(widgets[i])
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeServicePrimaryFallback(t *testing.T) {
|
||||
input := store.ServiceInput{
|
||||
Name: " Router ",
|
||||
URLs: []store.ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://router.local"},
|
||||
},
|
||||
}
|
||||
got, err := NormalizeService(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "Router" {
|
||||
t.Fatalf("name = %q", got.Name)
|
||||
}
|
||||
if !got.URLs[0].IsPrimary {
|
||||
t.Fatal("first URL not made primary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServiceRejectsTwoPrimary(t *testing.T) {
|
||||
_, err := NormalizeService(store.ServiceInput{
|
||||
Name: "Router",
|
||||
URLs: []store.ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://router.local", IsPrimary: true},
|
||||
{Label: "wan", Kind: "external", URL: "https://router.example.com", IsPrimary: true},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("accepted two primary URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskWidget(t *testing.T) {
|
||||
widget := store.WidgetInstance{
|
||||
Type: "pihole",
|
||||
Config: json.RawMessage(`{"baseUrl":"http://pihole.local","apiToken":"secret"}`),
|
||||
}
|
||||
got := MaskWidget(widget)
|
||||
if strings.Contains(string(got.Config), "secret") {
|
||||
t.Fatalf("token leaked: %s", got.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWidgetPatchValidatesCurrentType(t *testing.T) {
|
||||
current := store.WidgetInstance{
|
||||
Type: "image",
|
||||
Title: "Photo",
|
||||
Config: json.RawMessage(`{"imageUrl":"https://example.com/a.png"}`),
|
||||
}
|
||||
_, err := NormalizeWidgetPatch(current, store.WidgetInput{
|
||||
Config: json.RawMessage(`{"imageUrl":"/relative.png"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("accepted invalid image config without explicit type")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: dashboard.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const listGroups = `-- name: ListGroups :many
|
||||
SELECT id::text, name, sort_order, collapsed, created_at, updated_at
|
||||
FROM groups
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
`
|
||||
|
||||
type ListGroupsRow struct {
|
||||
ID string
|
||||
Name string
|
||||
SortOrder int32
|
||||
Collapsed bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) ListGroups(ctx context.Context) ([]ListGroupsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGroupsRow
|
||||
for rows.Next() {
|
||||
var i ListGroupsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.SortOrder,
|
||||
&i.Collapsed,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceURLs = `-- name: ListServiceURLs :many
|
||||
SELECT id::text, service_id::text, label, kind, url, sort_order, is_primary, created_at, updated_at
|
||||
FROM service_urls
|
||||
ORDER BY service_id, sort_order ASC, created_at ASC
|
||||
`
|
||||
|
||||
type ListServiceURLsRow struct {
|
||||
ID string
|
||||
ServiceID string
|
||||
Label string
|
||||
Kind string
|
||||
Url string
|
||||
SortOrder int32
|
||||
IsPrimary bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceURLs(ctx context.Context) ([]ListServiceURLsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listServiceURLs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListServiceURLsRow
|
||||
for rows.Next() {
|
||||
var i ListServiceURLsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ServiceID,
|
||||
&i.Label,
|
||||
&i.Kind,
|
||||
&i.Url,
|
||||
&i.SortOrder,
|
||||
&i.IsPrimary,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServices = `-- name: ListServices :many
|
||||
SELECT id::text, group_id, name, icon_url, icon_asset_id, sort_order, created_at, updated_at
|
||||
FROM services
|
||||
ORDER BY group_id NULLS FIRST, sort_order ASC, created_at ASC
|
||||
`
|
||||
|
||||
type ListServicesRow struct {
|
||||
ID string
|
||||
GroupID pgtype.UUID
|
||||
Name string
|
||||
IconUrl pgtype.Text
|
||||
IconAssetID pgtype.UUID
|
||||
SortOrder int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) ListServices(ctx context.Context) ([]ListServicesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListServicesRow
|
||||
for rows.Next() {
|
||||
var i ListServicesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GroupID,
|
||||
&i.Name,
|
||||
&i.IconUrl,
|
||||
&i.IconAssetID,
|
||||
&i.SortOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listWidgets = `-- name: ListWidgets :many
|
||||
SELECT id::text, type, title, enabled, sort_order, config, created_at, updated_at
|
||||
FROM widget_instances
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
`
|
||||
|
||||
type ListWidgetsRow struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
Config []byte
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) ListWidgets(ctx context.Context) ([]ListWidgetsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listWidgets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListWidgetsRow
|
||||
for rows.Next() {
|
||||
var i ListWidgetsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Type,
|
||||
&i.Title,
|
||||
&i.Enabled,
|
||||
&i.SortOrder,
|
||||
&i.Config,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type AssetFile struct {
|
||||
ID pgtype.UUID
|
||||
OriginalName string
|
||||
StoredName string
|
||||
MimeType string
|
||||
SizeBytes int32
|
||||
PublicPath string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID pgtype.UUID
|
||||
Name string
|
||||
SortOrder int32
|
||||
Collapsed bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID pgtype.UUID
|
||||
GroupID pgtype.UUID
|
||||
Name string
|
||||
IconUrl pgtype.Text
|
||||
IconAssetID pgtype.UUID
|
||||
SortOrder int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ServiceUrl struct {
|
||||
ID pgtype.UUID
|
||||
ServiceID pgtype.UUID
|
||||
Label string
|
||||
Kind string
|
||||
Url string
|
||||
SortOrder int32
|
||||
IsPrimary bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WidgetCache struct {
|
||||
WidgetID pgtype.UUID
|
||||
Status string
|
||||
Data []byte
|
||||
Error pgtype.Text
|
||||
FetchedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WidgetInstance struct {
|
||||
ID pgtype.UUID
|
||||
Type string
|
||||
Title string
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
Config []byte
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrValidation = errors.New("validation")
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
Services []Service `json:"services"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
GroupID *string `json:"groupId"`
|
||||
Name string `json:"name"`
|
||||
IconURL *string `json:"iconUrl"`
|
||||
IconAssetID *string `json:"iconAssetId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
URLs []ServiceURL `json:"urls"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ServiceURL struct {
|
||||
ID string `json:"id"`
|
||||
ServiceID string `json:"-"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type WidgetInstance struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type WidgetData struct {
|
||||
WidgetID string `json:"widgetId"`
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error *string `json:"error"`
|
||||
FetchedAt *time.Time `json:"fetchedAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type AssetFile struct {
|
||||
ID string `json:"id"`
|
||||
OriginalName string `json:"originalName"`
|
||||
StoredName string `json:"storedName"`
|
||||
MimeType string `json:"mimeType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
PublicPath string `json:"publicPath"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Groups []Group `json:"groups"`
|
||||
UngroupedServices []Service `json:"ungroupedServices"`
|
||||
Widgets []WidgetInstance `json:"widgets"`
|
||||
}
|
||||
|
||||
type ServiceURLInput struct {
|
||||
ID *string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
}
|
||||
|
||||
type ServiceInput struct {
|
||||
GroupID *string `json:"groupId"`
|
||||
Name string `json:"name"`
|
||||
IconURL *string `json:"iconUrl"`
|
||||
IconAssetID *string `json:"iconAssetId"`
|
||||
URLs []ServiceURLInput `json:"urls"`
|
||||
}
|
||||
|
||||
type GroupInput struct {
|
||||
Name *string `json:"name"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
type WidgetInput struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
type LayoutInput struct {
|
||||
GroupIDs []string `json:"groupIds"`
|
||||
WidgetIDs []string `json:"widgetIds"`
|
||||
UngroupedServices []string `json:"ungroupedServiceIds"`
|
||||
GroupServices map[string][]string `json:"groupServices"`
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"dash/backend/internal/store/dbgen"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
queries *dbgen.Queries
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool, queries: dbgen.New(pool)}
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Dashboard(ctx context.Context) (Dashboard, error) {
|
||||
groups, err := s.Groups(ctx)
|
||||
if err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
services, err := s.Services(ctx)
|
||||
if err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
widgets, err := s.Widgets(ctx)
|
||||
if err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []Group{}
|
||||
}
|
||||
if services == nil {
|
||||
services = []Service{}
|
||||
}
|
||||
if widgets == nil {
|
||||
widgets = []WidgetInstance{}
|
||||
}
|
||||
|
||||
groupByID := make(map[string]*Group, len(groups))
|
||||
for i := range groups {
|
||||
groups[i].Services = []Service{}
|
||||
groupByID[groups[i].ID] = &groups[i]
|
||||
}
|
||||
|
||||
ungrouped := make([]Service, 0)
|
||||
for _, service := range services {
|
||||
if service.GroupID == nil {
|
||||
ungrouped = append(ungrouped, service)
|
||||
continue
|
||||
}
|
||||
group := groupByID[*service.GroupID]
|
||||
if group == nil {
|
||||
ungrouped = append(ungrouped, service)
|
||||
continue
|
||||
}
|
||||
group.Services = append(group.Services, service)
|
||||
}
|
||||
|
||||
return Dashboard{
|
||||
Groups: groups,
|
||||
UngroupedServices: ungrouped,
|
||||
Widgets: widgets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Groups(ctx context.Context) ([]Group, error) {
|
||||
rows, err := s.queries.ListGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
groups = append(groups, Group{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
SortOrder: int(row.SortOrder),
|
||||
Collapsed: row.Collapsed,
|
||||
Services: []Service{},
|
||||
CreatedAt: timestamptz(row.CreatedAt),
|
||||
UpdatedAt: timestamptz(row.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *Store) Services(ctx context.Context) ([]Service, error) {
|
||||
rows, err := s.queries.ListServices(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
services := make([]Service, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
services = append(services, Service{
|
||||
ID: row.ID,
|
||||
GroupID: uuidPtr(row.GroupID),
|
||||
Name: row.Name,
|
||||
IconURL: textPtr(row.IconUrl),
|
||||
IconAssetID: uuidPtr(row.IconAssetID),
|
||||
SortOrder: int(row.SortOrder),
|
||||
URLs: []ServiceURL{},
|
||||
CreatedAt: timestamptz(row.CreatedAt),
|
||||
UpdatedAt: timestamptz(row.UpdatedAt),
|
||||
})
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return services, nil
|
||||
}
|
||||
urls, err := s.serviceURLs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range services {
|
||||
if serviceURLs, ok := urls[services[i].ID]; ok {
|
||||
services[i].URLs = serviceURLs
|
||||
}
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func (s *Store) Group(ctx context.Context, id string) (Group, error) {
|
||||
var group Group
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, name, sort_order, collapsed, created_at, updated_at
|
||||
FROM groups WHERE id = $1`, id).
|
||||
Scan(&group.ID, &group.Name, &group.SortOrder, &group.Collapsed, &group.CreatedAt, &group.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Group{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
group.Services = []Service{}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateGroup(ctx context.Context, name string) (Group, error) {
|
||||
var group Group
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO groups (name, sort_order)
|
||||
VALUES ($1, COALESCE((SELECT max(sort_order) + 1 FROM groups), 0))
|
||||
RETURNING id::text, name, sort_order, collapsed, created_at, updated_at`, name).
|
||||
Scan(&group.ID, &group.Name, &group.SortOrder, &group.Collapsed, &group.CreatedAt, &group.UpdatedAt)
|
||||
return group, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateGroup(ctx context.Context, id string, input GroupInput) (Group, error) {
|
||||
group, err := s.Group(ctx, id)
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
name := group.Name
|
||||
collapsed := group.Collapsed
|
||||
if input.Name != nil {
|
||||
name = *input.Name
|
||||
}
|
||||
if input.Collapsed != nil {
|
||||
collapsed = *input.Collapsed
|
||||
}
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE groups SET name = $2, collapsed = $3
|
||||
WHERE id = $1
|
||||
RETURNING id::text, name, sort_order, collapsed, created_at, updated_at`, id, name, collapsed).
|
||||
Scan(&group.ID, &group.Name, &group.SortOrder, &group.Collapsed, &group.CreatedAt, &group.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Group{}, ErrNotFound
|
||||
}
|
||||
return group, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGroup(ctx context.Context, id string, moveServices bool) error {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*) FROM services WHERE group_id = $1`, id).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 && !moveServices {
|
||||
return ErrConflict
|
||||
}
|
||||
if count > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
WITH moved AS (
|
||||
SELECT id, row_number() OVER (ORDER BY sort_order, created_at) - 1 rn
|
||||
FROM services WHERE group_id = $1
|
||||
), base AS (
|
||||
SELECT COALESCE(max(sort_order) + 1, 0) next_order
|
||||
FROM services WHERE group_id IS NULL
|
||||
)
|
||||
UPDATE services s
|
||||
SET group_id = NULL, sort_order = base.next_order + moved.rn
|
||||
FROM moved, base
|
||||
WHERE s.id = moved.id`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM groups WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Service(ctx context.Context, id string) (Service, error) {
|
||||
var service Service
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, group_id::text, name, icon_url, icon_asset_id::text, sort_order, created_at, updated_at
|
||||
FROM services WHERE id = $1`, id).
|
||||
Scan(&service.ID, &service.GroupID, &service.Name, &service.IconURL, &service.IconAssetID, &service.SortOrder, &service.CreatedAt, &service.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Service{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
urls, err := s.serviceURLs(ctx)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
service.URLs = urls[service.ID]
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateService(ctx context.Context, input ServiceInput) (Service, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
|
||||
var service Service
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO services (group_id, name, icon_url, icon_asset_id, sort_order)
|
||||
VALUES ($1, $2, $3, $4, (
|
||||
SELECT COALESCE(max(sort_order) + 1, 0) FROM services
|
||||
WHERE group_id IS NOT DISTINCT FROM $1::uuid
|
||||
))
|
||||
RETURNING id::text, group_id::text, name, icon_url, icon_asset_id::text, sort_order, created_at, updated_at`,
|
||||
input.GroupID, input.Name, input.IconURL, input.IconAssetID).
|
||||
Scan(&service.ID, &service.GroupID, &service.Name, &service.IconURL, &service.IconAssetID, &service.SortOrder, &service.CreatedAt, &service.UpdatedAt)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
if err := replaceServiceURLs(ctx, tx, service.ID, input.URLs); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
return s.Service(ctx, service.ID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateService(ctx context.Context, id string, input ServiceInput) (Service, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE services
|
||||
SET group_id = $2, name = $3, icon_url = $4, icon_asset_id = $5
|
||||
WHERE id = $1`, id, input.GroupID, input.Name, input.IconURL, input.IconAssetID)
|
||||
if err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return Service{}, ErrNotFound
|
||||
}
|
||||
if err := replaceServiceURLs(ctx, tx, id, input.URLs); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
return s.Service(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteService(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Widgets(ctx context.Context) ([]WidgetInstance, error) {
|
||||
rows, err := s.queries.ListWidgets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
widgets := make([]WidgetInstance, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
widgets = append(widgets, WidgetInstance{
|
||||
ID: row.ID,
|
||||
Type: row.Type,
|
||||
Title: row.Title,
|
||||
Enabled: row.Enabled,
|
||||
SortOrder: int(row.SortOrder),
|
||||
Config: json.RawMessage(row.Config),
|
||||
CreatedAt: timestamptz(row.CreatedAt),
|
||||
UpdatedAt: timestamptz(row.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return widgets, nil
|
||||
}
|
||||
|
||||
func (s *Store) Widget(ctx context.Context, id string) (WidgetInstance, error) {
|
||||
var widget WidgetInstance
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, type, title, enabled, sort_order, config, created_at, updated_at
|
||||
FROM widget_instances WHERE id = $1`, id).
|
||||
Scan(&widget.ID, &widget.Type, &widget.Title, &widget.Enabled, &widget.SortOrder, &widget.Config, &widget.CreatedAt, &widget.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return WidgetInstance{}, ErrNotFound
|
||||
}
|
||||
return widget, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateWidget(ctx context.Context, input WidgetInput) (WidgetInstance, error) {
|
||||
enabled := true
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
var widget WidgetInstance
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO widget_instances (type, title, enabled, config, sort_order)
|
||||
VALUES ($1, $2, $3, $4, COALESCE((SELECT max(sort_order) + 1 FROM widget_instances), 0))
|
||||
RETURNING id::text, type, title, enabled, sort_order, config, created_at, updated_at`,
|
||||
input.Type, input.Title, enabled, jsonOrEmpty(input.Config)).
|
||||
Scan(&widget.ID, &widget.Type, &widget.Title, &widget.Enabled, &widget.SortOrder, &widget.Config, &widget.CreatedAt, &widget.UpdatedAt)
|
||||
return widget, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateWidget(ctx context.Context, id string, input WidgetInput) (WidgetInstance, error) {
|
||||
current, err := s.Widget(ctx, id)
|
||||
if err != nil {
|
||||
return WidgetInstance{}, err
|
||||
}
|
||||
enabled := current.Enabled
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
widgetType := input.Type
|
||||
if widgetType == "" {
|
||||
widgetType = current.Type
|
||||
}
|
||||
title := input.Title
|
||||
if title == "" {
|
||||
title = current.Title
|
||||
}
|
||||
config := input.Config
|
||||
if len(config) == 0 {
|
||||
config = current.Config
|
||||
}
|
||||
var widget WidgetInstance
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
UPDATE widget_instances
|
||||
SET type = $2, title = $3, enabled = $4, config = $5
|
||||
WHERE id = $1
|
||||
RETURNING id::text, type, title, enabled, sort_order, config, created_at, updated_at`,
|
||||
id, widgetType, title, enabled, jsonOrEmpty(config)).
|
||||
Scan(&widget.ID, &widget.Type, &widget.Title, &widget.Enabled, &widget.SortOrder, &widget.Config, &widget.CreatedAt, &widget.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return WidgetInstance{}, ErrNotFound
|
||||
}
|
||||
return widget, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteWidget(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM widget_instances WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) WidgetData(ctx context.Context, widgetID string) (WidgetData, error) {
|
||||
var data WidgetData
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT widget_id::text, status, data, error, fetched_at, expires_at
|
||||
FROM widget_cache WHERE widget_id = $1`, widgetID).
|
||||
Scan(&data.WidgetID, &data.Status, &data.Data, &data.Error, &data.FetchedAt, &data.ExpiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return WidgetData{}, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (s *Store) SaveWidgetData(ctx context.Context, data WidgetData) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO widget_cache (widget_id, status, data, error, fetched_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (widget_id) DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
data = EXCLUDED.data,
|
||||
error = EXCLUDED.error,
|
||||
fetched_at = EXCLUDED.fetched_at,
|
||||
expires_at = EXCLUDED.expires_at`,
|
||||
data.WidgetID, data.Status, nilIfEmptyJSON(data.Data), data.Error, data.FetchedAt, data.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateAsset(ctx context.Context, file AssetFile) (AssetFile, error) {
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO asset_files (original_name, stored_name, mime_type, size_bytes, public_path)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id::text, original_name, stored_name, mime_type, size_bytes, public_path, created_at`,
|
||||
file.OriginalName, file.StoredName, file.MimeType, file.SizeBytes, file.PublicPath).
|
||||
Scan(&file.ID, &file.OriginalName, &file.StoredName, &file.MimeType, &file.SizeBytes, &file.PublicPath, &file.CreatedAt)
|
||||
return file, err
|
||||
}
|
||||
|
||||
func (s *Store) ApplyLayout(ctx context.Context, input LayoutInput) (Dashboard, error) {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
defer rollback(ctx, tx)
|
||||
|
||||
if err := validateLayoutRefs(ctx, tx, input); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
for order, id := range input.GroupIDs {
|
||||
if _, err := tx.Exec(ctx, `UPDATE groups SET sort_order = $2 WHERE id = $1`, id, order); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
}
|
||||
for order, id := range input.WidgetIDs {
|
||||
if _, err := tx.Exec(ctx, `UPDATE widget_instances SET sort_order = $2 WHERE id = $1`, id, order); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
}
|
||||
for order, id := range input.UngroupedServices {
|
||||
if _, err := tx.Exec(ctx, `UPDATE services SET group_id = NULL, sort_order = $2 WHERE id = $1`, id, order); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
}
|
||||
groupIDs := make([]string, 0, len(input.GroupServices))
|
||||
for groupID := range input.GroupServices {
|
||||
groupIDs = append(groupIDs, groupID)
|
||||
}
|
||||
sort.Strings(groupIDs)
|
||||
for _, groupID := range groupIDs {
|
||||
for order, serviceID := range input.GroupServices[groupID] {
|
||||
if _, err := tx.Exec(ctx, `UPDATE services SET group_id = $2, sort_order = $3 WHERE id = $1`, serviceID, groupID, order); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
return s.Dashboard(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) serviceURLs(ctx context.Context) (map[string][]ServiceURL, error) {
|
||||
rows, err := s.queries.ListServiceURLs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urls := make(map[string][]ServiceURL)
|
||||
for _, row := range rows {
|
||||
serviceURL := ServiceURL{
|
||||
ID: row.ID,
|
||||
ServiceID: row.ServiceID,
|
||||
Label: row.Label,
|
||||
Kind: row.Kind,
|
||||
URL: row.Url,
|
||||
SortOrder: int(row.SortOrder),
|
||||
IsPrimary: row.IsPrimary,
|
||||
CreatedAt: timestamptz(row.CreatedAt),
|
||||
UpdatedAt: timestamptz(row.UpdatedAt),
|
||||
}
|
||||
urls[serviceURL.ServiceID] = append(urls[serviceURL.ServiceID], serviceURL)
|
||||
}
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func replaceServiceURLs(ctx context.Context, tx pgx.Tx, serviceID string, urls []ServiceURLInput) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM service_urls WHERE service_id = $1`, serviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, serviceURL := range urls {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO service_urls (service_id, label, kind, url, sort_order, is_primary)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
serviceID, serviceURL.Label, serviceURL.Kind, serviceURL.URL, i, serviceURL.IsPrimary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLayoutRefs(ctx context.Context, tx pgx.Tx, input LayoutInput) error {
|
||||
if input.GroupServices == nil {
|
||||
return fmt.Errorf("%w: groupServices is required", ErrValidation)
|
||||
}
|
||||
if err := ensureFullIDSet(ctx, tx, "groups", input.GroupIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureFullIDSet(ctx, tx, "widget_instances", input.WidgetIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
allServices := append([]string{}, input.UngroupedServices...)
|
||||
for groupID, serviceIDs := range input.GroupServices {
|
||||
if err := ensureIDs(ctx, tx, "groups", []string{groupID}); err != nil {
|
||||
return err
|
||||
}
|
||||
allServices = append(allServices, serviceIDs...)
|
||||
}
|
||||
if err := noDuplicates(allServices, "service"); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureFullIDSet(ctx, tx, "services", allServices)
|
||||
}
|
||||
|
||||
func ensureIDs(ctx context.Context, tx pgx.Tx, table string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := noDuplicates(ids, table); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, fmt.Sprintf(`SELECT id::text FROM %s WHERE id = ANY($1::uuid[])`, table), ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invalid %s id", ErrValidation, table)
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(seen) != len(ids) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureFullIDSet(ctx context.Context, tx pgx.Tx, table string, ids []string) error {
|
||||
if err := ensureIDs(ctx, tx, table, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, fmt.Sprintf(`SELECT count(*) FROM %s`, table)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(ids) {
|
||||
return fmt.Errorf("%w: %s layout must include every existing row exactly once", ErrValidation, table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func noDuplicates(ids []string, label string) error {
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("%w: %s id appears more than once", ErrValidation, label)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollback(ctx context.Context, tx pgx.Tx) {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
|
||||
func jsonOrEmpty(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func nilIfEmptyJSON(raw json.RawMessage) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func Fresh(data WidgetData, now time.Time) bool {
|
||||
return data.Status == "fresh" && data.ExpiresAt != nil && data.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
func timestamptz(value pgtype.Timestamptz) time.Time {
|
||||
if !value.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.Time
|
||||
}
|
||||
|
||||
func textPtr(value pgtype.Text) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
|
||||
func uuidPtr(value pgtype.UUID) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
out := uuid.UUID(value.Bytes).String()
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dash/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestDashboardLayoutAndGroupDeleteIntegration(t *testing.T) {
|
||||
pool := testutil.TestPool(t)
|
||||
st := New(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
infra, err := st.CreateGroup(ctx, "Infra")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media, err := st.CreateGroup(ctx, "Media")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := st.CreateService(ctx, ServiceInput{
|
||||
GroupID: &infra.ID,
|
||||
Name: "Pi-hole",
|
||||
URLs: []ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://pihole.local", IsPrimary: true},
|
||||
{Label: "wan", Kind: "external", URL: "https://pihole.example.com"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dashboard, err := st.Dashboard(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dashboard.Groups) != 2 || len(dashboard.Groups[0].Services) != 1 {
|
||||
t.Fatalf("unexpected dashboard: %+v", dashboard)
|
||||
}
|
||||
|
||||
dashboard, err = st.ApplyLayout(ctx, LayoutInput{
|
||||
GroupIDs: []string{media.ID, infra.ID},
|
||||
WidgetIDs: []string{},
|
||||
UngroupedServices: []string{},
|
||||
GroupServices: map[string][]string{
|
||||
media.ID: {service.ID},
|
||||
infra.ID: {},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dashboard.Groups[0].ID != media.ID || len(dashboard.Groups[0].Services) != 1 {
|
||||
t.Fatalf("service was not moved to media group: %+v", dashboard.Groups)
|
||||
}
|
||||
|
||||
if err := st.DeleteGroup(ctx, media.ID, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("DeleteGroup() error = %v, want conflict", err)
|
||||
}
|
||||
if err := st.DeleteGroup(ctx, media.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err = st.Service(ctx, service.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.GroupID != nil {
|
||||
t.Fatalf("service group id = %v, want nil", *service.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLayoutRejectsPartialServiceSetIntegration(t *testing.T) {
|
||||
pool := testutil.TestPool(t)
|
||||
st := New(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
service, err := st.CreateService(ctx, ServiceInput{
|
||||
Name: "Router",
|
||||
URLs: []ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://router.local", IsPrimary: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = st.CreateService(ctx, ServiceInput{
|
||||
Name: "NAS",
|
||||
URLs: []ServiceURLInput{
|
||||
{Label: "local", Kind: "local", URL: "http://nas.local", IsPrimary: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = st.ApplyLayout(ctx, LayoutInput{
|
||||
GroupIDs: []string{},
|
||||
WidgetIDs: []string{},
|
||||
UngroupedServices: []string{service.ID},
|
||||
GroupServices: map[string][]string{},
|
||||
})
|
||||
if !errors.Is(err, ErrValidation) {
|
||||
t.Fatalf("ApplyLayout() error = %v, want validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetCacheIntegration(t *testing.T) {
|
||||
pool := testutil.TestPool(t)
|
||||
st := New(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
widget, err := st.CreateWidget(ctx, WidgetInput{
|
||||
Type: "clock",
|
||||
Title: "Clock",
|
||||
Config: json.RawMessage(`{"timezones":["Europe/Prague"]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
err = st.SaveWidgetData(ctx, WidgetData{
|
||||
WidgetID: widget.ID,
|
||||
Status: "fresh",
|
||||
Data: json.RawMessage(`{"ok":true}`),
|
||||
FetchedAt: &now,
|
||||
ExpiresAt: &now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := st.WidgetData(ctx, widget.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data.Status != "fresh" || len(data.Data) == 0 {
|
||||
t.Fatalf("unexpected widget data: %+v", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func TestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := goose.Up(db, migrationsDir(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
Truncate(t, pool)
|
||||
t.Cleanup(func() { Truncate(t, pool) })
|
||||
return pool
|
||||
}
|
||||
|
||||
func Truncate(t *testing.T, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := pool.Exec(ctx, `
|
||||
TRUNCATE widget_cache, widget_instances, service_urls, services, asset_files, groups
|
||||
RESTART IDENTITY CASCADE`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrationsDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot resolve testutil path")
|
||||
}
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", "db", "migrations"))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Name(raw string) (string, error) {
|
||||
return boundedText(raw, 1, 80, "name")
|
||||
}
|
||||
|
||||
func Label(raw string) (string, error) {
|
||||
return boundedText(raw, 1, 40, "label")
|
||||
}
|
||||
|
||||
func AbsoluteHTTP(raw, field string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", field)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("%s must be absolute URL", field)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("%s must use http or https", field)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func OptionalAbsoluteHTTP(raw, field string) (*string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
clean, err := AbsoluteHTTP(value, field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &clean, nil
|
||||
}
|
||||
|
||||
func URLKind(kind string) error {
|
||||
switch kind {
|
||||
case "local", "external", "custom":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("kind must be local, external, or custom")
|
||||
}
|
||||
}
|
||||
|
||||
func WidgetType(kind string) error {
|
||||
switch kind {
|
||||
case "clock", "image", "pihole", "memos":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("widget type must be clock, image, pihole, or memos")
|
||||
}
|
||||
}
|
||||
|
||||
func boundedText(raw string, minLen int, maxLen int, field string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if len(value) < minLen {
|
||||
return "", fmt.Errorf("%s is required", field)
|
||||
}
|
||||
if len(value) > maxLen {
|
||||
return "", fmt.Errorf("%s must be at most %d chars", field, maxLen)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package validation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAbsoluteHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
}{
|
||||
{"http", "http://localhost:3000", false},
|
||||
{"https", "https://example.com", false},
|
||||
{"relative", "/pihole", true},
|
||||
{"ftp", "ftp://example.com", true},
|
||||
{"missing host", "https://", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := AbsoluteHTTP(tt.raw, "url")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("AbsoluteHTTP() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
if got, err := Name(" Pi-hole "); err != nil || got != "Pi-hole" {
|
||||
t.Fatalf("Name() = %q, %v", got, err)
|
||||
}
|
||||
if _, err := Name(""); err == nil {
|
||||
t.Fatal("Name() accepted empty value")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package widgets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"dash/backend/internal/validation"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WidgetTemplate defines everything needed to support a widget type.
|
||||
// To add a new widget:
|
||||
// 1. Add a new entry to the All map below.
|
||||
// 2. Implement Validate and Fetch functions in this file (or import them).
|
||||
// 3. Add frontend template in frontend/lib/widgets/templates.ts.
|
||||
// 4. Add frontend widget component in frontend/components/widgets/.
|
||||
// 5. Add backend validation in validation.go WidgetType switch.
|
||||
// 6. Update DB migration enum if needed.
|
||||
|
||||
type WidgetTemplate struct {
|
||||
Type string
|
||||
Name string
|
||||
Description string
|
||||
Category string // "system" | "service"
|
||||
DefaultTitle string
|
||||
DefaultConfig map[string]any
|
||||
NeedsDataFetch bool
|
||||
Validate func(raw []byte) error
|
||||
Fetch func(ctx context.Context, client *http.Client, raw []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// All registered widget templates. Ordered slice for stable listing.
|
||||
var All = []*WidgetTemplate{
|
||||
{
|
||||
Type: "clock",
|
||||
Name: "Clock",
|
||||
Description: "Display current time across multiple timezones.",
|
||||
Category: "system",
|
||||
DefaultTitle: "Clock",
|
||||
DefaultConfig: map[string]any{"timezones": []string{"UTC"}},
|
||||
NeedsDataFetch: false,
|
||||
Validate: func(raw []byte) error {
|
||||
var cfg struct {
|
||||
Timezones []string `json:"timezones"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Timezones) == 0 {
|
||||
return errors.New("at least one timezone is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "image",
|
||||
Name: "Image",
|
||||
Description: "Show an image from a URL with an optional link.",
|
||||
Category: "system",
|
||||
DefaultTitle: "Image",
|
||||
DefaultConfig: map[string]any{"imageUrl": "", "linkUrl": nil},
|
||||
NeedsDataFetch: false,
|
||||
Validate: func(raw []byte) error {
|
||||
var cfg struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
LinkURL string `json:"linkUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validation.AbsoluteHTTP(cfg.ImageURL, "imageUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.LinkURL != "" {
|
||||
if _, err := validation.AbsoluteHTTP(cfg.LinkURL, "linkUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "pihole",
|
||||
Name: "Pi-hole",
|
||||
Description: "Live stats from a Pi-hole DNS sinkhole instance.",
|
||||
Category: "service",
|
||||
DefaultTitle: "Pi-hole",
|
||||
DefaultConfig: map[string]any{"baseUrl": "", "apiToken": ""},
|
||||
NeedsDataFetch: true,
|
||||
Validate: func(raw []byte) error {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIToken string `json:"apiToken"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validation.AbsoluteHTTP(cfg.BaseURL, "baseUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Fetch: fetchPiHole,
|
||||
},
|
||||
{
|
||||
Type: "memos",
|
||||
Name: "Memos",
|
||||
Description: "Recent notes from your Memos instance.",
|
||||
Category: "service",
|
||||
DefaultTitle: "Memos",
|
||||
DefaultConfig: map[string]any{"baseUrl": "", "apiToken": "", "pageSize": 5},
|
||||
NeedsDataFetch: true,
|
||||
Validate: func(raw []byte) error {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIToken string `json:"apiToken"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validation.AbsoluteHTTP(cfg.BaseURL, "baseUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.APIToken == "" {
|
||||
return errors.New("apiToken is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Fetch: fetchMemos,
|
||||
},
|
||||
{
|
||||
Type: "immich",
|
||||
Name: "Immich",
|
||||
Description: "Photo and video stats from your Immich server.",
|
||||
Category: "service",
|
||||
DefaultTitle: "Immich",
|
||||
DefaultConfig: map[string]any{"baseUrl": "", "apiKey": ""},
|
||||
NeedsDataFetch: true,
|
||||
Validate: func(raw []byte) error {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validation.AbsoluteHTTP(cfg.BaseURL, "baseUrl"); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return errors.New("apiKey is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Fetch: fetchImmich,
|
||||
},
|
||||
}
|
||||
|
||||
var byType = make(map[string]*WidgetTemplate)
|
||||
|
||||
func init() {
|
||||
for _, t := range All {
|
||||
byType[t.Type] = t
|
||||
}
|
||||
}
|
||||
|
||||
func GetTemplate(widgetType string) (*WidgetTemplate, bool) {
|
||||
t, ok := byType[widgetType]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func fetchPiHole(ctx context.Context, client *http.Client, raw []byte) ([]byte, error) {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIToken string `json:"apiToken"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := url.Parse(strings.TrimRight(cfg.BaseURL, "/"))
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return nil, errors.New("invalid Pi-hole baseUrl")
|
||||
}
|
||||
|
||||
if payload, err := fetchPiHoleV6(ctx, client, base, cfg.APIToken); err == nil {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
endpoints := []string{"/admin/api.php?summaryRaw"}
|
||||
var lastErr error
|
||||
var rawPayloadOut []byte
|
||||
for _, endpoint := range endpoints {
|
||||
requestURL := base.String() + endpoint
|
||||
if cfg.APIToken != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(requestURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
requestURL += sep + "auth=" + url.QueryEscape(cfg.APIToken)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
func() {
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
lastErr = fmt.Errorf("Pi-hole returned %d", res.StatusCode)
|
||||
return
|
||||
}
|
||||
var rawPayload map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&rawPayload); err != nil {
|
||||
lastErr = err
|
||||
return
|
||||
}
|
||||
payload, err := normalizePiHole(rawPayload)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
return
|
||||
}
|
||||
lastErr = nil
|
||||
rawPayloadOut = payload
|
||||
}()
|
||||
if lastErr == nil && rawPayloadOut != nil {
|
||||
return rawPayloadOut, nil
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("Pi-hole fetch failed")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func fetchPiHoleV6(ctx context.Context, client *http.Client, base *url.URL, password string) ([]byte, error) {
|
||||
sid := ""
|
||||
if password != "" {
|
||||
authURL := base.String() + "/api/auth"
|
||||
body, err := json.Marshal(map[string]string{"password": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Pi-hole auth returned %d", res.StatusCode)
|
||||
}
|
||||
var auth struct {
|
||||
Session struct {
|
||||
Valid bool `json:"valid"`
|
||||
SID string `json:"sid"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !auth.Session.Valid || auth.Session.SID == "" {
|
||||
return nil, errors.New("Pi-hole auth returned invalid session")
|
||||
}
|
||||
sid = auth.Session.SID
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String()+"/api/stats/summary", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sid != "" {
|
||||
req.Header.Set("X-FTL-SID", sid)
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Pi-hole returned %d", res.StatusCode)
|
||||
}
|
||||
var rawPayload map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&rawPayload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizePiHole(rawPayload)
|
||||
}
|
||||
|
||||
func normalizePiHole(raw map[string]any) ([]byte, error) {
|
||||
blocked := number(raw, "queries_blocked")
|
||||
if blocked == 0 {
|
||||
blocked = nestedNumber(raw, "queries", "blocked")
|
||||
}
|
||||
if blocked == 0 {
|
||||
blocked = number(raw, "ads_blocked_today")
|
||||
}
|
||||
total := number(raw, "dns_queries_today")
|
||||
if total == 0 {
|
||||
total = nestedNumber(raw, "queries", "total")
|
||||
}
|
||||
if total == 0 {
|
||||
total = number(raw, "queries")
|
||||
}
|
||||
percent := number(raw, "ads_percentage_today")
|
||||
if percent == 0 {
|
||||
percent = nestedNumber(raw, "queries", "percent_blocked")
|
||||
}
|
||||
if percent == 0 && total > 0 {
|
||||
percent = blocked / total * 100
|
||||
}
|
||||
status := "unknown"
|
||||
if value, ok := raw["status"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"blockedCount": blocked,
|
||||
"queryCount": total,
|
||||
"percentBlocked": percent,
|
||||
"status": status,
|
||||
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func nestedNumber(raw map[string]any, objectKey string, key string) float64 {
|
||||
nested, ok := raw[objectKey].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return number(nested, key)
|
||||
}
|
||||
|
||||
func number(raw map[string]any, key string) float64 {
|
||||
switch value := raw[key].(type) {
|
||||
case float64:
|
||||
return value
|
||||
case int:
|
||||
return float64(value)
|
||||
case json.Number:
|
||||
out, _ := value.Float64()
|
||||
return out
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func fetchMemos(ctx context.Context, client *http.Client, raw []byte) ([]byte, error) {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIToken string `json:"apiToken"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.PageSize <= 0 {
|
||||
cfg.PageSize = 5
|
||||
}
|
||||
if cfg.PageSize > 20 {
|
||||
cfg.PageSize = 20
|
||||
}
|
||||
|
||||
reqURL := strings.TrimRight(cfg.BaseURL, "/") + fmt.Sprintf("/api/v1/memos?pageSize=%d", cfg.PageSize)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("memos returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Memos []struct {
|
||||
Name string `json:"name"`
|
||||
UID string `json:"uid"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"createTime"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
} `json:"memos"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type memoSummary struct {
|
||||
UID string `json:"uid"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"createTime"`
|
||||
}
|
||||
summaries := make([]memoSummary, 0, len(body.Memos))
|
||||
for _, m := range body.Memos {
|
||||
content := strings.TrimSpace(m.Content)
|
||||
if len(content) > 120 {
|
||||
content = content[:117] + "..."
|
||||
}
|
||||
summaries = append(summaries, memoSummary{
|
||||
UID: m.UID,
|
||||
Content: content,
|
||||
CreateTime: m.CreateTime,
|
||||
})
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"memos": summaries,
|
||||
"count": len(body.Memos),
|
||||
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func fetchImmich(ctx context.Context, client *http.Client, raw []byte) ([]byte, error) {
|
||||
var cfg struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqURL := strings.TrimRight(cfg.BaseURL, "/") + "/api/server-info/statistics"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-api-key", cfg.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("immich returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Photos int `json:"photos"`
|
||||
Videos int `json:"videos"`
|
||||
Usage int `json:"usage"` // bytes
|
||||
Users int `json:"users"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Format usage to human readable
|
||||
usageStr := formatBytes(body.Usage)
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"photos": body.Photos,
|
||||
"videos": body.Videos,
|
||||
"usage": usageStr,
|
||||
"usageRaw": body.Usage,
|
||||
"users": body.Users,
|
||||
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func formatBytes(b int) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package widgets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"dash/backend/internal/store"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
store *store.Store
|
||||
client *http.Client
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewRegistry(st *store.Store, timeout time.Duration, cacheTTL time.Duration) *Registry {
|
||||
return &Registry{
|
||||
store: st,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Data(ctx context.Context, widget store.WidgetInstance) (store.WidgetData, error) {
|
||||
cached, err := r.store.WidgetData(ctx, widget.ID)
|
||||
if err == nil && store.Fresh(cached, time.Now()) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
tmpl, ok := GetTemplate(widget.Type)
|
||||
if !ok {
|
||||
return store.WidgetData{WidgetID: widget.ID, Status: "fresh", Data: json.RawMessage(`{}`)}, nil
|
||||
}
|
||||
if !tmpl.NeedsDataFetch {
|
||||
if err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
return store.WidgetData{WidgetID: widget.ID, Status: "fresh", Data: json.RawMessage(`{}`)}, nil
|
||||
}
|
||||
return r.Refresh(ctx, widget)
|
||||
}
|
||||
|
||||
func (r *Registry) Refresh(ctx context.Context, widget store.WidgetInstance) (store.WidgetData, error) {
|
||||
now := time.Now()
|
||||
|
||||
tmpl, ok := GetTemplate(widget.Type)
|
||||
if !ok || !tmpl.NeedsDataFetch {
|
||||
data := store.WidgetData{
|
||||
WidgetID: widget.ID,
|
||||
Status: "fresh",
|
||||
Data: json.RawMessage(`{}`),
|
||||
FetchedAt: &now,
|
||||
ExpiresAt: ptr(now.Add(r.cacheTTL)),
|
||||
}
|
||||
return data, r.store.SaveWidgetData(ctx, data)
|
||||
}
|
||||
|
||||
payload, err := tmpl.Fetch(ctx, r.client, widget.Config)
|
||||
if err != nil {
|
||||
message := "[ERROR: " + err.Error() + "]"
|
||||
if cached, cacheErr := r.store.WidgetData(ctx, widget.ID); cacheErr == nil && len(cached.Data) > 0 {
|
||||
cached.Status = "stale"
|
||||
cached.Error = &message
|
||||
cached.ExpiresAt = ptr(now.Add(r.cacheTTL))
|
||||
_ = r.store.SaveWidgetData(ctx, cached)
|
||||
return cached, nil
|
||||
}
|
||||
data := store.WidgetData{
|
||||
WidgetID: widget.ID,
|
||||
Status: "error",
|
||||
Error: &message,
|
||||
FetchedAt: &now,
|
||||
ExpiresAt: ptr(now.Add(r.cacheTTL)),
|
||||
}
|
||||
_ = r.store.SaveWidgetData(ctx, data)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
data := store.WidgetData{
|
||||
WidgetID: widget.ID,
|
||||
Status: "fresh",
|
||||
Data: payload,
|
||||
FetchedAt: &now,
|
||||
ExpiresAt: ptr(now.Add(r.cacheTTL)),
|
||||
}
|
||||
return data, r.store.SaveWidgetData(ctx, data)
|
||||
}
|
||||
|
||||
func ptr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package widgets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePiHoleClassicSummary(t *testing.T) {
|
||||
got, err := normalizePiHole(map[string]any{
|
||||
"ads_blocked_today": float64(25),
|
||||
"dns_queries_today": float64(100),
|
||||
"ads_percentage_today": float64(25),
|
||||
"status": "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(got, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["blockedCount"] != float64(25) || payload["queryCount"] != float64(100) {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePiHoleV6Summary(t *testing.T) {
|
||||
got, err := normalizePiHole(map[string]any{
|
||||
"queries": map[string]any{
|
||||
"blocked": float64(30),
|
||||
"total": float64(120),
|
||||
"percent_blocked": float64(25),
|
||||
},
|
||||
"status": "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(got, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["percentBlocked"] != float64(25) {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user