🚀 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
+9
View File
@@ -0,0 +1,9 @@
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
+56
View File
@@ -0,0 +1,56 @@
# Dependencies
frontend/node_modules/
backend/vendor/
# Build outputs
frontend/.next/
frontend/out/
frontend/build/
backend/tmp/
backend/bin/
*.exe
# Environment files (NEVER commit these)
.env
.env.local
.env.*.local
frontend/.env*
backend/.env*
# Data directories
data/
backend/data/
*.db
*.sqlite
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Testing
coverage/
*.test
# Misc
.DS_Store
*.pem
.vercel
.vscode/
.idea/
*.swp
*.swo
*~
# TypeScript
*.tsbuildinfo
next-env.d.ts
# OS files
Thumbs.db
# Keep only the main screenshot for README
screenshots/*.png
!screenshots/dashboard-dark.png
+468
View File
@@ -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.
+310
View File
@@ -0,0 +1,310 @@
# Design System Inspired by Vercel
## 1. Visual Theme & Atmosphere
Vercel's website is the visual thesis of developer infrastructure made invisible — a design system so restrained it borders on philosophical. The page is overwhelmingly white (`#ffffff`) with near-black (`#171717`) text, creating a gallery-like emptiness where every element earns its pixel. This isn't minimalism as decoration; it's minimalism as engineering principle. The Geist design system treats the interface like a compiler treats code — every unnecessary token is stripped away until only structure remains.
The custom Geist font family is the crown jewel. Geist Sans uses aggressive negative letter-spacing (-2.4px to -2.88px at display sizes), creating headlines that feel compressed, urgent, and engineered — like code that's been minified for production. At body sizes, the tracking relaxes but the geometric precision persists. Geist Mono completes the system as the monospace companion for code, terminal output, and technical labels. Both fonts enable OpenType `"liga"` (ligatures) globally, adding a layer of typographic sophistication that rewards close reading.
What distinguishes Vercel from other monochrome design systems is its shadow-as-border philosophy. Instead of traditional CSS borders, Vercel uses `box-shadow: 0px 0px 0px 1px rgba(0,0,0,0.08)` — a zero-offset, zero-blur, 1px-spread shadow that creates a border-like line without the box model implications. This technique allows borders to exist in the shadow layer, enabling smoother transitions, rounded corners without clipping, and a subtler visual weight than traditional borders. The entire depth system is built on layered, multi-value shadow stacks where each layer serves a specific purpose: one for the border, one for soft elevation, one for ambient depth.
**Key Characteristics:**
- Geist Sans with extreme negative letter-spacing (-2.4px to -2.88px at display) — text as compressed infrastructure
- Geist Mono for code and technical labels with OpenType `"liga"` globally
- Shadow-as-border technique: `box-shadow 0px 0px 0px 1px` replaces traditional borders throughout
- Multi-layer shadow stacks for nuanced depth (border + elevation + ambient in single declarations)
- Near-pure white canvas with `#171717` text — not quite black, creating micro-contrast softness
- Workflow-specific accent colors: Ship Red (`#ff5b4f`), Preview Pink (`#de1d8d`), Develop Blue (`#0a72ef`)
- Focus ring system using `hsla(212, 100%, 48%, 1)` — a saturated blue for accessibility
- Pill badges (9999px) with tinted backgrounds for status indicators
## 2. Color Palette & Roles
### Primary
- **Vercel Black** (`#171717`): Primary text, headings, dark surface backgrounds. Not pure black — the slight warmth prevents harshness.
- **Pure White** (`#ffffff`): Page background, card surfaces, button text on dark.
- **True Black** (`#000000`): Secondary use, `--geist-console-text-color-default`, used in specific console/code contexts.
### Workflow Accent Colors
- **Ship Red** (`#ff5b4f`): `--ship-text`, the "ship to production" workflow step — warm, urgent coral-red.
- **Preview Pink** (`#de1d8d`): `--preview-text`, the preview deployment workflow — vivid magenta-pink.
- **Develop Blue** (`#0a72ef`): `--develop-text`, the development workflow — bright, focused blue.
### Console / Code Colors
- **Console Blue** (`#0070f3`): `--geist-console-text-color-blue`, syntax highlighting blue.
- **Console Purple** (`#7928ca`): `--geist-console-text-color-purple`, syntax highlighting purple.
- **Console Pink** (`#eb367f`): `--geist-console-text-color-pink`, syntax highlighting pink.
### Interactive
- **Link Blue** (`#0072f5`): Primary link color with underline decoration.
- **Focus Blue** (`hsla(212, 100%, 48%, 1)`): `--ds-focus-color`, focus ring on interactive elements.
- **Ring Blue** (`rgba(147, 197, 253, 0.5)`): `--tw-ring-color`, Tailwind ring utility.
### Neutral Scale
- **Gray 900** (`#171717`): Primary text, headings, nav text.
- **Gray 600** (`#4d4d4d`): Secondary text, description copy.
- **Gray 500** (`#666666`): Tertiary text, muted links.
- **Gray 400** (`#808080`): Placeholder text, disabled states.
- **Gray 100** (`#ebebeb`): Borders, card outlines, dividers.
- **Gray 50** (`#fafafa`): Subtle surface tint, inner shadow highlight.
### Surface & Overlay
- **Overlay Backdrop** (`hsla(0, 0%, 98%, 1)`): `--ds-overlay-backdrop-color`, modal/dialog backdrop.
- **Selection Text** (`hsla(0, 0%, 95%, 1)`): `--geist-selection-text-color`, text selection highlight.
- **Badge Blue Bg** (`#ebf5ff`): Pill badge background, tinted blue surface.
- **Badge Blue Text** (`#0068d6`): Pill badge text, darker blue for readability.
### Shadows & Depth
- **Border Shadow** (`rgba(0, 0, 0, 0.08) 0px 0px 0px 1px`): The signature — replaces traditional borders.
- **Subtle Elevation** (`rgba(0, 0, 0, 0.04) 0px 2px 2px`): Minimal lift for cards.
- **Card Stack** (`rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, rgba(0,0,0,0.04) 0px 8px 8px -8px, #fafafa 0px 0px 0px 1px`): Full multi-layer card shadow.
- **Ring Border** (`rgb(235, 235, 235) 0px 0px 0px 1px`): Light gray ring-border for tabs and images.
## 3. Typography Rules
### Font Family
- **Primary**: `Geist`, with fallbacks: `Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol`
- **Monospace**: `Geist Mono`, with fallbacks: `ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New`
- **OpenType Features**: `"liga"` enabled globally on all Geist text; `"tnum"` for tabular numbers on specific captions.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display Hero | Geist | 48px (3.00rem) | 600 | 1.001.17 (tight) | -2.4px to -2.88px | Maximum compression, billboard impact |
| Section Heading | Geist | 40px (2.50rem) | 600 | 1.20 (tight) | -2.4px | Feature section titles |
| Sub-heading Large | Geist | 32px (2.00rem) | 600 | 1.25 (tight) | -1.28px | Card headings, sub-sections |
| Sub-heading | Geist | 32px (2.00rem) | 400 | 1.50 | -1.28px | Lighter sub-headings |
| Card Title | Geist | 24px (1.50rem) | 600 | 1.33 | -0.96px | Feature cards |
| Card Title Light | Geist | 24px (1.50rem) | 500 | 1.33 | -0.96px | Secondary card headings |
| Body Large | Geist | 20px (1.25rem) | 400 | 1.80 (relaxed) | normal | Introductions, feature descriptions |
| Body | Geist | 18px (1.13rem) | 400 | 1.56 | normal | Standard reading text |
| Body Small | Geist | 16px (1.00rem) | 400 | 1.50 | normal | Standard UI text |
| Body Medium | Geist | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized text |
| Body Semibold | Geist | 16px (1.00rem) | 600 | 1.50 | -0.32px | Strong labels, active states |
| Button / Link | Geist | 14px (0.88rem) | 500 | 1.43 | normal | Buttons, links, captions |
| Button Small | Geist | 14px (0.88rem) | 400 | 1.00 (tight) | normal | Compact buttons |
| Caption | Geist | 12px (0.75rem) | 400500 | 1.33 | normal | Metadata, tags |
| Mono Body | Geist Mono | 16px (1.00rem) | 400 | 1.50 | normal | Code blocks |
| Mono Caption | Geist Mono | 13px (0.81rem) | 500 | 1.54 | normal | Code labels |
| Mono Small | Geist Mono | 12px (0.75rem) | 500 | 1.00 (tight) | normal | `text-transform: uppercase`, technical labels |
| Micro Badge | Geist | 7px (0.44rem) | 700 | 1.00 (tight) | normal | `text-transform: uppercase`, tiny badges |
### Principles
- **Compression as identity**: Geist Sans at display sizes uses -2.4px to -2.88px letter-spacing — the most aggressive negative tracking of any major design system. This creates text that feels _minified_, like code optimized for production. The tracking progressively relaxes as size decreases: -1.28px at 32px, -0.96px at 24px, -0.32px at 16px, and normal at 14px.
- **Ligatures everywhere**: Every Geist text element enables OpenType `"liga"`. Ligatures aren't decorative — they're structural, creating tighter, more efficient glyph combinations.
- **Three weights, strict roles**: 400 (body/reading), 500 (UI/interactive), 600 (headings/emphasis). No bold (700) except for tiny micro-badges. This narrow weight range creates hierarchy through size and tracking, not weight.
- **Mono for identity**: Geist Mono in uppercase with `"tnum"` or `"liga"` serves as the "developer console" voice — compact technical labels that connect the marketing site to the product.
## 4. Component Stylings
### Buttons
**Primary White (Shadow-bordered)**
- Background: `#ffffff`
- Text: `#171717`
- Padding: 0px 6px (minimal — content-driven width)
- Radius: 6px (subtly rounded)
- Shadow: `rgb(235, 235, 235) 0px 0px 0px 1px` (ring-border)
- Hover: background shifts to `var(--ds-gray-1000)` (dark)
- Focus: `2px solid var(--ds-focus-color)` outline + `var(--ds-focus-ring)` shadow
- Use: Standard secondary button
**Primary Dark (Inferred from Geist system)**
- Background: `#171717`
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 6px
- Use: Primary CTA ("Start Deploying", "Get Started")
**Pill Button / Badge**
- Background: `#ebf5ff` (tinted blue)
- Text: `#0068d6`
- Padding: 0px 10px
- Radius: 9999px (full pill)
- Font: 12px weight 500
- Use: Status badges, tags, feature labels
**Large Pill (Navigation)**
- Background: transparent or `#171717`
- Radius: 64px100px
- Use: Tab navigation, section selectors
### Cards & Containers
- Background: `#ffffff`
- Border: via shadow — `rgba(0, 0, 0, 0.08) 0px 0px 0px 1px`
- Radius: 8px (standard), 12px (featured/image cards)
- Shadow stack: `rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, #fafafa 0px 0px 0px 1px`
- Image cards: `1px solid #ebebeb` with 12px top radius
- Hover: subtle shadow intensification
### Inputs & Forms
- Radio: standard styling with focus `var(--ds-gray-200)` background
- Focus shadow: `1px 0 0 0 var(--ds-gray-alpha-600)`
- Focus outline: `2px solid var(--ds-focus-color)` — consistent blue focus ring
- Border: via shadow technique, not traditional border
### Navigation
- Clean horizontal nav on white, sticky
- Vercel logotype left-aligned, 262x52px
- Links: Geist 14px weight 500, `#171717` text
- Active: weight 600 or underline
- CTA: dark pill buttons ("Start Deploying", "Contact Sales")
- Mobile: hamburger menu collapse
- Product dropdowns with multi-level menus
### Image Treatment
- Product screenshots with `1px solid #ebebeb` border
- Top-rounded images: `12px 12px 0px 0px` radius
- Dashboard/code preview screenshots dominate feature sections
- Soft gradient backgrounds behind hero images (pastel multi-color)
### Distinctive Components
**Workflow Pipeline**
- Three-step horizontal pipeline: Develop → Preview → Ship
- Each step has its own accent color: Blue → Pink → Red
- Connected with lines/arrows
- The visual metaphor for Vercel's core value proposition
**Trust Bar / Logo Grid**
- Company logos (Perplexity, ChatGPT, Cursor, etc.) in grayscale
- Horizontal scroll or grid layout
- Subtle `#ebebeb` border separation
**Metric Cards**
- Large number display (e.g., "10x faster")
- Geist 48px weight 600 for the metric
- Description below in gray body text
- Shadow-bordered card container
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 16px, 32px, 36px, 40px
- Notable gap: jumps from 16px to 32px — no 20px or 24px in primary scale
### Grid & Container
- Max content width: approximately 1200px
- Hero: centered single-column with generous top padding
- Feature sections: 23 column grids for cards
- Full-width dividers using `border-bottom: 1px solid #171717`
- Code/dashboard screenshots as full-width or contained with border
### Whitespace Philosophy
- **Gallery emptiness**: Massive vertical padding between sections (80px120px+). The white space IS the design — it communicates that Vercel has nothing to prove and nothing to hide.
- **Compressed text, expanded space**: The aggressive negative letter-spacing on headlines is counterbalanced by generous surrounding whitespace. The text is dense; the space around it is vast.
- **Section rhythm**: White sections alternate with white sections — there's no color variation between sections. Separation comes from borders (shadow-borders) and spacing alone.
### Border Radius Scale
- Micro (2px): Inline code snippets, small spans
- Subtle (4px): Small containers
- Standard (6px): Buttons, links, functional elements
- Comfortable (8px): Cards, list items
- Image (12px): Featured cards, image containers (top-rounded)
- Large (64px): Tab navigation pills
- XL (100px): Large navigation links
- Full Pill (9999px): Badges, status pills, tags
- Circle (50%): Menu toggle, avatar containers
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow | Page background, text blocks |
| Ring (Level 1) | `rgba(0,0,0,0.08) 0px 0px 0px 1px` | Shadow-as-border for most elements |
| Light Ring (Level 1b) | `rgb(235,235,235) 0px 0px 0px 1px` | Lighter ring for tabs, images |
| Subtle Card (Level 2) | Ring + `rgba(0,0,0,0.04) 0px 2px 2px` | Standard cards with minimal lift |
| Full Card (Level 3) | Ring + Subtle + `rgba(0,0,0,0.04) 0px 8px 8px -8px` + inner `#fafafa` ring | Featured cards, highlighted panels |
| Focus (Accessibility) | `2px solid hsla(212, 100%, 48%, 1)` outline | Keyboard focus on all interactive elements |
**Shadow Philosophy**: Vercel has arguably the most sophisticated shadow system in modern web design. Rather than using shadows for elevation in the traditional Material Design sense, Vercel uses multi-value shadow stacks where each layer has a distinct architectural purpose: one creates the "border" (0px spread, 1px), another adds ambient softness (2px blur), another handles depth at distance (8px blur with negative spread), and an inner ring (`#fafafa`) creates the subtle highlight that makes the card "glow" from within. This layered approach means cards feel built, not floating.
### Decorative Depth
- Hero gradient: soft, pastel multi-color gradient wash behind hero content (barely visible, atmospheric)
- Section borders: `1px solid #171717` (full dark line) between major sections
- No background color variation — depth comes entirely from shadow layering and border contrast
## 7. Do's and Don'ts
### Do
- Use Geist Sans with aggressive negative letter-spacing at display sizes (-2.4px to -2.88px at 48px)
- Use shadow-as-border (`0px 0px 0px 1px rgba(0,0,0,0.08)`) instead of traditional CSS borders
- Enable `"liga"` on all Geist text — ligatures are structural, not optional
- Use the three-weight system: 400 (body), 500 (UI), 600 (headings)
- Apply workflow accent colors (Red/Pink/Blue) only in their workflow context
- Use multi-layer shadow stacks for cards (border + elevation + ambient + inner highlight)
- Keep the color palette achromatic — grays from `#171717` to `#ffffff` are the system
- Use `#171717` instead of `#000000` for primary text — the micro-warmth matters
### Don't
- Don't use positive letter-spacing on Geist Sans — it's always negative or zero
- Don't use weight 700 (bold) on body text — 600 is the maximum, used only for headings
- Don't use traditional CSS `border` on cards — use the shadow-border technique
- Don't introduce warm colors (oranges, yellows, greens) into the UI chrome
- Don't apply the workflow accent colors (Ship Red, Preview Pink, Develop Blue) decoratively
- Don't use heavy shadows (> 0.1 opacity) — the shadow system is whisper-level
- Don't increase body text letter-spacing — Geist is designed to run tight
- Don't use pill radius (9999px) on primary action buttons — pills are for badges/tags only
- Don't skip the inner `#fafafa` ring in card shadows — it's the glow that makes the system work
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <400px | Tight single column, minimal padding |
| Mobile | 400600px | Standard mobile, stacked layout |
| Tablet Small | 600768px | 2-column grids begin |
| Tablet | 7681024px | Full card grids, expanded padding |
| Desktop Small | 10241200px | Standard desktop layout |
| Desktop | 12001400px | Full layout, maximum content width |
| Large Desktop | >1400px | Centered, generous margins |
### Touch Targets
- Buttons use comfortable padding (8px16px vertical)
- Navigation links at 14px with adequate spacing
- Pill badges have 10px horizontal padding for tap targets
- Mobile menu toggle uses 50% radius circular button
### Collapsing Strategy
- Hero: display 48px → scales down, maintains negative tracking proportionally
- Navigation: horizontal links + CTAs → hamburger menu
- Feature cards: 3-column → 2-column → single column stacked
- Code screenshots: maintain aspect ratio, may horizontally scroll
- Trust bar logos: grid → horizontal scroll
- Footer: multi-column → stacked single column
- Section spacing: 80px+ → 48px on mobile
### Image Behavior
- Dashboard screenshots maintain border treatment at all sizes
- Hero gradient softens/simplifies on mobile
- Product screenshots use responsive images with consistent border radius
- Full-width sections maintain edge-to-edge treatment
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Vercel Black (`#171717`)
- Background: Pure White (`#ffffff`)
- Heading text: Vercel Black (`#171717`)
- Body text: Gray 600 (`#4d4d4d`)
- Border (shadow): `rgba(0, 0, 0, 0.08) 0px 0px 0px 1px`
- Link: Link Blue (`#0072f5`)
- Focus ring: Focus Blue (`hsla(212, 100%, 48%, 1)`)
### Example Component Prompts
- "Create a hero section on white background. Headline at 48px Geist weight 600, line-height 1.00, letter-spacing -2.4px, color #171717. Subtitle at 20px Geist weight 400, line-height 1.80, color #4d4d4d. Dark CTA button (#171717, 6px radius, 8px 16px padding) and ghost button (white, shadow-border rgba(0,0,0,0.08) 0px 0px 0px 1px, 6px radius)."
- "Design a card: white background, no CSS border. Use shadow stack: rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, #fafafa 0px 0px 0px 1px. Radius 8px. Title at 24px Geist weight 600, letter-spacing -0.96px. Body at 16px weight 400, #4d4d4d."
- "Build a pill badge: #ebf5ff background, #0068d6 text, 9999px radius, 0px 10px padding, 12px Geist weight 500."
- "Create navigation: white sticky header. Geist 14px weight 500 for links, #171717 text. Dark pill CTA 'Start Deploying' right-aligned. Shadow-border on bottom: rgba(0,0,0,0.08) 0px 0px 0px 1px."
- "Design a workflow section showing three steps: Develop (text color #0a72ef), Preview (#de1d8d), Ship (#ff5b4f). Each step: 14px Geist Mono uppercase label + 24px Geist weight 600 title + 16px weight 400 description in #4d4d4d."
### Iteration Guide
1. Always use shadow-as-border instead of CSS border — `0px 0px 0px 1px rgba(0,0,0,0.08)` is the foundation
2. Letter-spacing scales with font size: -2.4px at 48px, -1.28px at 32px, -0.96px at 24px, normal at 14px
3. Three weights only: 400 (read), 500 (interact), 600 (announce)
4. Color is functional, never decorative — workflow colors (Red/Pink/Blue) mark pipeline stages only
5. The inner `#fafafa` ring in card shadows is what gives Vercel cards their subtle inner glow
6. Geist Mono uppercase for technical labels, Geist Sans for everything else
+442
View File
@@ -0,0 +1,442 @@
# FrontendPlan.md
## Mission
Build the complete frontend for the self-hosted homelab dashboard. The frontend owns the Next.js app, shadcn UI composition, dashboard interactions, drag/drop UX, theme system, generated API client usage, frontend tests, and production-grade responsive design.
This agent may edit:
- `/frontend`
This agent must not edit `/backend`, `/db`, or `/openapi`. Read `openapi/openapi.yaml` only to generate client/types. If the API contract is missing or wrong, document the needed backend change instead of creating duplicate schemas.
## Product Context
The app is a fast, clean dashboard for home lab services. It should feel closer to Vercel, Linear, and a Nothing-style instrument panel than older dashboard tools. The first screen is the actual dashboard, not a landing page.
Core frontend responsibilities:
- Show date/time/timezones.
- Show dashboard widgets.
- Show grouped and ungrouped services.
- Add/edit/delete services with multiple URLs.
- Let user choose local/external/custom URL before opening when service has multiple URLs.
- Create/edit/collapse/delete groups.
- Drag/drop reorder groups, services, and widgets.
- Support dark and light mode.
- Use generated API types/client only.
## Stack
- Next.js App Router.
- React with TypeScript strict mode.
- Tailwind CSS.
- shadcn/ui.
- `@dnd-kit` for drag/drop.
- `@tanstack/react-query` for server state.
- `openapi-typescript` for generated types.
- `openapi-fetch` or a thin generated client wrapper for requests.
- Playwright for end-to-end tests.
- MSW or local fixtures while backend is incomplete.
## Repository Layout
Use this frontend layout:
```text
frontend/
app/
layout.tsx
page.tsx
globals.css
components/
dashboard/
groups/
services/
widgets/
shell/
ui/
lib/
api/
mocks/
theme/
utils/
hooks/
tests/
e2e/
```
Intent:
- `app`: Next.js routes and global shell.
- `components/ui`: shadcn components only.
- `components/dashboard`: dashboard composition and layout.
- `components/groups`: group panels, group controls, group reorder.
- `components/services`: service cards, service form, URL picker.
- `components/widgets`: clock, image, Pi-hole widget cards, widget form.
- `lib/api`: generated client and React Query hooks.
- `lib/mocks`: fixtures/MSW handlers matching OpenAPI.
- `lib/theme`: theme constants and helpers.
## API Contract
Frontend consumes `../openapi/openapi.yaml`.
Generate types/client through scripts:
```json
{
"scripts": {
"api:generate": "openapi-typescript ../openapi/openapi.yaml -o lib/api/schema.ts",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test"
}
}
```
Rules:
- Do not hand-write API response types.
- All API DTOs come from generated OpenAPI types.
- UI view models may exist, but must be derived from generated types.
- If backend is unavailable, use MSW fixtures shaped exactly like generated types.
- API base URL comes from `NEXT_PUBLIC_API_BASE_URL`, default `http://localhost:8080`.
Expected resources:
```text
Dashboard
Group
Service
ServiceUrl
WidgetInstance
WidgetData
AssetFile
ErrorResponse
```
Required frontend API hooks:
```text
useDashboard()
useCreateGroup()
useUpdateGroup()
useDeleteGroup()
useCreateService()
useUpdateService()
useDeleteService()
useUpdateLayout()
useUploadIcon()
useCreateWidget()
useUpdateWidget()
useDeleteWidget()
useWidgetData(widgetId)
useRefreshWidget(widgetId)
```
Mutation behavior:
- Optimistically update layout reorder where safe.
- Roll back reorder on API failure.
- For CRUD forms, close dialog only after success.
- Render inline errors as `[ERROR: message]`.
- Avoid toast dependency for core flows; inline status preferred.
## Design Direction
Primary design direction:
- Dark-first dashboard.
- Light mode complete and polished.
- Vercel-inspired structure: restrained cards, shadow-as-border, tight typography.
- Nothing-inspired accents: OLED black, mono uppercase labels, red signal accent only when meaningful.
Fonts:
- Load `Geist` and `Geist_Mono` with `next/font/google`.
- Optional `Doto` only for large clock/hero metric. Do not use Doto for body text.
Visual tokens:
- Background dark: near/OLED black.
- Background light: off-white/white.
- Text dark mode: white/soft gray hierarchy.
- Text light mode: `#171717` and neutral grays.
- Radius: 6px for controls, 8px for cards, no large rounded card stacks.
- Borders: use shadow-as-border or tokenized border, not heavy outlines.
- Accent: red only for alert/active signal/destructive state.
- Icons: monoline Lucide via shadcn project icon library.
Nothing/Vercel constraints:
- No gradients in app chrome.
- No decorative blobs/orbs.
- No emojis.
- No nested cards.
- No large marketing hero.
- No skeleton-only loading screens; prefer `[LOADING...]` or compact shadcn loading state.
- No visible instructional paragraphs explaining obvious UI.
- Use strong spacing and hierarchy instead of decorative containers.
## shadcn Rules
Use shadcn components before custom markup:
- Button
- Card
- Dialog
- Sheet
- Input
- Select
- Switch
- Tabs
- Badge
- DropdownMenu
- Tooltip
- Collapsible
- Alert
- Empty
- Separator
- ScrollArea
- Field / FieldGroup where available
Rules:
- Forms use `FieldGroup` and `Field` patterns.
- Dialogs, Sheets, and Drawers always have titles.
- Buttons with icons use `data-icon`.
- Use `gap-*`, never `space-x-*` or `space-y-*`.
- Use semantic Tailwind tokens, not random raw colors in component classes.
- Use `cn()` for conditional class names.
- Use shadcn `Empty` for no services/no groups.
- Use shadcn `Alert` for blocking errors.
## Screens and Components
### Dashboard Page
The first viewport is the dashboard.
Layout:
- Top shell with app name, current date/time, theme toggle, add button.
- Widget strip below header.
- Service area below widgets.
- Groups render as collapsible sections.
- Ungrouped services render in their own section when present.
- Empty dashboard renders a minimal empty state with primary add action.
Desktop:
- Max width around 1200-1400px.
- Dense but calm grid.
- Service cards use responsive columns.
Mobile:
- Single-column service grid.
- Add/edit forms use full-screen or near-full-height Sheet/Dialog.
- Drag handles must remain tappable.
### Service Cards
Show:
- Icon or generated initials fallback.
- Service name.
- URL kind badges or compact count.
- Optional primary URL label.
- Menu for edit/delete/move.
Click behavior:
- If service has one URL, open it directly in same tab or new tab based on app setting/default.
- If service has multiple URLs, open URL picker dialog.
- URL picker lists label, kind, and hostname.
Drag behavior:
- Drag service within group.
- Drag service across groups.
- Drag service to ungrouped.
- Show clear drop target state.
- Persist through `PUT /api/v1/layout`.
### Service Form
Fields:
- Name.
- Icon mode: icon URL or upload file.
- URLs dynamic list:
- label
- kind: local, external, custom
- URL
- primary toggle
- Group select.
Validation:
- Name required.
- At least one URL required.
- URL must be absolute `http` or `https`.
- Exactly one primary URL in UI; if user never chooses, first is primary.
- Show field-level errors, not generic banners only.
### Groups
Group section includes:
- Name.
- Service count.
- Collapse/expand control.
- Drag handle.
- Menu for rename/delete.
Behavior:
- Collapsed state persists via group PATCH.
- Empty group can be deleted.
- Non-empty group delete should ask user to move services to ungrouped, matching backend option.
- Group reorder persists through layout endpoint.
### Widgets
Widget strip supports:
- Clock widget.
- Image widget.
- Pi-hole widget.
Clock:
- Shows large time and date.
- Supports configured timezones when backend exposes config.
- Uses Geist Mono or optional Doto for display moment.
Image:
- Shows configured image in restrained card.
- Optional link click.
- No decorative cropping that hides important content.
Pi-hole:
- Shows status, blocked count, query count, percent blocked.
- Loading: `[LOADING...]`.
- Error: `[ERROR: ...]`.
- Stale data: show data with compact stale label.
- Manual refresh button.
Widget management:
- Add/edit widget Sheet.
- Widget type select.
- Type-specific config fields.
- Widget drag reorder persisted through layout endpoint.
## State Management
Use React Query for server state:
- Dashboard query is primary source for layout.
- Widget data can be separate query per widget.
- Invalidate dashboard after CRUD mutations.
- Use optimistic mutation for layout reorder.
Local UI state:
- Dialog open/closed.
- Drag active item.
- Theme.
- Form draft state.
Do not mirror entire dashboard into global client state unless needed for drag preview. If local reorder state is needed, keep it scoped to dashboard components and reconcile from query data.
## Accessibility
Required:
- Keyboard focus visible on all controls.
- Dialog title for every Dialog/Sheet.
- Service cards keyboard-accessible.
- Drag/drop has keyboard fallback or at least accessible controls for move up/down.
- Form fields have labels and error messages.
- Theme toggle has accessible label.
- Icon-only buttons have labels/tooltips.
- Color is not only signal for URL kind or errors.
## Testing Plan
Unit/component tests:
- Service form validates required name and URL.
- Dynamic URL rows add/remove correctly.
- URL picker opens for multi-URL service.
- Group collapse button updates state.
- Widget cards render loading/error/stale/data states.
Playwright tests:
- Dashboard loads fixture data.
- Add service with two URLs.
- Click multi-URL service and choose local/external URL.
- Create group and move service into group.
- Collapse group and confirm persisted state after reload.
- Drag reorder services and verify layout mutation.
- Toggle dark/light mode.
- Pi-hole widget shows data and refresh button.
Build checks:
```bash
npm run api:generate
npm run typecheck
npm run lint
npm run build
npm run test:e2e
```
Use the package manager chosen during app scaffold. If using pnpm, scripts remain same but commands run as `pnpm ...`.
## Implementation Steps
1. Scaffold Next.js App Router project in `/frontend` with TypeScript and Tailwind.
2. Initialize shadcn/ui and install required base components.
3. Add API generation script from `../openapi/openapi.yaml`.
4. Create generated client wrapper and React Query provider.
5. Build MSW/fixture data matching OpenAPI.
6. Implement theme tokens, fonts, dark/light modes, and shell.
7. Build dashboard page with fixture data.
8. Build service cards and URL picker.
9. Build add/edit service form with dynamic URLs and icon upload field.
10. Build group sections, collapse, menus, and group forms.
11. Add drag/drop for services, groups, and widgets.
12. Build widgets: clock, image, Pi-hole.
13. Wire all CRUD and layout mutations to API client.
14. Add tests and responsive polish.
15. Run build/typecheck/e2e.
## Acceptance Criteria
- `/frontend` builds successfully.
- Frontend generates types from `../openapi/openapi.yaml`.
- No hand-written API DTO duplication.
- Dashboard works with MSW fixtures before backend is ready.
- Dashboard works against backend API once available by changing `NEXT_PUBLIC_API_BASE_URL`.
- Add/edit/delete service flows work.
- Multiple URL picker works.
- Groups can be created, collapsed, reordered, and deleted safely.
- Drag/drop persists through layout API.
- Clock, image, and Pi-hole widgets render expected states.
- Dark and light modes are polished.
- Mobile and desktop layouts do not overlap or clip text.
## Parallel Coordination
- Frontend can start with fixtures while backend builds API.
- Do not invent contract fields outside OpenAPI.
- If UI needs a new field, request OpenAPI change from backend agent.
- Regenerate API client after any OpenAPI update.
- Keep all frontend work isolated inside `/frontend`.
+44
View File
@@ -0,0 +1,44 @@
BACKEND_DIR := backend
DATABASE_URL ?= postgres://dash:dash@localhost:5432/dash?sslmode=disable
MIGRATIONS_DIR := db/migrations
.PHONY: dev dev-down dev-logs backend-dev backend-test db-migrate-up db-migrate-down openapi-validate sqlc docker-up
# Start entire app in Docker (postgres + backend + frontend)
dev:
docker compose up --build -d
@echo "App starting..."
@echo "Frontend: http://localhost:3000"
@echo "Backend API: http://localhost:8080"
@echo "Run 'make dev-logs' to see logs"
# Stop the Docker app
dev-down:
docker compose down
# View logs from all services
dev-logs:
docker compose logs -f
# Legacy docker-up (same as dev)
docker-up:
docker compose up --build
# Local backend development (requires local postgres)
backend-dev:
cd $(BACKEND_DIR) && APP_ENV=development DATABASE_URL="$(DATABASE_URL)" DATA_DIR=../data go run ./cmd/server
backend-test:
cd $(BACKEND_DIR) && go test ./...
db-migrate-up:
go run github.com/pressly/goose/v3/cmd/goose@latest -dir $(MIGRATIONS_DIR) postgres "$(DATABASE_URL)" up
db-migrate-down:
go run github.com/pressly/goose/v3/cmd/goose@latest -dir $(MIGRATIONS_DIR) postgres "$(DATABASE_URL)" down
openapi-validate:
npx --yes @redocly/cli@latest lint openapi/openapi.yaml
sqlc:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest generate
+73
View File
@@ -0,0 +1,73 @@
# Homelab Dashboard Project Plan
## Summary
Create `project.md` as full build spec for self-hosted homelab dashboard: Next.js React frontend, shadcn/ui, Go Gin backend, PostgreSQL via Docker Compose. Core v1: service cards, groups, drag/drop ordering, multi-URL launch picker, no auth, dark-first Vercel/Nothing-inspired UI, extensible widget framework with one real Pi-hole widget.
## Key Changes
- Scaffold monorepo:
- `/frontend`: Next.js App Router, React, TypeScript strict, Tailwind, shadcn/ui.
- `/backend`: Go + Gin API, zap logging, OpenAPI contract.
- `/db`: PostgreSQL migrations with goose, typed queries with sqlc.
- `/deploy`: Docker Compose for frontend, backend, Postgres.
- API source of truth: OpenAPI spec generates frontend TypeScript client/types.
- No authentication in v1; assume trusted LAN/self-hosted deployment.
- Persist:
- services
- service URLs marked `local` / `external` / custom label
- icon URL or uploaded icon file reference
- groups
- group collapsed state
- drag/drop ordering
- dashboard widget instances/settings
- UI:
- Dark-first, light mode also supported.
- Vercel base: Geist Sans, Geist Mono, white/black neutrals, shadow-as-border, 6-8px radius.
- Nothing accent: OLED dark mode, mono uppercase labels, red signal accent, sparse dot-grid detail.
- Fonts loaded through `next/font/google`: `Geist`, `Geist_Mono`; optional `Doto` only for clock/hero widget if used.
- shadcn components:
- Button, Card, Dialog, Sheet, Input, Select, Switch, Tabs, Badge, DropdownMenu, Tooltip, Collapsible, Alert, Empty, Separator, ScrollArea.
- Drag/drop via `@dnd-kit`.
- Forms use shadcn `FieldGroup` / `Field` patterns.
## Product Behavior
- Dashboard opens to time/date/timezone strip, widget row, grouped service grid.
- `+` action opens add-service dialog:
- name
- icon URL or uploaded icon
- one or more URLs
- URL label/type: local, external, custom
- optional group
- Clicking service:
- one URL: open directly
- multiple URLs: show picker dialog, then open chosen URL
- Groups:
- create, rename, delete empty group
- collapse/expand
- drag groups
- drag services within and across groups
- Widgets:
- v1 includes widget framework plus Pi-hole adapter.
- Pi-hole widget stores base URL/API token locally in backend DB.
- Widget failures render inline `[ERROR: ...]`, no toast spam.
- Future adapters: AdGuard, Immich, custom image/status widgets.
## Test Plan
- Backend:
- Go unit tests for service/group ordering, URL validation, widget config validation.
- API integration tests against test Postgres.
- Migration up/down check with goose.
- Frontend:
- Component tests for service form, URL picker, group collapse.
- Playwright flow: add service, add multiple URLs, choose URL, reorder cards, collapse group, toggle theme.
- Accessibility check: keyboard focus, dialog titles, labels, contrast.
- Build:
- `docker compose up` starts full stack.
- OpenAPI generation succeeds.
- Frontend typecheck and backend tests pass.
## Assumptions
- Use Next.js React, not SolidJS, because user wants to try Next.js and remain React-focused.
- Use PostgreSQL Docker, not SQLite, per chosen TDvorak default.
- Start visual design dark-first; light mode remains first-class.
- v1 ships no auth and is intended for trusted self-hosted network.
- `project.md` will be written with this plan when execution/mutation is allowed.
+150
View File
@@ -0,0 +1,150 @@
# 🏠 Dash
> *Your services, organized beautifully.*
Hey there! 👋 This is my personal homelab dashboard - built because I wanted something cleaner than my messy bookmarks folder. It's heavily inspired by CasaOS but with my own twist on things.
## Why I Built This
I got tired of:
- Forgetting which port my Jellyfin was on
- Bookmark folders that grew out of control
- Not knowing if my services were actually running
- Dashboards that felt cluttered from day one
So I made something that starts **completely empty** and lets you build your perfect setup, piece by piece.
## What Makes It Different
### 🎨 Three Moods, Not Just Themes
| Light | Dark | CasaOS |
|-------|------|--------|
| Clean & crisp for daytime | Easy on the eyes at night | Glass panels with ambient gradients |
Switch anytime from the header dropdown.
### 🚀 Drag, Drop, Done
Organize apps however you want:
- Drag between groups
- Reorder within groups
- Collapse groups you don't need right now
- Grid view for quick access, list view for details
### 📊 Widgets That Actually Matter
Not bloat - just the stuff I check daily:
- **Clock** - Multiple timezones (great for checking server times vs local)
- **Pi-hole** - "Are ads being blocked?" at a glance
- **Memos** - Recent notes so I don't forget what I was doing
- **Immich** - Photo stats (because why not)
### The Empty Canvas Philosophy
Most dashboards assault you with demo data. This one doesn't.
First launch? Clean slate. Add what you need, when you need it. Your dashboard should reflect *your* homelab, not someone else's idea of what it should look like.
## Tech Stack
**Backend:** Go + Gin + PostgreSQL + sqlc
**Frontend:** Next.js 15 + React 19 + Tailwind + shadcn/ui
**Why:** Fast, type-safe, and I actually enjoy working with it
## Getting Started
### The Easy Way (Docker)
```bash
git clone https://github.com/tdvorak/Dash.git
cd Dash
cp .env.example .env
docker compose up --build
```
Then open http://localhost:3000
### The Developer Way
**Backend:**
```bash
cd backend
make db-migrate-up # needs Postgres running
make backend-dev # hot reload on :8080
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # Next.js on :3000
```
## Setting Up Widgets
### Pi-hole
```json
{
"baseUrl": "http://your-pihole-ip",
"apiToken": "your-token-here"
}
```
Works with both v6 (session auth) and legacy API.
### Memos
```json
{
"baseUrl": "https://memos.yourdomain.com",
"apiToken": "your-token",
"pageSize": 5
}
```
### Immich
```json
{
"baseUrl": "https://immich.yourdomain.com",
"apiToken": "your-api-key"
}
```
## Project Structure
```
Dash/
├── backend/ # Go REST API
│ ├── cmd/server/
│ ├── internal/
│ │ ├── httpapi/ # HTTP handlers
│ │ ├── store/ # Database layer (sqlc)
│ │ └── config/
│ └── go.mod
├── frontend/ # Next.js app
│ ├── app/
│ ├── components/
│ │ ├── dashboard/
│ │ ├── widgets/ # Clock, Pi-hole, etc.
│ │ ├── services/ # App cards
│ │ └── groups/ # Group sections
│ └── lib/
├── db/migrations/ # Goose migrations
└── openapi/ # API spec
```
## Security Reality Check
v1 is built for **trusted LANs**. No auth, no sessions, no complexity.
For production:
- Put it behind Authelia/Authentik
- Or use a VPN
- Or accept that your homelab is probably fine without auth anyway ¯\_(ツ)_/¯
## License
MIT - use it, fork it, make it yours. Would love to see what you build!
---
+27
View File
@@ -0,0 +1,27 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY backend/go.mod backend/go.sum* ./backend/
WORKDIR /src/backend
RUN go mod download
WORKDIR /src
COPY backend ./backend
COPY db ./db
WORKDIR /src/backend
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/dash-backend ./cmd/server
FROM alpine:3.22
RUN adduser -D -H -u 10001 app && apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=build /out/dash-backend /app/dash-backend
COPY --from=build /src/db /app/db
RUN mkdir -p /data && chown -R app:app /data /app
USER app
ENV HTTP_ADDR=:8080
ENV MIGRATIONS_DIR=/app/db/migrations
EXPOSE 8080
CMD ["/app/dash-backend"]
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"database/sql"
"errors"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"go.uber.org/zap"
"dash/backend/internal/config"
"dash/backend/internal/httpapi"
"dash/backend/internal/store"
)
func main() {
log, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer func() { _ = log.Sync() }()
cfg, err := config.Load()
if err != nil {
log.Fatal("load config", zap.Error(err))
}
if cfg.AppEnv == "development" {
devLog, err := zap.NewDevelopment()
if err == nil {
log = devLog
}
}
if err := os.MkdirAll(cfg.IconDir(), 0o755); err != nil {
log.Fatal("create data dirs", zap.Error(err))
}
if err := runMigrations(cfg); err != nil {
log.Fatal("run migrations", zap.Error(err))
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatal("connect database", zap.Error(err))
}
defer pool.Close()
st := store.New(pool)
router := httpapi.NewRouter(cfg, log, st)
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Info("backend listening", zap.String("addr", cfg.HTTPAddr))
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal("server failed", zap.Error(err))
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error("server shutdown failed", zap.Error(err))
}
}
func runMigrations(cfg config.Config) error {
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.Up(db, cfg.MigrationsDir)
}
+52
View File
@@ -0,0 +1,52 @@
module dash/backend
go 1.23.0
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/pressly/goose/v3 v3.26.0
go.uber.org/zap v1.27.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+130
View File
@@ -0,0 +1,130 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+116
View File
@@ -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"
)
+98
View File
@@ -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
}
+432
View File
@@ -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),
)
}
}
+190
View File
@@ -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
}
+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)
}
}
+67
View File
@@ -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"))
}
+72
View File
@@ -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")
}
}
+491
View File
@@ -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])
}
+93
View File
@@ -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
}
+46
View File
@@ -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)
}
}
Executable
BIN
View File
Binary file not shown.
+111
View File
@@ -0,0 +1,111 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
sort_order integer NOT NULL,
collapsed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE asset_files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
original_name text NOT NULL,
stored_name text NOT NULL,
mime_type text NOT NULL,
size_bytes integer NOT NULL,
public_path text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE services (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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 integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE service_urls (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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 integer NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE widget_instances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type text NOT NULL CHECK (type IN ('clock', 'image', 'pihole')),
title text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL,
config jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE widget_cache (
widget_id uuid PRIMARY KEY 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 DEFAULT now()
);
CREATE INDEX groups_sort_order_idx ON groups(sort_order);
CREATE INDEX services_group_sort_order_idx ON services(group_id, sort_order);
CREATE INDEX services_ungrouped_sort_order_idx ON services(sort_order) WHERE group_id IS NULL;
CREATE INDEX service_urls_service_sort_order_idx ON service_urls(service_id, sort_order);
CREATE INDEX widget_instances_enabled_sort_order_idx ON widget_instances(enabled, sort_order);
CREATE INDEX asset_files_created_at_idx ON asset_files(created_at);
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER groups_set_updated_at BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER services_set_updated_at BEFORE UPDATE ON services
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER service_urls_set_updated_at BEFORE UPDATE ON service_urls
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER widget_instances_set_updated_at BEFORE UPDATE ON widget_instances
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER widget_cache_set_updated_at BEFORE UPDATE ON widget_cache
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- +goose Down
DROP TRIGGER IF EXISTS widget_cache_set_updated_at ON widget_cache;
DROP TRIGGER IF EXISTS widget_instances_set_updated_at ON widget_instances;
DROP TRIGGER IF EXISTS service_urls_set_updated_at ON service_urls;
DROP TRIGGER IF EXISTS services_set_updated_at ON services;
DROP TRIGGER IF EXISTS groups_set_updated_at ON groups;
DROP FUNCTION IF EXISTS set_updated_at();
DROP TABLE IF EXISTS widget_cache;
DROP TABLE IF EXISTS widget_instances;
DROP TABLE IF EXISTS service_urls;
DROP TABLE IF EXISTS services;
DROP TABLE IF EXISTS asset_files;
DROP TABLE IF EXISTS groups;
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE widget_instances DROP CONSTRAINT IF EXISTS widget_instances_type_check;
ALTER TABLE widget_instances ADD CONSTRAINT widget_instances_type_check CHECK (type IN ('clock', 'image', 'pihole', 'memos'));
-- +goose Down
ALTER TABLE widget_instances DROP CONSTRAINT IF EXISTS widget_instances_type_check;
ALTER TABLE widget_instances ADD CONSTRAINT widget_instances_type_check CHECK (type IN ('clock', 'image', 'pihole'));
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE widget_instances DROP CONSTRAINT IF EXISTS widget_instances_type_check;
ALTER TABLE widget_instances ADD CONSTRAINT widget_instances_type_check CHECK (type IN ('clock', 'image', 'pihole', 'memos', 'immich'));
-- +goose Down
ALTER TABLE widget_instances DROP CONSTRAINT IF EXISTS widget_instances_type_check;
ALTER TABLE widget_instances ADD CONSTRAINT widget_instances_type_check CHECK (type IN ('clock', 'image', 'pihole', 'memos'));
+19
View File
@@ -0,0 +1,19 @@
-- name: ListGroups :many
SELECT id::text, name, sort_order, collapsed, created_at, updated_at
FROM groups
ORDER BY sort_order ASC, created_at ASC;
-- 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;
-- 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;
-- 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;
+48
View File
@@ -0,0 +1,48 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: dash
POSTGRES_USER: dash
POSTGRES_PASSWORD: dash
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dash -d dash"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: .
dockerfile: backend/Dockerfile
env_file:
- .env.example
environment:
DATABASE_URL: postgres://dash:dash@postgres:5432/dash?sslmode=disable
DATA_DIR: /data
ports:
- "8080:8080"
volumes:
- backend-data:/data
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres-data:
backend-data:
+36
View File
@@ -0,0 +1,36 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/
+11
View File
@@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "app/globals.css",
"tailwindFunctions": ["cn", "cva"]
}
+21
View File
@@ -0,0 +1,21 @@
# Next.js template
This is a Next.js template with shadcn/ui.
## Adding components
To add components to your app, run the following command:
```bash
npx shadcn@latest add button
```
This will place the ui components in the `components` directory.
## Using components
To use the components in your app, import them as follows:
```tsx
import { Button } from "@/components/ui/button";
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+32
View File
@@ -0,0 +1,32 @@
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
import { ThemeProvider } from "@/components/theme-provider"
const fontSans = Geist({
subsets: ["latin"],
variable: "--font-sans",
})
const fontMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-mono",
})
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased`}
>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button"
export default function Page() {
return (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
</div>
<div className="font-mono text-xs text-muted-foreground">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
)
}
View File
+71
View File
@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<ThemeHotkey />
{children}
</NextThemesProvider>
)
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT"
)
}
function ThemeHotkey() {
const { resolvedTheme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.repeat) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.toLowerCase() !== "d") {
return
}
if (isTypingTarget(event.target)) {
return
}
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [resolvedTheme, setTheme])
return null
}
export { ThemeProvider }
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
View File
View File
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig
+6695
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "next-app",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.1.7",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",
"eslint-config-next": "16.1.7",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"postcss": "^8",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config
View File
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+573
View File
@@ -0,0 +1,573 @@
openapi: 3.1.0
info:
title: Dash Homelab Backend API
version: 0.1.0
license:
name: Private
identifier: LicenseRef-Private
servers:
- url: /
security: []
tags:
- name: Health
description: Process and dependency health checks.
- name: Dashboard
description: Render-ready dashboard data.
- name: Groups
description: Dashboard service groups.
- name: Services
description: Service cards and launch URLs.
- name: Layout
description: Drag/drop ordering persistence.
- name: Assets
description: Uploaded service icon files.
- name: Widgets
description: Dashboard widget instances and data.
paths:
/health:
get:
tags: [Health]
operationId: health
summary: Check backend health
responses:
"200":
description: Backend and database are healthy.
content:
application/json:
schema:
type: object
required: [ok]
properties:
ok: { type: boolean }
"503": { $ref: "#/components/responses/InternalError" }
"400": { $ref: "#/components/responses/ValidationError" }
/api/v1/dashboard:
get:
tags: [Dashboard]
operationId: getDashboard
summary: Get dashboard
responses:
"200":
description: Full dashboard in render order.
content:
application/json:
schema: { $ref: "#/components/schemas/Dashboard" }
"500": { $ref: "#/components/responses/InternalError" }
"400": { $ref: "#/components/responses/ValidationError" }
/api/v1/groups:
get:
tags: [Groups]
operationId: listGroups
summary: List groups
responses:
"200":
description: Groups in sort order.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Group" }
"400": { $ref: "#/components/responses/ValidationError" }
post:
tags: [Groups]
operationId: createGroup
summary: Create group
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateGroupRequest" }
responses:
"201":
description: Created group.
content:
application/json:
schema: { $ref: "#/components/schemas/Group" }
"400": { $ref: "#/components/responses/ValidationError" }
/api/v1/groups/{groupId}:
parameters:
- $ref: "#/components/parameters/GroupId"
get:
tags: [Groups]
operationId: getGroup
summary: Get group
responses:
"200":
description: Group.
content:
application/json:
schema: { $ref: "#/components/schemas/Group" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [Groups]
operationId: patchGroup
summary: Patch group
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PatchGroupRequest" }
responses:
"200":
description: Updated group.
content:
application/json:
schema: { $ref: "#/components/schemas/Group" }
"400": { $ref: "#/components/responses/ValidationError" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [Groups]
operationId: deleteGroup
summary: Delete group
parameters:
- name: moveServicesToUngrouped
in: query
schema: { type: boolean, default: false }
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
/api/v1/services:
get:
tags: [Services]
operationId: listServices
summary: List services
responses:
"200":
description: Services with URLs.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Service" }
"400": { $ref: "#/components/responses/ValidationError" }
post:
tags: [Services]
operationId: createService
summary: Create service
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceRequest" }
responses:
"201":
description: Created service.
content:
application/json:
schema: { $ref: "#/components/schemas/Service" }
"400": { $ref: "#/components/responses/ValidationError" }
/api/v1/services/{serviceId}:
parameters:
- $ref: "#/components/parameters/ServiceId"
get:
tags: [Services]
operationId: getService
summary: Get service
responses:
"200":
description: Service.
content:
application/json:
schema: { $ref: "#/components/schemas/Service" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [Services]
operationId: patchService
summary: Patch service
description: Replaces service fields and URL list atomically.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceRequest" }
responses:
"200":
description: Updated service.
content:
application/json:
schema: { $ref: "#/components/schemas/Service" }
"400": { $ref: "#/components/responses/ValidationError" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [Services]
operationId: deleteService
summary: Delete service
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/layout:
put:
tags: [Layout]
operationId: putLayout
summary: Update layout
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/LayoutRequest" }
responses:
"200":
description: Updated dashboard.
content:
application/json:
schema: { $ref: "#/components/schemas/Dashboard" }
"400": { $ref: "#/components/responses/ValidationError" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/assets/icons:
post:
tags: [Assets]
operationId: uploadIcon
summary: Upload icon
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"201":
description: Uploaded icon asset.
content:
application/json:
schema: { $ref: "#/components/schemas/AssetFile" }
"400": { $ref: "#/components/responses/ValidationError" }
"413": { $ref: "#/components/responses/UploadTooLarge" }
"415": { $ref: "#/components/responses/UnsupportedMediaType" }
/api/v1/widgets:
get:
tags: [Widgets]
operationId: listWidgets
summary: List widgets
responses:
"200":
description: Widget instances.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/WidgetInstance" }
"400": { $ref: "#/components/responses/ValidationError" }
post:
tags: [Widgets]
operationId: createWidget
summary: Create widget
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetRequest" }
responses:
"201":
description: Created widget.
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetInstance" }
"400": { $ref: "#/components/responses/ValidationError" }
/api/v1/widgets/{widgetId}:
parameters:
- $ref: "#/components/parameters/WidgetId"
get:
tags: [Widgets]
operationId: getWidget
summary: Get widget
responses:
"200":
description: Widget.
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetInstance" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [Widgets]
operationId: patchWidget
summary: Patch widget
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetRequest" }
responses:
"200":
description: Updated widget.
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetInstance" }
"400": { $ref: "#/components/responses/ValidationError" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [Widgets]
operationId: deleteWidget
summary: Delete widget
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/widgets/{widgetId}/data:
parameters:
- $ref: "#/components/parameters/WidgetId"
get:
tags: [Widgets]
operationId: getWidgetData
summary: Get widget data
responses:
"200":
description: Cached or refreshed widget data.
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetData" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/widgets/{widgetId}/refresh:
parameters:
- $ref: "#/components/parameters/WidgetId"
post:
tags: [Widgets]
operationId: refreshWidget
summary: Refresh widget
responses:
"200":
description: Refreshed widget data.
content:
application/json:
schema: { $ref: "#/components/schemas/WidgetData" }
"404": { $ref: "#/components/responses/NotFound" }
components:
parameters:
GroupId:
name: groupId
in: path
required: true
schema: { type: string, format: uuid }
ServiceId:
name: serviceId
in: path
required: true
schema: { type: string, format: uuid }
WidgetId:
name: widgetId
in: path
required: true
schema: { type: string, format: uuid }
responses:
ValidationError:
description: Validation error.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
NotFound:
description: Resource not found.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
Conflict:
description: Operation conflicts with current state.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
UploadTooLarge:
description: Uploaded file is too large.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
UnsupportedMediaType:
description: Uploaded file type is unsupported.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
InternalError:
description: Internal server error.
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
schemas:
Dashboard:
type: object
required: [groups, ungroupedServices, widgets]
properties:
groups:
type: array
items: { $ref: "#/components/schemas/Group" }
ungroupedServices:
type: array
items: { $ref: "#/components/schemas/Service" }
widgets:
type: array
items: { $ref: "#/components/schemas/WidgetInstance" }
Group:
type: object
required: [id, name, sortOrder, collapsed, services, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
sortOrder: { type: integer }
collapsed: { type: boolean }
services:
type: array
items: { $ref: "#/components/schemas/Service" }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
Service:
type: object
required: [id, groupId, name, iconUrl, iconAssetId, sortOrder, urls, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
groupId: { type: [string, "null"], format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
iconUrl: { type: [string, "null"], format: uri }
iconAssetId: { type: [string, "null"], format: uuid }
sortOrder: { type: integer }
urls:
type: array
items: { $ref: "#/components/schemas/ServiceUrl" }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
ServiceUrl:
type: object
required: [id, label, kind, url, sortOrder, isPrimary]
properties:
id: { type: string, format: uuid }
label: { type: string, minLength: 1, maxLength: 40 }
kind: { type: string, enum: [local, external, custom] }
url: { type: string, format: uri }
sortOrder: { type: integer }
isPrimary: { type: boolean }
WidgetInstance:
type: object
required: [id, type, title, enabled, sortOrder, config, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
type: { type: string, enum: [clock, image, pihole, memos, immich] }
title: { type: string, minLength: 1, maxLength: 80 }
enabled: { type: boolean }
sortOrder: { type: integer }
config: { type: object, additionalProperties: true, description: "Pi-hole apiToken is masked in responses." }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
WidgetData:
type: object
required: [widgetId, status]
properties:
widgetId: { type: string, format: uuid }
status: { type: string, enum: [fresh, stale, error] }
data: { type: object, additionalProperties: true }
error: { type: [string, "null"] }
fetchedAt: { type: [string, "null"], format: date-time }
expiresAt: { type: [string, "null"], format: date-time }
AssetFile:
type: object
required: [id, originalName, storedName, mimeType, sizeBytes, publicPath, createdAt]
properties:
id: { type: string, format: uuid }
originalName: { type: string }
storedName: { type: string }
mimeType: { type: string }
sizeBytes: { type: integer }
publicPath: { type: string }
createdAt: { type: string, format: date-time }
CreateGroupRequest:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 80 }
PatchGroupRequest:
type: object
properties:
name: { type: string, minLength: 1, maxLength: 80 }
collapsed: { type: boolean }
ServiceRequest:
type: object
required: [name, urls]
properties:
groupId: { type: [string, "null"], format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
iconUrl: { type: [string, "null"], format: uri }
iconAssetId: { type: [string, "null"], format: uuid }
urls:
type: array
minItems: 1
items: { $ref: "#/components/schemas/ServiceUrlInput" }
ServiceUrlInput:
type: object
required: [label, kind, url]
properties:
id: { type: string, format: uuid }
label: { type: string, minLength: 1, maxLength: 40 }
kind: { type: string, enum: [local, external, custom] }
url: { type: string, format: uri }
isPrimary: { type: boolean, default: false }
LayoutRequest:
type: object
required: [groupIds, widgetIds, ungroupedServiceIds, groupServices]
properties:
groupIds:
type: array
items: { type: string, format: uuid }
widgetIds:
type: array
items: { type: string, format: uuid }
ungroupedServiceIds:
type: array
items: { type: string, format: uuid }
groupServices:
type: object
additionalProperties:
type: array
items: { type: string, format: uuid }
WidgetRequest:
type: object
required: [type, title, config]
properties:
type: { type: string, enum: [clock, image, pihole, memos] }
title: { type: string, minLength: 1, maxLength: 80 }
enabled: { type: boolean, default: true }
config:
oneOf:
- $ref: "#/components/schemas/ClockWidgetConfig"
- $ref: "#/components/schemas/ImageWidgetConfig"
- $ref: "#/components/schemas/PiHoleWidgetConfig"
- $ref: "#/components/schemas/MemosWidgetConfig"
ClockWidgetConfig:
type: object
properties:
timezones:
type: array
items: { type: string }
ImageWidgetConfig:
type: object
required: [imageUrl]
properties:
imageUrl: { type: string, format: uri }
linkUrl: { type: [string, "null"], format: uri }
PiHoleWidgetConfig:
type: object
required: [baseUrl, apiToken]
properties:
baseUrl: { type: string, format: uri }
apiToken: { type: string, writeOnly: true }
MemosWidgetConfig:
type: object
required: [baseUrl, apiToken]
properties:
baseUrl: { type: string, format: uri }
apiToken: { type: string, writeOnly: true }
pageSize: { type: integer, default: 5 }
ErrorResponse:
type: object
required: [code, message, details]
properties:
code:
type: string
enum:
- validation_error
- not_found
- conflict
- upload_too_large
- unsupported_media_type
- widget_fetch_failed
- internal_error
message: { type: string }
details: { type: ["object", "null"], additionalProperties: true }
+1
View File
@@ -0,0 +1 @@
Okay, I have another simple, quick, easy project that I want to create. I always use some kind of dashboard to manage my home lab and visualize all the stuff that I am running and also what stuff I am running externally. But most of the dashboards seem like they are stuck in the past, which I don't really like. So currently, what I use is CasaOS, which isn't really used for this, but you know, I managed to make it work because when I deploy services, I, let's say, I mostly deploy on CasaOS or Dockploy, but that's not the main point. It's just the CasaOS management and the visualization is so good and easy to use that I like it really much. So here is what I want from the project. I want it stylized and simple, looking like Vercel, in the Vercel style, so ChatCN, styling, GoBackEnd and SolidJS or React frontend, really easy, modern, dark and white mode, nice to look at and easy to manage. I want drag and drop, no authentication, easy plus button to add service. At external service, so you provide the URL, the name, and the link or the file of the image. So you add these to that, it then visualizes it easily from it. Also, when we add the URL for the service, let's allow insertion of multiple URLs. So, for example, the user has local URL for when they are on the local network. Another they have, for example, external, so their domain if they are externally. Then they can save both of these, then mark it as local or external. And then when I click on the dashboard on the service, it shows a pop-up of these two and they can choose, and it takes them to the proper website. So easy like that. Make sure it's really drag and drop, reorient. Also another feature that I'm missing from QOS is grouping, so let's say I have four services, I group them into one section, it then visualizes it, I can close it, open it, reorient the whole section, make it really nice, really easy, really nice visually and the UI, UX. Also, what I want from the app, I just want it to be a lightweight. I open it, see date, time, for example, time zones, and... Let's add some random widget the user can add. For example, let's add Pi-hole, which would be great for the user. AdGuard, Image, for example, if the user are using Image. And I will later send a list of what services exactly I am using, so we can prepare a proper widget structure for other stuff that have APIs and so on. So this should be all of it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+10
View File
@@ -0,0 +1,10 @@
version: "2"
sql:
- engine: "postgresql"
queries: "db/query"
schema: "db/migrations"
gen:
go:
package: "dbgen"
out: "backend/internal/store/dbgen"
sql_package: "pgx/v5"