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