🚀 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:
Tomas Dvorak
2026-05-03 16:13:46 +02:00
commit b17a06fbba
59 changed files with 12534 additions and 0 deletions
@@ -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
}
+32
View File
@@ -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,
}
}
+72
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
package store
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrValidation = errors.New("validation")
)
+111
View File
@@ -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"`
}
+648
View File
@@ -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)
}
}