mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-03 23:12: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:
+468
@@ -0,0 +1,468 @@
|
||||
# BackendPlan.md
|
||||
|
||||
## Mission
|
||||
|
||||
Build the complete backend for the self-hosted homelab dashboard. The backend owns persistence, OpenAPI, migrations, ordering rules, icon uploads, widget integrations, Docker runtime, and production-grade API behavior.
|
||||
|
||||
This agent may edit:
|
||||
|
||||
- `/backend`
|
||||
- `/db`
|
||||
- `/openapi`
|
||||
- root infra/docs needed for backend integration, such as `docker-compose.yml`, `.env.example`, `Makefile`, and `README.md`
|
||||
|
||||
This agent must not edit `/frontend` except to document commands or contract expectations.
|
||||
|
||||
## Product Context
|
||||
|
||||
The app is a lightweight dashboard for managing self-hosted and external services. It replaces older dashboard-style tools with a modern Vercel/Nothing-inspired interface. v1 has no authentication and assumes trusted LAN/self-hosted use.
|
||||
|
||||
Core backend responsibilities:
|
||||
|
||||
- Store service cards and multiple launch URLs per service.
|
||||
- Store groups, collapsed state, and drag/drop ordering.
|
||||
- Store dashboard widgets and widget configuration.
|
||||
- Provide one real third-party widget adapter: Pi-hole.
|
||||
- Store uploaded service icons locally.
|
||||
- Serve a stable OpenAPI contract so frontend can generate types/client.
|
||||
|
||||
## Stack
|
||||
|
||||
- Go 1.22 or newer.
|
||||
- Gin for HTTP routing.
|
||||
- zap for structured logging.
|
||||
- pgx for PostgreSQL access.
|
||||
- PostgreSQL 16.
|
||||
- goose for migrations.
|
||||
- sqlc for typed query generation.
|
||||
- OpenAPI 3.1 in `openapi/openapi.yaml`.
|
||||
- Docker Compose for local/self-hosted runtime.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
Backend implementation should use this structure:
|
||||
|
||||
```text
|
||||
backend/
|
||||
cmd/server/
|
||||
main.go
|
||||
internal/config/
|
||||
internal/http/
|
||||
internal/services/
|
||||
internal/store/
|
||||
internal/widgets/
|
||||
internal/assets/
|
||||
internal/validation/
|
||||
internal/testutil/
|
||||
db/
|
||||
migrations/
|
||||
query/
|
||||
openapi/
|
||||
openapi.yaml
|
||||
```
|
||||
|
||||
Package intent:
|
||||
|
||||
- `cmd/server`: process entrypoint, config load, logger, DB pool, router start.
|
||||
- `internal/config`: env parsing, defaults, validation.
|
||||
- `internal/http`: routes, handlers, request/response mapping.
|
||||
- `internal/services`: business rules and ordering transactions.
|
||||
- `internal/store`: sqlc-generated store plus thin transaction helpers.
|
||||
- `internal/widgets`: widget registry, Pi-hole adapter, cache refresh logic.
|
||||
- `internal/assets`: icon upload validation and local file storage.
|
||||
- `internal/validation`: shared URL/name/config validation.
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
Provide `.env.example` with:
|
||||
|
||||
```env
|
||||
APP_ENV=development
|
||||
HTTP_ADDR=:8080
|
||||
DATABASE_URL=postgres://dash:dash@postgres:5432/dash?sslmode=disable
|
||||
DATA_DIR=/data
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
WIDGET_FETCH_TIMEOUT=5s
|
||||
WIDGET_CACHE_TTL=60s
|
||||
MAX_ICON_UPLOAD_BYTES=524288
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Fail fast on invalid required config.
|
||||
- Create icon upload directory under `DATA_DIR/icons`.
|
||||
- Use CORS only for configured origins in development/self-hosted split runtime.
|
||||
- Keep all secrets in env or database, never hardcoded.
|
||||
|
||||
## Data Model
|
||||
|
||||
Use UUID primary keys, `created_at`, `updated_at`, and integer `sort_order` where order matters.
|
||||
|
||||
Tables:
|
||||
|
||||
```text
|
||||
groups
|
||||
id uuid pk
|
||||
name text not null
|
||||
sort_order int not null
|
||||
collapsed boolean not null default false
|
||||
created_at timestamptz not null
|
||||
updated_at timestamptz not null
|
||||
|
||||
services
|
||||
id uuid pk
|
||||
group_id uuid null references groups(id) on delete set null
|
||||
name text not null
|
||||
icon_url text null
|
||||
icon_asset_id uuid null references asset_files(id) on delete set null
|
||||
sort_order int not null
|
||||
created_at timestamptz not null
|
||||
updated_at timestamptz not null
|
||||
|
||||
service_urls
|
||||
id uuid pk
|
||||
service_id uuid not null references services(id) on delete cascade
|
||||
label text not null
|
||||
kind text not null check (kind in ('local', 'external', 'custom'))
|
||||
url text not null
|
||||
sort_order int not null
|
||||
is_primary boolean not null default false
|
||||
created_at timestamptz not null
|
||||
updated_at timestamptz not null
|
||||
|
||||
widget_instances
|
||||
id uuid pk
|
||||
type text not null check (type in ('clock', 'image', 'pihole'))
|
||||
title text not null
|
||||
enabled boolean not null default true
|
||||
sort_order int not null
|
||||
config jsonb not null default '{}'
|
||||
created_at timestamptz not null
|
||||
updated_at timestamptz not null
|
||||
|
||||
widget_cache
|
||||
widget_id uuid pk references widget_instances(id) on delete cascade
|
||||
status text not null check (status in ('fresh', 'stale', 'error'))
|
||||
data jsonb null
|
||||
error text null
|
||||
fetched_at timestamptz null
|
||||
expires_at timestamptz null
|
||||
updated_at timestamptz not null
|
||||
|
||||
asset_files
|
||||
id uuid pk
|
||||
original_name text not null
|
||||
stored_name text not null
|
||||
mime_type text not null
|
||||
size_bytes int not null
|
||||
public_path text not null
|
||||
created_at timestamptz not null
|
||||
```
|
||||
|
||||
Indexes:
|
||||
|
||||
- `groups(sort_order)`
|
||||
- `services(group_id, sort_order)`
|
||||
- `service_urls(service_id, sort_order)`
|
||||
- `widget_instances(enabled, sort_order)`
|
||||
- `asset_files(created_at)`
|
||||
|
||||
Ordering invariant:
|
||||
|
||||
- Groups order independently.
|
||||
- Services order inside their current `group_id`; ungrouped services use `group_id = null`.
|
||||
- Widgets order independently.
|
||||
- Reorder endpoints accept full ordered lists and persist in one transaction.
|
||||
|
||||
## OpenAPI Contract
|
||||
|
||||
Backend owns `openapi/openapi.yaml`. Create/update it before handler implementation. Frontend must be able to generate TypeScript client/types from it.
|
||||
|
||||
Base path:
|
||||
|
||||
```text
|
||||
/api/v1
|
||||
```
|
||||
|
||||
Health:
|
||||
|
||||
```text
|
||||
GET /health
|
||||
```
|
||||
|
||||
Required endpoints:
|
||||
|
||||
```text
|
||||
GET /api/v1/dashboard
|
||||
|
||||
GET /api/v1/groups
|
||||
POST /api/v1/groups
|
||||
GET /api/v1/groups/{groupId}
|
||||
PATCH /api/v1/groups/{groupId}
|
||||
DELETE /api/v1/groups/{groupId}
|
||||
|
||||
GET /api/v1/services
|
||||
POST /api/v1/services
|
||||
GET /api/v1/services/{serviceId}
|
||||
PATCH /api/v1/services/{serviceId}
|
||||
DELETE /api/v1/services/{serviceId}
|
||||
|
||||
PUT /api/v1/layout
|
||||
|
||||
POST /api/v1/assets/icons
|
||||
|
||||
GET /api/v1/widgets
|
||||
POST /api/v1/widgets
|
||||
GET /api/v1/widgets/{widgetId}
|
||||
PATCH /api/v1/widgets/{widgetId}
|
||||
DELETE /api/v1/widgets/{widgetId}
|
||||
GET /api/v1/widgets/{widgetId}/data
|
||||
POST /api/v1/widgets/{widgetId}/refresh
|
||||
```
|
||||
|
||||
Core response schemas:
|
||||
|
||||
```yaml
|
||||
Dashboard:
|
||||
groups: Group[]
|
||||
ungroupedServices: Service[]
|
||||
widgets: WidgetInstance[]
|
||||
|
||||
Group:
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: integer
|
||||
collapsed: boolean
|
||||
services: Service[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
Service:
|
||||
id: string
|
||||
groupId: string | null
|
||||
name: string
|
||||
iconUrl: string | null
|
||||
iconAssetId: string | null
|
||||
sortOrder: integer
|
||||
urls: ServiceUrl[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
ServiceUrl:
|
||||
id: string
|
||||
label: string
|
||||
kind: local | external | custom
|
||||
url: string
|
||||
sortOrder: integer
|
||||
isPrimary: boolean
|
||||
|
||||
WidgetInstance:
|
||||
id: string
|
||||
type: clock | image | pihole
|
||||
title: string
|
||||
enabled: boolean
|
||||
sortOrder: integer
|
||||
config: object
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
```
|
||||
|
||||
Error schema:
|
||||
|
||||
```yaml
|
||||
ErrorResponse:
|
||||
code: string
|
||||
message: string
|
||||
details: object | null
|
||||
```
|
||||
|
||||
Use stable error codes:
|
||||
|
||||
- `validation_error`
|
||||
- `not_found`
|
||||
- `conflict`
|
||||
- `upload_too_large`
|
||||
- `unsupported_media_type`
|
||||
- `widget_fetch_failed`
|
||||
- `internal_error`
|
||||
|
||||
## API Behavior
|
||||
|
||||
Dashboard:
|
||||
|
||||
- `GET /dashboard` returns all enabled/visible dashboard data in render order.
|
||||
- Include services nested under groups.
|
||||
- Include ungrouped services separately.
|
||||
- Include widgets regardless of data freshness; data comes from widget data endpoint.
|
||||
|
||||
Groups:
|
||||
|
||||
- Create group with next `sort_order`.
|
||||
- Rename and collapsed state via PATCH.
|
||||
- Delete empty group by default.
|
||||
- If group contains services, return `409 conflict` unless request includes explicit handling:
|
||||
- `?moveServicesToUngrouped=true` moves services to ungrouped.
|
||||
- No hard delete of child services in v1.
|
||||
|
||||
Services:
|
||||
|
||||
- Create/update accepts service fields and full URL list.
|
||||
- A service must have at least one URL.
|
||||
- URL labels must be non-empty.
|
||||
- Exactly one primary URL is allowed; if omitted, first URL becomes primary.
|
||||
- Patch replaces URL list atomically for simplicity.
|
||||
- Delete service cascades URLs.
|
||||
|
||||
Layout:
|
||||
|
||||
- `PUT /layout` accepts ordered groups, ordered widgets, and ordered services per group/ungrouped.
|
||||
- Validate all referenced IDs exist.
|
||||
- Validate every submitted service appears once.
|
||||
- Persist all ordering in one DB transaction.
|
||||
- Return updated dashboard.
|
||||
|
||||
Assets:
|
||||
|
||||
- `POST /assets/icons` accepts multipart field `file`.
|
||||
- Allow PNG, JPEG, WebP, SVG only.
|
||||
- Enforce `MAX_ICON_UPLOAD_BYTES`.
|
||||
- Store file under `DATA_DIR/icons`.
|
||||
- Return `AssetFile` with `publicPath`.
|
||||
- Serve uploaded icons from `/uploads/icons/{storedName}`.
|
||||
|
||||
Widgets:
|
||||
|
||||
- Widget config is JSONB but validated by widget type.
|
||||
- `clock` config: `{ "timezones": string[] }`.
|
||||
- `image` config: `{ "imageUrl": string, "linkUrl": string | null }`.
|
||||
- `pihole` config: `{ "baseUrl": string, "apiToken": string }`.
|
||||
- Do not expose `apiToken` in normal widget responses. Return masked/omitted secret fields.
|
||||
- `GET /widgets/{id}/data` returns cache if fresh; refreshes if expired where reasonable.
|
||||
- `POST /widgets/{id}/refresh` forces fetch and updates cache.
|
||||
|
||||
Pi-hole adapter:
|
||||
|
||||
- Use HTTP client with timeout from config.
|
||||
- Normalize base URL and call Pi-hole summary endpoint.
|
||||
- Support modern Pi-hole API where possible, but isolate endpoint details behind adapter.
|
||||
- Return compact data useful for cards: blocked count, query count, percent blocked, status, fetchedAt.
|
||||
- On failure, keep stale data if available and return status `stale` with error.
|
||||
- If no data exists, return status `error` with `[ERROR: ...]` compatible message.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Names: trim whitespace, 1 to 80 chars.
|
||||
- Labels: trim whitespace, 1 to 40 chars.
|
||||
- URLs: absolute `http` or `https` only for service URLs and widget URLs.
|
||||
- Icon URL: absolute `http`/`https` or uploaded asset reference.
|
||||
- Reject duplicate service URL IDs in update payload.
|
||||
- Reject unknown widget type.
|
||||
- Reject unknown fields only if OpenAPI decoder supports this cleanly; otherwise ignore safely.
|
||||
|
||||
## Security Posture
|
||||
|
||||
v1 has no authentication. Still implement baseline hardening:
|
||||
|
||||
- No arbitrary file paths from user input.
|
||||
- Uploaded file names must be generated server-side.
|
||||
- SVG uploads must be served as files, not inlined.
|
||||
- Do not log Pi-hole tokens.
|
||||
- CORS restricted to configured origins.
|
||||
- Use request body size limits.
|
||||
- Return generic internal errors to clients.
|
||||
|
||||
## Docker and Local Run
|
||||
|
||||
Provide root `docker-compose.yml` with:
|
||||
|
||||
- `postgres`
|
||||
- `backend`
|
||||
|
||||
Frontend service may be added by frontend agent later. Backend compose must still work alone.
|
||||
|
||||
Backend container:
|
||||
|
||||
- Multi-stage Dockerfile.
|
||||
- Runs migrations before server start, or provide clear `make migrate-up` used by compose entrypoint.
|
||||
- Exposes `8080`.
|
||||
- Mounts persistent data volume for `/data`.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
make backend-dev
|
||||
make backend-test
|
||||
make db-migrate-up
|
||||
make db-migrate-down
|
||||
make openapi-validate
|
||||
make sqlc
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Scaffold Go module and backend package layout.
|
||||
2. Create Docker Compose, backend Dockerfile, `.env.example`, Makefile targets.
|
||||
3. Write initial goose migrations for all tables/indexes.
|
||||
4. Write sqlc config and queries for CRUD, dashboard loading, and ordering.
|
||||
5. Write `openapi/openapi.yaml` with all schemas/endpoints/errors.
|
||||
6. Implement config, logger, DB pool, health route.
|
||||
7. Implement group/service/dashboard APIs.
|
||||
8. Implement layout transaction.
|
||||
9. Implement asset upload and static file serving.
|
||||
10. Implement widget registry, config validation, cache table logic.
|
||||
11. Implement Pi-hole adapter and refresh/data endpoints.
|
||||
12. Add backend tests and integration tests.
|
||||
13. Run full backend validation.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- URL validation accepts `http`/`https`, rejects relative and unsupported schemes.
|
||||
- Service create/update requires at least one URL.
|
||||
- Primary URL fallback selects first URL.
|
||||
- Group delete rejects non-empty group without move flag.
|
||||
- Widget config validation rejects missing Pi-hole token/base URL.
|
||||
- Pi-hole token masking never returns raw token.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- Migrations apply cleanly to empty Postgres.
|
||||
- Create group, create service with two URLs, fetch dashboard.
|
||||
- Move service between groups through layout endpoint.
|
||||
- Reorder groups/services/widgets in one transaction.
|
||||
- Delete service cascades URLs.
|
||||
- Upload valid icon, reject oversized/unsupported file.
|
||||
- Widget refresh caches success and preserves stale data on later failure.
|
||||
|
||||
Smoke tests:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make openapi-validate
|
||||
docker compose up --build
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:8080/api/v1/dashboard
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `openapi/openapi.yaml` exists and is valid.
|
||||
- Backend compiles with `go test ./...`.
|
||||
- Migrations run against PostgreSQL 16.
|
||||
- API returns deterministic JSON matching OpenAPI.
|
||||
- Dashboard data persists after container restart.
|
||||
- `docker compose up` boots backend and Postgres cleanly.
|
||||
- Frontend agent can generate client from OpenAPI without manual type fixes.
|
||||
|
||||
## Parallel Coordination
|
||||
|
||||
- Backend can proceed before frontend exists.
|
||||
- Keep breaking OpenAPI changes deliberate and documented.
|
||||
- If contract changes, update `openapi/openapi.yaml` first, then backend handlers/tests.
|
||||
- Do not create frontend types by hand.
|
||||
- Do not move shared contract into frontend.
|
||||
|
||||
Reference in New Issue
Block a user