chore: add GSD codebase map with 7 analysis documents
Parallel analysis of tech stack, architecture, structure, conventions, testing patterns, integrations, and concerns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
165
.planning/codebase/ARCHITECTURE.md
Normal file
165
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
**Overall:** Monolithic Go HTTP server with embedded React SPA frontend
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Single Go binary serves both the JSON API and the static frontend assets
|
||||||
|
- All backend logic lives in one library package (`pkg/diunwebhook/`)
|
||||||
|
- SQLite database for persistence (pure-Go driver, no CGO)
|
||||||
|
- Frontend is a standalone React SPA that communicates via REST polling
|
||||||
|
- No middleware framework -- uses `net/http` standard library directly
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**HTTP Layer (Handlers):**
|
||||||
|
- Purpose: Accept HTTP requests, validate input, delegate to storage functions, return JSON responses
|
||||||
|
- Location: `pkg/diunwebhook/diunwebhook.go` (functions: `WebhookHandler`, `UpdatesHandler`, `DismissHandler`, `TagsHandler`, `TagByIDHandler`, `TagAssignmentHandler`)
|
||||||
|
- Contains: Request parsing, method checks, JSON encoding/decoding, HTTP status responses
|
||||||
|
- Depends on: Storage layer (package-level `db` and `mu` variables)
|
||||||
|
- Used by: Route registration in `cmd/diunwebhook/main.go`
|
||||||
|
|
||||||
|
**Storage Layer (SQLite):**
|
||||||
|
- Purpose: Persist and query DIUN events, tags, and tag assignments
|
||||||
|
- Location: `pkg/diunwebhook/diunwebhook.go` (functions: `InitDB`, `UpdateEvent`, `GetUpdates`; inline SQL in handlers)
|
||||||
|
- Contains: Schema creation, migrations, CRUD operations via raw SQL
|
||||||
|
- Depends on: `modernc.org/sqlite` driver, `database/sql` stdlib
|
||||||
|
- Used by: HTTP handlers in the same file
|
||||||
|
|
||||||
|
**Entry Point / Wiring:**
|
||||||
|
- Purpose: Initialize database, configure routes, start HTTP server with graceful shutdown
|
||||||
|
- Location: `cmd/diunwebhook/main.go`
|
||||||
|
- Contains: Environment variable reading, mux setup, signal handling, server lifecycle
|
||||||
|
- Depends on: `pkg/diunwebhook` (imported as `diun`)
|
||||||
|
- Used by: Docker container CMD, direct `go run`
|
||||||
|
|
||||||
|
**Frontend SPA:**
|
||||||
|
- Purpose: Display DIUN update events in an interactive dashboard with drag-and-drop grouping
|
||||||
|
- Location: `frontend/src/`
|
||||||
|
- Contains: React components, custom hooks for data fetching, TypeScript type definitions
|
||||||
|
- Depends on: Backend REST API (`/api/*` endpoints)
|
||||||
|
- Used by: Served as static files from `frontend/dist/` by the Go server
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
**Webhook Ingestion:**
|
||||||
|
|
||||||
|
1. DIUN sends `POST /webhook` with JSON payload containing image update event
|
||||||
|
2. `WebhookHandler` in `pkg/diunwebhook/diunwebhook.go` validates the `Authorization` header (if `WEBHOOK_SECRET` is set) using constant-time comparison
|
||||||
|
3. JSON body is decoded into `DiunEvent` struct; `image` field is required
|
||||||
|
4. `UpdateEvent()` acquires `mu.Lock()`, executes `INSERT OR REPLACE` into `updates` table (keyed on `image`), sets `received_at` to current time, resets `acknowledged_at` to `NULL`
|
||||||
|
5. Returns `200 OK`
|
||||||
|
|
||||||
|
**Dashboard Polling:**
|
||||||
|
|
||||||
|
1. React SPA (`useUpdates` hook in `frontend/src/hooks/useUpdates.ts`) polls `GET /api/updates` every 5 seconds
|
||||||
|
2. `UpdatesHandler` in `pkg/diunwebhook/diunwebhook.go` queries `updates` table with `LEFT JOIN` on `tag_assignments` and `tags`
|
||||||
|
3. Returns `map[string]UpdateEntry` as JSON (keyed by image name)
|
||||||
|
4. Frontend groups entries by tag, displays in `TagSection` components with `ServiceCard` children
|
||||||
|
|
||||||
|
**Acknowledge (Dismiss):**
|
||||||
|
|
||||||
|
1. User clicks acknowledge button on a `ServiceCard`
|
||||||
|
2. Frontend sends `PATCH /api/updates/{image}` via `useUpdates.acknowledge()`
|
||||||
|
3. Frontend performs optimistic update on local state
|
||||||
|
4. `DismissHandler` sets `acknowledged_at = datetime('now')` for matching image row
|
||||||
|
|
||||||
|
**Tag Management:**
|
||||||
|
|
||||||
|
1. Tags are fetched once on mount via `useTags` hook (`GET /api/tags`)
|
||||||
|
2. Create: `POST /api/tags` with `{ name }` -- tag names must be unique (409 on conflict)
|
||||||
|
3. Delete: `DELETE /api/tags/{id}` -- cascades to `tag_assignments` via FK constraint
|
||||||
|
4. Assign: `PUT /api/tag-assignments` with `{ image, tag_id }` -- `INSERT OR REPLACE`
|
||||||
|
5. Unassign: `DELETE /api/tag-assignments` with `{ image }`
|
||||||
|
6. Drag-and-drop in frontend uses `@dnd-kit/core`; `DndContext.onDragEnd` calls `assignTag()` which performs optimistic UI update then fires API call
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
|
||||||
|
- **Backend:** No in-memory state beyond the `sync.Mutex`. All data lives in SQLite. The `db` and `mu` variables are package-level globals in `pkg/diunwebhook/diunwebhook.go`.
|
||||||
|
- **Frontend:** React `useState` hooks in two custom hooks:
|
||||||
|
- `useUpdates` (`frontend/src/hooks/useUpdates.ts`): holds `UpdatesMap`, loading/error state, polling countdown
|
||||||
|
- `useTags` (`frontend/src/hooks/useTags.ts`): holds `Tag[]`, provides create/delete callbacks
|
||||||
|
- No global state library (no Redux, Zustand, etc.) -- state is passed via props from `App.tsx`
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**DiunEvent:**
|
||||||
|
- Purpose: Represents a single DIUN webhook payload (image update notification)
|
||||||
|
- Defined in: `pkg/diunwebhook/diunwebhook.go` (Go struct), `frontend/src/types/diun.ts` (TypeScript interface)
|
||||||
|
- Pattern: Direct JSON mapping between Go struct tags and TypeScript interface
|
||||||
|
|
||||||
|
**UpdateEntry:**
|
||||||
|
- Purpose: Wraps a `DiunEvent` with metadata (received timestamp, acknowledged flag, optional tag)
|
||||||
|
- Defined in: `pkg/diunwebhook/diunwebhook.go` (Go), `frontend/src/types/diun.ts` (TypeScript)
|
||||||
|
- Pattern: The API returns `map[string]UpdateEntry` keyed by image name (`UpdatesMap` type in frontend)
|
||||||
|
|
||||||
|
**Tag:**
|
||||||
|
- Purpose: User-defined grouping label for organizing images
|
||||||
|
- Defined in: `pkg/diunwebhook/diunwebhook.go` (Go), `frontend/src/types/diun.ts` (TypeScript)
|
||||||
|
- Pattern: Simple ID + name, linked to images via `tag_assignments` join table
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Go Server:**
|
||||||
|
- Location: `cmd/diunwebhook/main.go`
|
||||||
|
- Triggers: `go run ./cmd/diunwebhook/` or Docker container `CMD ["./server"]`
|
||||||
|
- Responsibilities: Read env vars (`DB_PATH`, `PORT`, `WEBHOOK_SECRET`), init DB, register routes, start HTTP server, handle graceful shutdown on SIGINT/SIGTERM
|
||||||
|
|
||||||
|
**Frontend SPA:**
|
||||||
|
- Location: `frontend/src/main.tsx`
|
||||||
|
- Triggers: Browser loads `index.html` from `frontend/dist/` (served by Go file server at `/`)
|
||||||
|
- Responsibilities: Mount React app, force dark mode (`document.documentElement.classList.add('dark')`)
|
||||||
|
|
||||||
|
**Webhook Endpoint:**
|
||||||
|
- Location: `POST /webhook` -> `WebhookHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Triggers: External DIUN instance sends webhook on image update detection
|
||||||
|
- Responsibilities: Authenticate (if secret set), validate payload, upsert event into database
|
||||||
|
|
||||||
|
## Concurrency Model
|
||||||
|
|
||||||
|
**Mutex-based serialization:**
|
||||||
|
- A single `sync.Mutex` (`mu`) in `pkg/diunwebhook/diunwebhook.go` guards all write operations to the database
|
||||||
|
- `UpdateEvent()`, `DismissHandler`, `TagsHandler` (POST), `TagByIDHandler` (DELETE), and `TagAssignmentHandler` (PUT/DELETE) all acquire `mu.Lock()` before writing
|
||||||
|
- Read operations (`GetUpdates`, `TagsHandler` GET) do NOT acquire the mutex
|
||||||
|
- SQLite connection is configured with `db.SetMaxOpenConns(1)` to prevent concurrent write issues
|
||||||
|
|
||||||
|
**HTTP Server:**
|
||||||
|
- Standard `net/http` server handles requests concurrently via goroutines
|
||||||
|
- Graceful shutdown with 15-second timeout on SIGINT/SIGTERM
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Return appropriate HTTP status codes with plain-text error messages; log errors server-side via `log.Printf`
|
||||||
|
|
||||||
|
**Backend Patterns:**
|
||||||
|
- Method validation: Return `405 Method Not Allowed` for wrong HTTP methods
|
||||||
|
- Input validation: Return `400 Bad Request` for missing/malformed fields
|
||||||
|
- Authentication: Return `401 Unauthorized` if webhook secret doesn't match
|
||||||
|
- Not found: Return `404 Not Found` when row doesn't exist (e.g., dismiss nonexistent image)
|
||||||
|
- Conflict: Return `409 Conflict` for unique constraint violations (duplicate tag name)
|
||||||
|
- Internal errors: Return `500 Internal Server Error` for database failures
|
||||||
|
- Fatal startup errors: `log.Fatalf` on `InitDB` failure
|
||||||
|
|
||||||
|
**Frontend Patterns:**
|
||||||
|
- `useUpdates`: catches fetch errors, stores error message in state, displays error banner
|
||||||
|
- `useTags`: catches errors, logs to `console.error`, fails silently (no user-visible error)
|
||||||
|
- `assignTag`: uses optimistic update -- updates local state first, fires API call, logs errors to console but does not revert on failure
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Logging:** Standard library `log` package. Logs webhook receipt, decode errors, storage errors. No structured logging or log levels beyond `log.Printf` and `log.Fatalf`.
|
||||||
|
|
||||||
|
**Validation:** Manual validation in each handler. No validation library or middleware. Each handler checks HTTP method, decodes body, validates required fields individually.
|
||||||
|
|
||||||
|
**Authentication:** Optional token-based auth on webhook endpoint only. `WEBHOOK_SECRET` env var compared via `crypto/subtle.ConstantTimeCompare` against `Authorization` header. No auth on API endpoints (`/api/*`).
|
||||||
|
|
||||||
|
**CORS:** Not configured. Frontend is served from the same origin as the API, so CORS is not needed in production. Vite dev server proxies `/api` and `/webhook` to `localhost:8080`.
|
||||||
|
|
||||||
|
**Database Migrations:** Inline in `InitDB()`. Uses `CREATE TABLE IF NOT EXISTS` for initial schema and `ALTER TABLE ADD COLUMN` (error silently ignored) for adding `acknowledged_at` to existing databases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-03-23*
|
||||||
195
.planning/codebase/CONCERNS.md
Normal file
195
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Codebase Concerns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
**Global mutable state in library package:**
|
||||||
|
- Issue: The package uses package-level `var db *sql.DB`, `var mu sync.Mutex`, and `var webhookSecret string`. This makes the package non-reusable and harder to test — only one "instance" can exist per process.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 48-52)
|
||||||
|
- Impact: Cannot run multiple instances, cannot run tests in parallel safely, tight coupling to global state.
|
||||||
|
- Fix approach: Refactor to a struct-based design (e.g., `type Server struct { db *sql.DB; mu sync.Mutex; secret string }`) with methods instead of package functions. Priority: Medium.
|
||||||
|
|
||||||
|
**Module name is "awesomeProject":**
|
||||||
|
- Issue: The Go module is named `awesomeProject` (a Go IDE default placeholder), not a meaningful name like `github.com/user/diun-dashboard` or similar.
|
||||||
|
- Files: `go.mod` (line 1), `cmd/diunwebhook/main.go` (line 13), `pkg/diunwebhook/diunwebhook_test.go` (line 15)
|
||||||
|
- Impact: Confusing for contributors, unprofessional in imports, cannot be used as a Go library.
|
||||||
|
- Fix approach: Rename module to a proper path (e.g., `gitea.jeanlucmakiola.de/makiolaj/diun-dashboard`) and update all imports. Priority: Low.
|
||||||
|
|
||||||
|
**Empty error handlers on rows.Close():**
|
||||||
|
- Issue: Multiple `defer rows.Close()` wrappers silently swallow errors with empty `if err != nil {}` blocks.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 131-136, 248-253)
|
||||||
|
- Impact: Suppressed errors make debugging harder. Not functionally critical since close errors on read queries rarely matter, but the pattern is misleading.
|
||||||
|
- Fix approach: Either log the error or use a simple `defer rows.Close()` without the wrapper. Priority: Low.
|
||||||
|
|
||||||
|
**Silent error swallowing in tests:**
|
||||||
|
- Issue: Several tests do `if err != nil { return }` instead of `t.Fatal(err)`, silently passing on failure.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook_test.go` (lines 38-40, 153-154, 228-231, 287-289)
|
||||||
|
- Impact: Tests can silently pass when they should fail, hiding bugs.
|
||||||
|
- Fix approach: Replace `return` with `t.Fatalf("...: %v", err)` in all test error checks. Priority: Medium.
|
||||||
|
|
||||||
|
**Ad-hoc SQL migration strategy:**
|
||||||
|
- Issue: Schema migrations are done inline with silent `ALTER TABLE` that ignores errors: `_, _ = db.Exec("ALTER TABLE updates ADD COLUMN acknowledged_at TEXT")`.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (line 87)
|
||||||
|
- Impact: Works for a single column addition but does not scale. No version tracking, no rollback, no way to know which migrations have run.
|
||||||
|
- Fix approach: Introduce a `schema_version` table or use a lightweight migration library. Priority: Low (acceptable for current scope).
|
||||||
|
|
||||||
|
**INSERT OR REPLACE loses tag assignments:**
|
||||||
|
- Issue: `UpdateEvent()` uses `INSERT OR REPLACE` which deletes and re-inserts the row. Because `tag_assignments` references `updates.image` but there is no `ON DELETE CASCADE` on that FK (and SQLite FK enforcement may not be enabled), the assignment row becomes orphaned or the behavior is undefined.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (line 109)
|
||||||
|
- Impact: When DIUN sends a new event for an already-tracked image, the tag assignment may be lost. Users would need to re-tag images after each update.
|
||||||
|
- Fix approach: Use `INSERT ... ON CONFLICT(image) DO UPDATE SET ...` (UPSERT) instead of `INSERT OR REPLACE`, or enable FK enforcement with `PRAGMA foreign_keys = ON` and add CASCADE. Priority: High.
|
||||||
|
|
||||||
|
**Foreign key enforcement not enabled:**
|
||||||
|
- Issue: SQLite does not enforce foreign keys by default. The `tag_assignments.tag_id REFERENCES tags(id) ON DELETE CASCADE` constraint exists in the schema but `PRAGMA foreign_keys = ON` is never executed.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 58-103)
|
||||||
|
- Impact: Deleting a tag may not cascade-delete assignments, leaving orphaned rows in `tag_assignments`. The test `TestDeleteTagHandler_CascadesAssignment` may pass due to the LEFT JOIN query hiding orphans rather than them actually being deleted.
|
||||||
|
- Fix approach: Add `db.Exec("PRAGMA foreign_keys = ON")` immediately after opening the database connection in `InitDB()`. Priority: High.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**No authentication on API endpoints:**
|
||||||
|
- Risk: All API endpoints (`GET /api/updates`, `PATCH /api/updates/*`, `GET/POST /api/tags`, etc.) are completely unauthenticated. Only `POST /webhook` supports optional token auth.
|
||||||
|
- Files: `cmd/diunwebhook/main.go` (lines 38-44), `pkg/diunwebhook/diunwebhook.go` (all handler functions)
|
||||||
|
- Current mitigation: The dashboard is presumably deployed on a private network.
|
||||||
|
- Recommendations: Add optional basic auth or token auth middleware for API endpoints. At minimum, document the assumption that the dashboard should not be exposed to the public internet. Priority: Medium.
|
||||||
|
|
||||||
|
**No request body size limit on webhook:**
|
||||||
|
- Risk: `json.NewDecoder(r.Body).Decode(&event)` reads the entire body without limit. A malicious client could send a multi-GB payload causing OOM.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (line 179)
|
||||||
|
- Current mitigation: `ReadTimeout: 10 * time.Second` on the server provides some protection.
|
||||||
|
- Recommendations: Wrap `r.Body` with `http.MaxBytesReader(w, r.Body, maxSize)` (e.g., 1MB). Apply the same to `TagsHandler` POST and `TagAssignmentHandler`. Priority: Medium.
|
||||||
|
|
||||||
|
**No CORS headers configured:**
|
||||||
|
- Risk: In development the Vite proxy handles cross-origin, but if the API is accessed directly from a different origin in production, there are no CORS headers.
|
||||||
|
- Files: `cmd/diunwebhook/main.go` (lines 38-45)
|
||||||
|
- Current mitigation: SPA is served from the same origin as the API.
|
||||||
|
- Recommendations: Not urgent since the SPA and API share an origin. Document this constraint. Priority: Low.
|
||||||
|
|
||||||
|
**Webhook secret sent as raw Authorization header:**
|
||||||
|
- Risk: The webhook secret is compared against the raw `Authorization` header value, not using a standard scheme like `Bearer <token>`. This is non-standard but functionally fine.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 164-170)
|
||||||
|
- Current mitigation: Uses `crypto/subtle.ConstantTimeCompare` which prevents timing attacks.
|
||||||
|
- Recommendations: Consider supporting `Bearer <token>` format for standard compliance. Priority: Low.
|
||||||
|
|
||||||
|
## Performance Bottlenecks
|
||||||
|
|
||||||
|
**Frontend polls entire dataset every 5 seconds:**
|
||||||
|
- Problem: `GET /api/updates` returns ALL updates as a single JSON map. The query joins three tables every time. As the number of tracked images grows, both the query and the JSON payload grow linearly.
|
||||||
|
- Files: `frontend/src/hooks/useUpdates.ts` (line 4, `POLL_INTERVAL = 5000`), `pkg/diunwebhook/diunwebhook.go` (lines 120-161)
|
||||||
|
- Cause: No incremental/differential update mechanism. No pagination. No caching headers.
|
||||||
|
- Improvement path: Add `If-Modified-Since` / `ETag` support, or switch to Server-Sent Events (SSE) / WebSocket for push-based updates. Add pagination for large datasets. Priority: Medium (fine for <1000 images, problematic beyond).
|
||||||
|
|
||||||
|
**Global mutex on all write operations:**
|
||||||
|
- Problem: A single `sync.Mutex` serializes all database writes across all handlers.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (line 49, used at lines 107, 224, 281, 317, 351, 369)
|
||||||
|
- Cause: SQLite single-writer limitation addressed with a process-level mutex.
|
||||||
|
- Improvement path: `SetMaxOpenConns(1)` already serializes at the driver level, so the mutex is redundant for correctness but adds belt-and-suspenders safety. For higher throughput, consider WAL mode (`PRAGMA journal_mode=WAL`) which allows concurrent reads. Priority: Low.
|
||||||
|
|
||||||
|
**GetUpdates() not protected by mutex but reads are not serialized:**
|
||||||
|
- Problem: `GetUpdates()` does not acquire the mutex, so it can read while a write is in progress. With `SetMaxOpenConns(1)`, the driver serializes connections, but the function could block waiting for the connection.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 120-161)
|
||||||
|
- Cause: Inconsistent locking strategy — writes lock the mutex, reads do not.
|
||||||
|
- Improvement path: Either lock reads too (for consistency) or enable WAL mode and document the strategy. Priority: Low.
|
||||||
|
|
||||||
|
## Scalability Limitations
|
||||||
|
|
||||||
|
**SQLite single-file database:**
|
||||||
|
- Current capacity: Suitable for hundreds to low thousands of tracked images.
|
||||||
|
- Limit: SQLite single-writer bottleneck. No replication. Database file grows unbounded since old updates are never purged.
|
||||||
|
- Scaling path: Add a retention/cleanup mechanism for old acknowledged updates. For multi-instance deployments, migrate to PostgreSQL. Priority: Low (appropriate for the use case).
|
||||||
|
|
||||||
|
**No data retention or cleanup:**
|
||||||
|
- Current capacity: Every image update is kept forever in the `updates` table.
|
||||||
|
- Limit: Database will grow indefinitely. No mechanism to archive or delete old, acknowledged entries.
|
||||||
|
- Scaling path: Add a configurable retention period (e.g., auto-delete acknowledged entries older than N days). Priority: Medium.
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
**URL path parsing for route parameters:**
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 219, 311)
|
||||||
|
- Why fragile: Image names and tag IDs are extracted via `strings.TrimPrefix(r.URL.Path, "/api/updates/")` and `strings.TrimPrefix(r.URL.Path, "/api/tags/")`. This works but is brittle — any change to the route prefix requires changing these strings in two places (handler + `main.go`).
|
||||||
|
- Safe modification: If adding new routes or refactoring, ensure the prefix strings stay in sync with `mux.HandleFunc` registrations in `cmd/diunwebhook/main.go`.
|
||||||
|
- Test coverage: Good — `TestDismissHandler_SlashInImageName` covers the tricky case of slashes in image names.
|
||||||
|
|
||||||
|
**Optimistic UI updates without rollback:**
|
||||||
|
- Files: `frontend/src/hooks/useUpdates.ts` (lines 60-84)
|
||||||
|
- Why fragile: `assignTag()` performs an optimistic state update before the API call. If the API call fails, the UI shows the new tag but the server still has the old one. No rollback occurs — only a `console.error`.
|
||||||
|
- Safe modification: Store previous state before optimistic update, restore on error.
|
||||||
|
- Test coverage: No frontend tests exist.
|
||||||
|
|
||||||
|
**Single monolithic handler file:**
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (380 lines)
|
||||||
|
- Why fragile: All database logic, HTTP handlers, data types, and initialization live in a single file. As features are added, this file will become increasingly difficult to navigate.
|
||||||
|
- Safe modification: Split into `models.go`, `storage.go`, `handlers.go`, and `init.go` within the same package.
|
||||||
|
- Test coverage: Good test coverage for existing functionality.
|
||||||
|
|
||||||
|
## Dependencies at Risk
|
||||||
|
|
||||||
|
**No pinned dependency versions in go.mod:**
|
||||||
|
- Risk: All Go dependencies are marked `// indirect` — the project has only one direct dependency (`modernc.org/sqlite`) but it is not explicitly listed as direct.
|
||||||
|
- Files: `go.mod`
|
||||||
|
- Impact: `go mod tidy` behavior may be unpredictable. The `go.sum` file provides integrity but the intent is unclear.
|
||||||
|
- Migration plan: Run `go mod tidy` and ensure `modernc.org/sqlite` is listed without the `// indirect` comment. Priority: Low.
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
**No frontend tests:**
|
||||||
|
- Problem: Zero test files exist for the React frontend. No unit tests, no integration tests, no E2E tests.
|
||||||
|
- Blocks: Cannot verify frontend behavior automatically, cannot catch regressions in UI logic (tag assignment, acknowledge flow, drag-and-drop).
|
||||||
|
- Priority: Medium.
|
||||||
|
|
||||||
|
**No "acknowledge all" or bulk operations:**
|
||||||
|
- Problem: Users must acknowledge images one by one. No bulk dismiss, no "acknowledge all in group" action.
|
||||||
|
- Blocks: Tedious workflow when many images have updates.
|
||||||
|
- Priority: Low.
|
||||||
|
|
||||||
|
**No dark/light theme toggle (hardcoded dark):**
|
||||||
|
- Problem: The UI uses CSS variables that assume a dark theme. No toggle or system preference detection.
|
||||||
|
- Files: `frontend/src/index.css`, `frontend/src/App.tsx`
|
||||||
|
- Blocks: Users who prefer light mode have no option.
|
||||||
|
- Priority: Low.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**No tests for TagAssignmentHandler edge cases:**
|
||||||
|
- What's not tested: Assigning a non-existent image (image not in `updates` table), assigning with `tag_id: 0` or negative values, malformed JSON bodies.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook.go` (lines 333-379)
|
||||||
|
- Risk: Unknown behavior for invalid inputs.
|
||||||
|
- Priority: Low.
|
||||||
|
|
||||||
|
**No tests for concurrent tag operations:**
|
||||||
|
- What's not tested: Concurrent create/delete of tags, concurrent assign/unassign operations.
|
||||||
|
- Files: `pkg/diunwebhook/diunwebhook_test.go`
|
||||||
|
- Risk: Potential race conditions in tag operations under load.
|
||||||
|
- Priority: Low.
|
||||||
|
|
||||||
|
**No frontend test infrastructure:**
|
||||||
|
- What's not tested: All React components, hooks, drag-and-drop behavior, polling logic, optimistic updates.
|
||||||
|
- Files: `frontend/src/**/*.{ts,tsx}`
|
||||||
|
- Risk: UI regressions go undetected. The `useUpdates` hook contains business logic (polling, optimistic updates) that should be tested.
|
||||||
|
- Priority: Medium.
|
||||||
|
|
||||||
|
## Accessibility Concerns
|
||||||
|
|
||||||
|
**Drag handle only visible on hover:**
|
||||||
|
- Issue: The grip handle for drag-and-drop (`GripVertical` icon) has `opacity-0 group-hover:opacity-100`, making it invisible until hover. Keyboard-only and touch users cannot discover this interaction.
|
||||||
|
- Files: `frontend/src/components/ServiceCard.tsx` (line 96)
|
||||||
|
- Impact: Drag-and-drop is the only way to re-tag images. Users without hover capability cannot reorganize.
|
||||||
|
- Fix approach: Make the handle always visible (or visible on focus), and provide an alternative non-drag method for tag assignment (e.g., a dropdown). Priority: Medium.
|
||||||
|
|
||||||
|
**Delete button invisible until hover:**
|
||||||
|
- Issue: The tag section delete button has `opacity-0 group-hover:opacity-100`, same discoverability problem.
|
||||||
|
- Files: `frontend/src/components/TagSection.tsx` (line 62)
|
||||||
|
- Impact: Cannot discover delete action without hover.
|
||||||
|
- Fix approach: Keep visible or show on focus. Priority: Low.
|
||||||
|
|
||||||
|
**No skip-to-content link, no ARIA landmarks:**
|
||||||
|
- Issue: The page lacks skip navigation links and semantic ARIA roles beyond basic HTML.
|
||||||
|
- Files: `frontend/src/App.tsx`, `frontend/src/components/Header.tsx`
|
||||||
|
- Impact: Screen reader users must tab through the entire header to reach content.
|
||||||
|
- Fix approach: Add `<a href="#main" className="sr-only focus:not-sr-only">Skip to content</a>` and `role="main"` / `aria-label` attributes. Priority: Low.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Concerns audit: 2026-03-23*
|
||||||
198
.planning/codebase/CONVENTIONS.md
Normal file
198
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
**Go Files:**
|
||||||
|
- Package-level source files use the package name: `diunwebhook.go`
|
||||||
|
- Test files follow Go convention: `diunwebhook_test.go`
|
||||||
|
- Test-only export files: `export_test.go`
|
||||||
|
- Entry point: `main.go` inside `cmd/diunwebhook/`
|
||||||
|
|
||||||
|
**Go Functions:**
|
||||||
|
- PascalCase for exported functions: `WebhookHandler`, `UpdateEvent`, `InitDB`, `GetUpdates`
|
||||||
|
- Handler functions are named `<Noun>Handler`: `WebhookHandler`, `UpdatesHandler`, `DismissHandler`, `TagsHandler`, `TagByIDHandler`, `TagAssignmentHandler`
|
||||||
|
- Test functions use `Test<FunctionName>_<Scenario>`: `TestWebhookHandler_BadRequest`, `TestDismissHandler_NotFound`
|
||||||
|
|
||||||
|
**Go Types:**
|
||||||
|
- PascalCase structs: `DiunEvent`, `UpdateEntry`, `Tag`
|
||||||
|
- JSON tags use snake_case: `json:"diun_version"`, `json:"hub_link"`, `json:"received_at"`
|
||||||
|
|
||||||
|
**Go Variables:**
|
||||||
|
- Package-level unexported variables use short names: `mu`, `db`, `webhookSecret`
|
||||||
|
- Local variables use short idiomatic Go names: `w`, `r`, `err`, `res`, `n`, `e`
|
||||||
|
|
||||||
|
**TypeScript Files:**
|
||||||
|
- Components: PascalCase `.tsx` files: `ServiceCard.tsx`, `AcknowledgeButton.tsx`, `Header.tsx`, `TagSection.tsx`
|
||||||
|
- Hooks: camelCase with `use` prefix: `useUpdates.ts`, `useTags.ts`
|
||||||
|
- Types: camelCase `.ts` files: `diun.ts`
|
||||||
|
- Utilities: camelCase `.ts` files: `utils.ts`, `time.ts`, `serviceIcons.ts`
|
||||||
|
- UI primitives (shadcn): lowercase `.tsx` files: `badge.tsx`, `button.tsx`, `card.tsx`, `tooltip.tsx`
|
||||||
|
|
||||||
|
**TypeScript Functions:**
|
||||||
|
- camelCase for regular functions and hooks: `fetchUpdates`, `useUpdates`, `getServiceIcon`
|
||||||
|
- PascalCase for React components: `ServiceCard`, `StatCard`, `AcknowledgeButton`
|
||||||
|
- Helper functions within components use camelCase: `getInitials`, `getTag`, `getShortName`
|
||||||
|
- Event handlers prefixed with `handle`: `handleDragEnd`, `handleNewGroupSubmit`
|
||||||
|
|
||||||
|
**TypeScript Types:**
|
||||||
|
- PascalCase interfaces: `DiunEvent`, `UpdateEntry`, `Tag`, `ServiceCardProps`
|
||||||
|
- Type aliases: PascalCase: `UpdatesMap`
|
||||||
|
- Interface properties use snake_case matching the Go JSON tags: `diun_version`, `hub_link`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Go Formatting:**
|
||||||
|
- `gofmt` enforced in CI (formatting check fails the build)
|
||||||
|
- No additional Go linter (golangci-lint) configured
|
||||||
|
- `go vet` runs in CI
|
||||||
|
- Standard Go formatting: tabs for indentation
|
||||||
|
|
||||||
|
**TypeScript Formatting:**
|
||||||
|
- No ESLint or Prettier configured in the frontend
|
||||||
|
- No formatting enforcement in CI for frontend code
|
||||||
|
- Consistent 2-space indentation observed in all `.tsx` and `.ts` files
|
||||||
|
- Single quotes for strings in TypeScript
|
||||||
|
- No semicolons (observed in all frontend files)
|
||||||
|
- Trailing commas used in multi-line constructs
|
||||||
|
|
||||||
|
**TypeScript Strictness:**
|
||||||
|
- `strict: true` in `tsconfig.app.json`
|
||||||
|
- `noUnusedLocals: true`
|
||||||
|
- `noUnusedParameters: true`
|
||||||
|
- `noFallthroughCasesInSwitch: true`
|
||||||
|
- `noUncheckedSideEffectImports: true`
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
**Go Import Order:**
|
||||||
|
Standard library imports come first, followed by a blank line, then the project import using the module alias:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
diun "awesomeProject/pkg/diunwebhook"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The project module is aliased as `diun` in both `main.go` and test files
|
||||||
|
- The blank-import pattern `_ "modernc.org/sqlite"` is used for the SQLite driver in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
|
||||||
|
**TypeScript Import Order:**
|
||||||
|
1. React and framework imports (`react`, `@dnd-kit/core`)
|
||||||
|
2. Internal imports using `@/` path alias (`@/hooks/useUpdates`, `@/components/Header`)
|
||||||
|
3. Type-only imports: `import type { Tag, UpdatesMap } from '@/types/diun'`
|
||||||
|
|
||||||
|
**Path Aliases:**
|
||||||
|
- `@/` maps to `frontend/src/` (configured in `vite.config.ts` and `tsconfig.app.json`)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Go Patterns:**
|
||||||
|
- Handlers use `http.Error(w, message, statusCode)` for all error responses
|
||||||
|
- Error messages are lowercase: `"bad request"`, `"internal error"`, `"not found"`, `"method not allowed"`
|
||||||
|
- Internal errors are logged with `log.Printf` before returning HTTP 500
|
||||||
|
- Decode errors include context: `log.Printf("WebhookHandler: failed to decode request: %v", err)`
|
||||||
|
- Fatal errors in `main.go` use `log.Fatalf`
|
||||||
|
- `errors.Is()` used for sentinel error comparison (e.g., `http.ErrServerClosed`)
|
||||||
|
- String matching used for SQLite constraint errors: `strings.Contains(err.Error(), "UNIQUE")`
|
||||||
|
|
||||||
|
**TypeScript Patterns:**
|
||||||
|
- API errors throw with HTTP status: `throw new Error(\`HTTP ${res.status}\`)`
|
||||||
|
- Catch blocks use `console.error` for logging
|
||||||
|
- Error state stored in hook state: `setError(e instanceof Error ? e.message : 'Failed to fetch updates')`
|
||||||
|
- Optimistic updates used for tag assignment (update UI first, then call API)
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Framework:** Go standard `log` package
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Startup messages: `log.Printf("Listening on :%s", port)`
|
||||||
|
- Warnings: `log.Println("WARNING: WEBHOOK_SECRET not set ...")`
|
||||||
|
- Request logging on success: `log.Printf("Update received: %s (%s)", event.Image, event.Status)`
|
||||||
|
- Error logging before HTTP error response: `log.Printf("WebhookHandler: failed to store event: %v", err)`
|
||||||
|
- Handler name prefixed to log messages: `"WebhookHandler: ..."`, `"UpdatesHandler: ..."`
|
||||||
|
|
||||||
|
**Frontend:** `console.error` for API failures, no structured logging
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
**When to Comment:**
|
||||||
|
- Comments are sparse in the Go codebase
|
||||||
|
- Handler functions have short doc comments describing the routes they handle:
|
||||||
|
```go
|
||||||
|
// TagsHandler handles GET /api/tags and POST /api/tags
|
||||||
|
// TagByIDHandler handles DELETE /api/tags/{id}
|
||||||
|
// TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments
|
||||||
|
```
|
||||||
|
- Inline comments used for non-obvious behavior: `// Migration: add acknowledged_at to existing databases`
|
||||||
|
- No JSDoc/TSDoc in the frontend codebase
|
||||||
|
|
||||||
|
## Function Design
|
||||||
|
|
||||||
|
**Go Handler Pattern:**
|
||||||
|
- Each handler is a standalone `func(http.ResponseWriter, *http.Request)`
|
||||||
|
- Method checking done at the top of each handler (not via middleware)
|
||||||
|
- Multi-method handlers use `switch r.Method`
|
||||||
|
- URL path parameters extracted via `strings.TrimPrefix`
|
||||||
|
- Request bodies decoded with `json.NewDecoder(r.Body).Decode(&target)`
|
||||||
|
- Responses written with `json.NewEncoder(w).Encode(data)` or `w.WriteHeader(status)`
|
||||||
|
- Mutex (`mu`) used around write operations to SQLite
|
||||||
|
|
||||||
|
**TypeScript Hook Pattern:**
|
||||||
|
- Custom hooks return object with state and action functions
|
||||||
|
- `useCallback` wraps all action functions
|
||||||
|
- `useEffect` for side effects (polling, initial fetch)
|
||||||
|
- State updates use functional form: `setUpdates(prev => { ... })`
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
**Go Exports:**
|
||||||
|
- Single package `diunwebhook` exports all types and handler functions
|
||||||
|
- No barrel files; single source file `diunwebhook.go` contains everything
|
||||||
|
- Test helpers exposed via `export_test.go` (only visible to `_test` packages)
|
||||||
|
|
||||||
|
**TypeScript Exports:**
|
||||||
|
- Named exports for all components, hooks, and utilities
|
||||||
|
- Default export only for the root `App` component (`export default function App()`)
|
||||||
|
- Type exports use `export interface` or `export type`
|
||||||
|
- `@/components/ui/` contains shadcn primitives (`badge.tsx`, `button.tsx`, etc.)
|
||||||
|
|
||||||
|
## Git Commit Message Conventions
|
||||||
|
|
||||||
|
**Format:** Conventional Commits with bold markdown formatting
|
||||||
|
|
||||||
|
**Pattern:** `**<type>(<scope>):** <description>`
|
||||||
|
|
||||||
|
**Types observed:**
|
||||||
|
- `feat` - new features
|
||||||
|
- `fix` - bug fixes
|
||||||
|
- `docs` - documentation changes
|
||||||
|
- `chore` - maintenance tasks (deps, config)
|
||||||
|
- `refactor` - code restructuring
|
||||||
|
- `style` - UI/styling changes
|
||||||
|
- `test` - test additions
|
||||||
|
|
||||||
|
**Scopes observed:** `docs`, `compose`, `webhook`, `ci`, `ui`, `main`, `errors`, `sql`, `api`, `deps`, `stats`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
**feat(webhook):** add `WEBHOOK_SECRET` for token authentication support
|
||||||
|
**fix(ci):** improve version bump script for robustness and compatibility
|
||||||
|
**docs:** expand `index.md` with architecture, quick start, and tech stack
|
||||||
|
**chore(docs):** add `.gitignore` for `docs` and introduce `bun.lock` file
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-change commits:** Use bullet list with each item prefixed by `- **type(scope):**`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-03-23*
|
||||||
255
.planning/codebase/INTEGRATIONS.md
Normal file
255
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## APIs & External Services
|
||||||
|
|
||||||
|
**DIUN (Docker Image Update Notifier):**
|
||||||
|
- DIUN sends webhook POST requests when container image updates are detected
|
||||||
|
- Endpoint: `POST /webhook`
|
||||||
|
- SDK/Client: None (DIUN pushes to this app; this app is the receiver)
|
||||||
|
- Auth: `Authorization` header must match `WEBHOOK_SECRET` env var (when set)
|
||||||
|
- Source: `pkg/diunwebhook/diunwebhook.go` lines 163-199
|
||||||
|
|
||||||
|
## API Contracts
|
||||||
|
|
||||||
|
### Webhook Ingestion
|
||||||
|
|
||||||
|
**`POST /webhook`** - Receive a DIUN event
|
||||||
|
- Handler: `WebhookHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Auth: `Authorization` header checked via constant-time compare against `WEBHOOK_SECRET`
|
||||||
|
- Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"diun_version": "4.28.0",
|
||||||
|
"hostname": "docker-host",
|
||||||
|
"status": "new",
|
||||||
|
"provider": "docker",
|
||||||
|
"image": "registry/org/image:tag",
|
||||||
|
"hub_link": "https://hub.docker.com/r/...",
|
||||||
|
"mime_type": "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
"digest": "sha256:abc123...",
|
||||||
|
"created": "2026-03-23T10:00:00Z",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"metadata": {
|
||||||
|
"ctn_names": "container-name",
|
||||||
|
"ctn_id": "abc123",
|
||||||
|
"ctn_state": "running",
|
||||||
|
"ctn_status": "Up 2 days"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Response: `200 OK` (empty body) on success
|
||||||
|
- Errors: `401 Unauthorized`, `405 Method Not Allowed`, `400 Bad Request` (missing `image` field or invalid JSON), `500 Internal Server Error`
|
||||||
|
- Behavior: Upserts into `updates` table keyed by `image`. Replaces existing entry and resets `acknowledged_at` to NULL.
|
||||||
|
|
||||||
|
### Updates API
|
||||||
|
|
||||||
|
**`GET /api/updates`** - List all tracked image updates
|
||||||
|
- Handler: `UpdatesHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Response: `200 OK` with JSON object keyed by image name:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registry/org/image:tag": {
|
||||||
|
"event": { /* DiunEvent fields */ },
|
||||||
|
"received_at": "2026-03-23T10:00:00Z",
|
||||||
|
"acknowledged": false,
|
||||||
|
"tag": { "id": 1, "name": "production" } // or null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`PATCH /api/updates/{image}`** - Dismiss (acknowledge) an update
|
||||||
|
- Handler: `DismissHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- URL parameter: `{image}` is the full image name (URL-encoded)
|
||||||
|
- Response: `204 No Content` on success
|
||||||
|
- Errors: `405 Method Not Allowed`, `400 Bad Request`, `404 Not Found`, `500 Internal Server Error`
|
||||||
|
- Behavior: Sets `acknowledged_at = datetime('now')` on the matching row
|
||||||
|
|
||||||
|
### Tags API
|
||||||
|
|
||||||
|
**`GET /api/tags`** - List all tags
|
||||||
|
- Handler: `TagsHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Response: `200 OK` with JSON array:
|
||||||
|
```json
|
||||||
|
[{ "id": 1, "name": "production" }, { "id": 2, "name": "staging" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`POST /api/tags`** - Create a new tag
|
||||||
|
- Handler: `TagsHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Request body: `{ "name": "production" }`
|
||||||
|
- Response: `201 Created` with `{ "id": 1, "name": "production" }`
|
||||||
|
- Errors: `400 Bad Request` (empty name), `409 Conflict` (duplicate name), `500 Internal Server Error`
|
||||||
|
|
||||||
|
**`DELETE /api/tags/{id}`** - Delete a tag
|
||||||
|
- Handler: `TagByIDHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- URL parameter: `{id}` is integer tag ID
|
||||||
|
- Response: `204 No Content`
|
||||||
|
- Errors: `405 Method Not Allowed`, `400 Bad Request` (invalid ID), `404 Not Found`, `500 Internal Server Error`
|
||||||
|
- Behavior: Cascading delete removes all `tag_assignments` referencing this tag
|
||||||
|
|
||||||
|
### Tag Assignments API
|
||||||
|
|
||||||
|
**`PUT /api/tag-assignments`** - Assign an image to a tag
|
||||||
|
- Handler: `TagAssignmentHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Request body: `{ "image": "registry/org/image:tag", "tag_id": 1 }`
|
||||||
|
- Response: `204 No Content`
|
||||||
|
- Errors: `400 Bad Request`, `404 Not Found` (tag doesn't exist), `500 Internal Server Error`
|
||||||
|
- Behavior: `INSERT OR REPLACE` - reassigns if already assigned
|
||||||
|
|
||||||
|
**`DELETE /api/tag-assignments`** - Unassign an image from its tag
|
||||||
|
- Handler: `TagAssignmentHandler` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Request body: `{ "image": "registry/org/image:tag" }`
|
||||||
|
- Response: `204 No Content`
|
||||||
|
- Errors: `400 Bad Request`, `500 Internal Server Error`
|
||||||
|
|
||||||
|
### Static File Serving
|
||||||
|
|
||||||
|
**`GET /` and all unmatched routes** - Serve React SPA
|
||||||
|
- Handler: `http.FileServer(http.Dir("./frontend/dist"))` in `cmd/diunwebhook/main.go`
|
||||||
|
- Serves the production build of the React frontend
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- SQLite (file-based, single-writer)
|
||||||
|
- Connection: `DB_PATH` env var (default `./diun.db`)
|
||||||
|
- Driver: `modernc.org/sqlite` (pure Go, registered as `"sqlite"` in `database/sql`)
|
||||||
|
- Max open connections: 1 (`db.SetMaxOpenConns(1)`)
|
||||||
|
- Write concurrency: `sync.Mutex` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Table: updates (one row per unique image)
|
||||||
|
CREATE TABLE IF NOT EXISTS updates (
|
||||||
|
image TEXT PRIMARY KEY,
|
||||||
|
diun_version TEXT NOT NULL DEFAULT '',
|
||||||
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
provider TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_link TEXT NOT NULL DEFAULT '',
|
||||||
|
mime_type TEXT NOT NULL DEFAULT '',
|
||||||
|
digest TEXT NOT NULL DEFAULT '',
|
||||||
|
created TEXT NOT NULL DEFAULT '',
|
||||||
|
platform TEXT NOT NULL DEFAULT '',
|
||||||
|
ctn_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ctn_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ctn_state TEXT NOT NULL DEFAULT '',
|
||||||
|
ctn_status TEXT NOT NULL DEFAULT '',
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
acknowledged_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Table: tags (user-defined grouping labels)
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Table: tag_assignments (image-to-tag mapping, one tag per image)
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_assignments (
|
||||||
|
image TEXT PRIMARY KEY,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migrations:**
|
||||||
|
- Schema is created on startup via `InitDB()` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Uses `CREATE TABLE IF NOT EXISTS` for all tables
|
||||||
|
- One manual migration: `ALTER TABLE updates ADD COLUMN acknowledged_at TEXT` (silently ignored if already present)
|
||||||
|
- No formal migration framework; migrations are inline Go code
|
||||||
|
|
||||||
|
**File Storage:** Local filesystem only (SQLite database file)
|
||||||
|
|
||||||
|
**Caching:** None
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Webhook Authentication:**
|
||||||
|
- Token-based via `WEBHOOK_SECRET` env var
|
||||||
|
- Checked in `WebhookHandler` using `crypto/subtle.ConstantTimeCompare` against the `Authorization` header
|
||||||
|
- When `WEBHOOK_SECRET` is empty, the webhook endpoint is unprotected (warning logged at startup)
|
||||||
|
- Implementation: `pkg/diunwebhook/diunwebhook.go` lines 54-56, 163-170
|
||||||
|
|
||||||
|
**User Authentication:** None. The dashboard and all API endpoints (except webhook) are open/unauthenticated.
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Error Tracking:** None (no Sentry, Datadog, etc.)
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
- Go stdlib `log` package writing to stdout
|
||||||
|
- Key log points: startup warnings, webhook receipt, errors in handlers
|
||||||
|
- No structured logging framework
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:** Self-hosted via Docker on a Gitea instance at `gitea.jeanlucmakiola.de`
|
||||||
|
|
||||||
|
**Container Registry:** `gitea.jeanlucmakiola.de/makiolaj/diundashboard`
|
||||||
|
|
||||||
|
**CI Pipeline (Gitea Actions):**
|
||||||
|
- Config: `.gitea/workflows/ci.yml`
|
||||||
|
- Triggers: Push to `develop`, PRs targeting `develop`
|
||||||
|
- Steps: `gofmt` check, `go vet`, tests with coverage (warn below 80%), `go build`
|
||||||
|
- Runner: Custom Docker image with Go + Node/Bun toolchains
|
||||||
|
|
||||||
|
**Release Pipeline (Gitea Actions):**
|
||||||
|
- Config: `.gitea/workflows/release.yml`
|
||||||
|
- Trigger: Manual `workflow_dispatch` with semver bump choice (patch/minor/major)
|
||||||
|
- Steps: Run full CI checks, compute new version tag, create git tag, build and push Docker image (versioned + `latest`), create Gitea release with changelog
|
||||||
|
- Secrets required: `GITEA_TOKEN`, `REGISTRY_TOKEN`
|
||||||
|
|
||||||
|
**Docker Build:**
|
||||||
|
- Multi-stage Dockerfile at project root (`Dockerfile`)
|
||||||
|
- Stage 1: `oven/bun:1-alpine` - Build frontend (`bun install --frozen-lockfile && bun run build`)
|
||||||
|
- Stage 2: `golang:1.26-alpine` - Build Go binary (`CGO_ENABLED=0 go build`)
|
||||||
|
- Stage 3: `alpine:3.18` - Runtime with binary + static assets, exposes port 8080
|
||||||
|
|
||||||
|
**Docker Compose:**
|
||||||
|
- `compose.yml` - Production deploy (pulls `latest` from registry, mounts `diun-data` volume at `/data`)
|
||||||
|
- `compose.dev.yml` - Local development (builds from Dockerfile)
|
||||||
|
|
||||||
|
**Documentation Site:**
|
||||||
|
- Separate `docs/Dockerfile` and `docs/nginx.conf` for static site deployment via Nginx
|
||||||
|
- Built with VitePress, served as static HTML
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required env vars (production):**
|
||||||
|
- None strictly required (all have defaults)
|
||||||
|
|
||||||
|
**Recommended env vars:**
|
||||||
|
- `WEBHOOK_SECRET` - Protect webhook endpoint from unauthorized access
|
||||||
|
- `DB_PATH` - Set to `/data/diun.db` in Docker for persistent volume mount
|
||||||
|
- `PORT` - Override default port 8080
|
||||||
|
|
||||||
|
**Secrets:**
|
||||||
|
- `WEBHOOK_SECRET` - Shared secret between DIUN and this app
|
||||||
|
- `GITEA_TOKEN` - CI/CD pipeline (Gitea API access)
|
||||||
|
- `REGISTRY_TOKEN` - CI/CD pipeline (Docker registry push)
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming:**
|
||||||
|
- `POST /webhook` - Receives DIUN image update notifications
|
||||||
|
|
||||||
|
**Outgoing:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
## Frontend-Backend Communication
|
||||||
|
|
||||||
|
**Dev Mode:**
|
||||||
|
- Vite dev server on `:5173` proxies `/api` and `/webhook` to `http://localhost:8080` (`frontend/vite.config.ts`)
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Go server serves `frontend/dist/` at `/` via `http.FileServer`
|
||||||
|
- API and webhook routes are on the same origin (no CORS needed)
|
||||||
|
|
||||||
|
**Polling:**
|
||||||
|
- React SPA polls `GET /api/updates` every 5 seconds (no WebSocket/SSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-03-23*
|
||||||
121
.planning/codebase/STACK.md
Normal file
121
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
**Primary:**
|
||||||
|
- Go 1.26 - Backend HTTP server and all API logic (`cmd/diunwebhook/main.go`, `pkg/diunwebhook/diunwebhook.go`)
|
||||||
|
- TypeScript ~5.7 - Frontend React SPA (`frontend/src/`)
|
||||||
|
|
||||||
|
**Secondary:**
|
||||||
|
- SQL (SQLite dialect) - Inline schema DDL and queries in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Go 1.26 (compiled binary, no runtime needed in production)
|
||||||
|
- Bun (frontend build toolchain, uses `oven/bun:1-alpine` Docker image)
|
||||||
|
- Alpine Linux 3.18 (production container base)
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- Go modules - `go.mod` at project root (module name: `awesomeProject`)
|
||||||
|
- Bun - `frontend/bun.lock` present for frontend dependencies
|
||||||
|
- Bun - `docs/bun.lock` present for documentation site dependencies
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- `net/http` (Go stdlib) - HTTP server, routing, and handler registration. No third-party router.
|
||||||
|
- React 19 (`^19.0.0`) - Frontend SPA (`frontend/`)
|
||||||
|
- Vite 6 (`^6.0.5`) - Frontend dev server and build tool (`frontend/vite.config.ts`)
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- Tailwind CSS 3.4 (`^3.4.17`) - Utility-first CSS (`frontend/tailwind.config.ts`)
|
||||||
|
- shadcn/ui - Component library (uses Radix UI primitives, `class-variance-authority`, `clsx`, `tailwind-merge`)
|
||||||
|
- Radix UI (`@radix-ui/react-tooltip` `^1.1.6`) - Accessible tooltip primitives
|
||||||
|
- dnd-kit (`@dnd-kit/core` `^6.3.1`, `@dnd-kit/utilities` `^3.2.2`) - Drag and drop
|
||||||
|
- Lucide React (`^0.469.0`) - Icon library
|
||||||
|
- simple-icons (`^16.9.0`) - Brand/service icons
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- VitePress (`^1.6.3`) - Static documentation site (`docs/`)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Go stdlib `testing` package with `httptest` for handler tests
|
||||||
|
- No frontend test framework detected
|
||||||
|
|
||||||
|
**Build/Dev:**
|
||||||
|
- Vite 6 (`^6.0.5`) - Frontend bundler (`frontend/vite.config.ts`)
|
||||||
|
- TypeScript ~5.7 (`^5.7.2`) - Type checking (`tsc -b` runs before `vite build`)
|
||||||
|
- PostCSS 8.4 (`^8.4.49`) with Autoprefixer 10.4 (`^10.4.20`) - CSS processing (`frontend/postcss.config.js`)
|
||||||
|
- `@vitejs/plugin-react` (`^4.3.4`) - React Fast Refresh for Vite
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical (Go):**
|
||||||
|
- `modernc.org/sqlite` v1.46.1 - Pure-Go SQLite driver (no CGO required). Registered as `database/sql` driver named `"sqlite"`.
|
||||||
|
- `modernc.org/libc` v1.67.6 - C runtime emulation for pure-Go SQLite
|
||||||
|
- `modernc.org/memory` v1.11.0 - Memory allocator for pure-Go SQLite
|
||||||
|
|
||||||
|
**Transitive (Go):**
|
||||||
|
- `github.com/dustin/go-humanize` v1.0.1 - Human-readable formatting (indirect dep of modernc.org/sqlite)
|
||||||
|
- `github.com/google/uuid` v1.6.0 - UUID generation (indirect)
|
||||||
|
- `github.com/mattn/go-isatty` v0.0.20 - Terminal detection (indirect)
|
||||||
|
- `golang.org/x/sys` v0.37.0 - System calls (indirect)
|
||||||
|
- `golang.org/x/exp` v0.0.0-20251023 - Experimental packages (indirect)
|
||||||
|
|
||||||
|
**Critical (Frontend):**
|
||||||
|
- `react` / `react-dom` `^19.0.0` - UI framework
|
||||||
|
- `@dnd-kit/core` `^6.3.1` - Drag-and-drop for tag assignment
|
||||||
|
- `tailwindcss` `^3.4.17` - Styling
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `class-variance-authority` `^0.7.1` - shadcn/ui component variant management
|
||||||
|
- `clsx` `^2.1.1` - Conditional CSS class composition
|
||||||
|
- `tailwind-merge` `^2.6.0` - Tailwind class deduplication
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
- `PORT` - HTTP listen port (default: `8080`)
|
||||||
|
- `DB_PATH` - SQLite database file path (default: `./diun.db`)
|
||||||
|
- `WEBHOOK_SECRET` - Token for webhook authentication (optional; when unset, webhook is open)
|
||||||
|
|
||||||
|
**Build Configuration:**
|
||||||
|
- `go.mod` - Go module definition (module `awesomeProject`)
|
||||||
|
- `frontend/vite.config.ts` - Vite config with `@` path alias to `./src`, dev proxy for `/api` and `/webhook` to `:8080`
|
||||||
|
- `frontend/tailwind.config.ts` - Tailwind with shadcn/ui theme tokens (dark mode via `class` strategy)
|
||||||
|
- `frontend/postcss.config.js` - PostCSS with Tailwind and Autoprefixer plugins
|
||||||
|
- `frontend/tsconfig.json` - Project references to `tsconfig.node.json` and `tsconfig.app.json`
|
||||||
|
|
||||||
|
**Frontend Path Alias:**
|
||||||
|
- `@` resolves to `frontend/src/` (configured in `frontend/vite.config.ts`)
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**Engine:** SQLite (file-based)
|
||||||
|
**Driver:** `modernc.org/sqlite` v1.46.1 (pure Go, CGO_ENABLED=0 compatible)
|
||||||
|
**Connection:** Single connection (`db.SetMaxOpenConns(1)`) with `sync.Mutex` guarding writes
|
||||||
|
**File:** Configurable via `DB_PATH` env var, default `./diun.db`
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Go 1.26+
|
||||||
|
- Bun (for frontend and docs development)
|
||||||
|
- No CGO required (pure-Go SQLite driver)
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Single static binary + `frontend/dist/` static assets
|
||||||
|
- Alpine Linux 3.18 Docker container
|
||||||
|
- Persistent volume at `/data/` for SQLite database
|
||||||
|
- Port 8080 (configurable via `PORT`)
|
||||||
|
|
||||||
|
**CI:**
|
||||||
|
- Gitea Actions with custom Docker image `gitea.jeanlucmakiola.de/makiolaj/docker-node-and-go` (contains both Go and Node/Bun toolchains)
|
||||||
|
- `GOTOOLCHAIN=local` env var set in CI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-03-23*
|
||||||
240
.planning/codebase/STRUCTURE.md
Normal file
240
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Codebase Structure
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
DiunDashboard/
|
||||||
|
├── cmd/
|
||||||
|
│ └── diunwebhook/
|
||||||
|
│ └── main.go # Application entry point
|
||||||
|
├── pkg/
|
||||||
|
│ └── diunwebhook/
|
||||||
|
│ ├── diunwebhook.go # Core library: types, DB, handlers
|
||||||
|
│ ├── diunwebhook_test.go # Tests (external test package)
|
||||||
|
│ └── export_test.go # Test-only exports
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.tsx # React entry point
|
||||||
|
│ │ ├── App.tsx # Root component (layout, state wiring)
|
||||||
|
│ │ ├── index.css # Tailwind CSS base styles
|
||||||
|
│ │ ├── vite-env.d.ts # Vite type declarations
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── Header.tsx # Top nav bar with refresh button
|
||||||
|
│ │ │ ├── TagSection.tsx # Droppable tag group container
|
||||||
|
│ │ │ ├── ServiceCard.tsx # Individual image/service card (draggable)
|
||||||
|
│ │ │ ├── AcknowledgeButton.tsx # Dismiss/acknowledge button
|
||||||
|
│ │ │ └── ui/ # shadcn/ui primitives
|
||||||
|
│ │ │ ├── badge.tsx
|
||||||
|
│ │ │ ├── button.tsx
|
||||||
|
│ │ │ ├── card.tsx
|
||||||
|
│ │ │ └── tooltip.tsx
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useUpdates.ts # Polling, acknowledge, tag assignment
|
||||||
|
│ │ │ └── useTags.ts # Tag CRUD operations
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ ├── utils.ts # cn() class merge utility
|
||||||
|
│ │ │ ├── time.ts # timeAgo() relative time formatter
|
||||||
|
│ │ │ ├── serviceIcons.ts # Map Docker image names to simple-icons
|
||||||
|
│ │ │ └── serviceIcons.json # Image name -> icon slug mapping
|
||||||
|
│ │ └── types/
|
||||||
|
│ │ └── diun.ts # TypeScript interfaces (DiunEvent, UpdateEntry, Tag, UpdatesMap)
|
||||||
|
│ ├── public/
|
||||||
|
│ │ └── favicon.svg
|
||||||
|
│ ├── index.html # SPA HTML shell
|
||||||
|
│ ├── package.json # Frontend dependencies
|
||||||
|
│ ├── vite.config.ts # Vite build + dev proxy config
|
||||||
|
│ ├── tailwind.config.ts # Tailwind theme configuration
|
||||||
|
│ ├── tsconfig.json # TypeScript project references
|
||||||
|
│ ├── tsconfig.app.json # App TypeScript config
|
||||||
|
│ ├── tsconfig.node.json # Node/Vite TypeScript config
|
||||||
|
│ ├── postcss.config.js # PostCSS/Tailwind pipeline
|
||||||
|
│ └── components.json # shadcn/ui component config
|
||||||
|
├── docs/
|
||||||
|
│ ├── index.md # VitePress docs homepage
|
||||||
|
│ ├── guide/
|
||||||
|
│ │ └── index.md # Getting started guide
|
||||||
|
│ ├── package.json # Docs site dependencies
|
||||||
|
│ ├── Dockerfile # Docs site Nginx container
|
||||||
|
│ ├── nginx.conf # Docs site Nginx config
|
||||||
|
│ └── .gitignore # Ignore docs build artifacts
|
||||||
|
├── .claude/
|
||||||
|
│ └── CLAUDE.md # Claude Code project instructions
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ ├── ci.yml # CI pipeline (test + build)
|
||||||
|
│ └── release.yml # Release/deploy pipeline
|
||||||
|
├── .planning/
|
||||||
|
│ └── codebase/ # GSD codebase analysis documents
|
||||||
|
├── Dockerfile # Multi-stage build (frontend + Go + runtime)
|
||||||
|
├── compose.yml # Docker Compose for deployment (pulls image)
|
||||||
|
├── compose.dev.yml # Docker Compose for local dev (builds locally)
|
||||||
|
├── go.mod # Go module definition
|
||||||
|
├── go.sum # Go dependency checksums
|
||||||
|
├── .gitignore # Git ignore rules
|
||||||
|
├── README.md # Project readme
|
||||||
|
├── CONTRIBUTING.md # Developer guide
|
||||||
|
└── LICENSE # License file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**`cmd/diunwebhook/`:**
|
||||||
|
- Purpose: Application binary entry point
|
||||||
|
- Contains: Single `main.go` file
|
||||||
|
- Key files: `cmd/diunwebhook/main.go`
|
||||||
|
|
||||||
|
**`pkg/diunwebhook/`:**
|
||||||
|
- Purpose: Core library containing all backend logic (types, database, HTTP handlers)
|
||||||
|
- Contains: One implementation file, one test file, one test-exports file
|
||||||
|
- Key files: `pkg/diunwebhook/diunwebhook.go`, `pkg/diunwebhook/diunwebhook_test.go`, `pkg/diunwebhook/export_test.go`
|
||||||
|
|
||||||
|
**`frontend/src/components/`:**
|
||||||
|
- Purpose: React UI components
|
||||||
|
- Contains: Feature components (`Header`, `TagSection`, `ServiceCard`, `AcknowledgeButton`) and `ui/` subdirectory with shadcn/ui primitives
|
||||||
|
|
||||||
|
**`frontend/src/components/ui/`:**
|
||||||
|
- Purpose: Reusable UI primitives from shadcn/ui
|
||||||
|
- Contains: `badge.tsx`, `button.tsx`, `card.tsx`, `tooltip.tsx`
|
||||||
|
- Note: These are generated/copied from shadcn/ui CLI and customized via `components.json`
|
||||||
|
|
||||||
|
**`frontend/src/hooks/`:**
|
||||||
|
- Purpose: Custom React hooks encapsulating data fetching and state management
|
||||||
|
- Contains: `useUpdates.ts` (polling, acknowledge, tag assignment), `useTags.ts` (tag CRUD)
|
||||||
|
|
||||||
|
**`frontend/src/lib/`:**
|
||||||
|
- Purpose: Shared utility functions and data
|
||||||
|
- Contains: `utils.ts` (Tailwind class merge), `time.ts` (relative time), `serviceIcons.ts` + `serviceIcons.json` (Docker image icon lookup)
|
||||||
|
|
||||||
|
**`frontend/src/types/`:**
|
||||||
|
- Purpose: TypeScript type definitions shared across the frontend
|
||||||
|
- Contains: `diun.ts` with interfaces matching Go backend structs
|
||||||
|
|
||||||
|
**`docs/`:**
|
||||||
|
- Purpose: VitePress documentation site (separate from main app)
|
||||||
|
- Contains: Markdown content, VitePress config, Dockerfile for static deployment
|
||||||
|
- Build output: `docs/.vitepress/dist/` (gitignored)
|
||||||
|
|
||||||
|
**`.gitea/workflows/`:**
|
||||||
|
- Purpose: CI/CD pipeline definitions for Gitea Actions
|
||||||
|
- Contains: `ci.yml` (test + build), `release.yml` (release/deploy)
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- `cmd/diunwebhook/main.go`: Go server entry point -- init DB, register routes, start server
|
||||||
|
- `frontend/src/main.tsx`: React SPA mount point -- renders `<App />` into DOM, enables dark mode
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `go.mod`: Go module `awesomeProject`, Go 1.26, SQLite dependency
|
||||||
|
- `frontend/vite.config.ts`: Vite build config, `@` path alias to `src/`, dev proxy for `/api` and `/webhook` to `:8080`
|
||||||
|
- `frontend/tailwind.config.ts`: Tailwind CSS theme customization
|
||||||
|
- `frontend/components.json`: shadcn/ui component generation config
|
||||||
|
- `frontend/tsconfig.json`: TypeScript project references (app + node configs)
|
||||||
|
- `Dockerfile`: Multi-stage build (Bun frontend build, Go binary build, Alpine runtime)
|
||||||
|
- `compose.yml`: Production deployment config (pulls from `gitea.jeanlucmakiola.de` registry)
|
||||||
|
- `compose.dev.yml`: Local development config (builds from Dockerfile)
|
||||||
|
|
||||||
|
**Core Logic:**
|
||||||
|
- `pkg/diunwebhook/diunwebhook.go`: ALL backend logic -- struct definitions, database init/migrations, event storage, all 6 HTTP handlers
|
||||||
|
- `frontend/src/App.tsx`: Root component -- stat cards, tag section rendering, drag-and-drop context, new group creation UI
|
||||||
|
- `frontend/src/hooks/useUpdates.ts`: Primary data hook -- 5s polling, acknowledge, tag assignment with optimistic updates
|
||||||
|
- `frontend/src/hooks/useTags.ts`: Tag management hook -- fetch, create, delete
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- `pkg/diunwebhook/diunwebhook_test.go`: All backend tests (external test package `diunwebhook_test`)
|
||||||
|
- `pkg/diunwebhook/export_test.go`: Exports internal functions for testing (`GetUpdatesMap`, `UpdatesReset`, `ResetTags`, `ResetWebhookSecret`)
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Go: lowercase, single word or underscore-separated (`diunwebhook.go`, `export_test.go`)
|
||||||
|
- React components: PascalCase (`ServiceCard.tsx`, `TagSection.tsx`)
|
||||||
|
- Hooks: camelCase prefixed with `use` (`useUpdates.ts`, `useTags.ts`)
|
||||||
|
- Utilities: camelCase (`time.ts`, `utils.ts`)
|
||||||
|
- shadcn/ui primitives: lowercase (`badge.tsx`, `button.tsx`)
|
||||||
|
|
||||||
|
**Directories:**
|
||||||
|
- Go: lowercase (`cmd/`, `pkg/`)
|
||||||
|
- Frontend: lowercase (`components/`, `hooks/`, `lib/`, `types/`, `ui/`)
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New API Endpoint:**
|
||||||
|
- Add handler function to `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Register route in `cmd/diunwebhook/main.go` on the `mux`
|
||||||
|
- Add tests in `pkg/diunwebhook/diunwebhook_test.go`
|
||||||
|
- If new test helpers are needed, add exports in `pkg/diunwebhook/export_test.go`
|
||||||
|
|
||||||
|
**New Database Table or Migration:**
|
||||||
|
- Add `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE` in `InitDB()` in `pkg/diunwebhook/diunwebhook.go`
|
||||||
|
- Follow existing pattern: `CREATE TABLE IF NOT EXISTS` for new tables, silent `ALTER TABLE` for column additions
|
||||||
|
|
||||||
|
**New React Component:**
|
||||||
|
- Feature component: `frontend/src/components/YourComponent.tsx`
|
||||||
|
- Reusable UI primitive: `frontend/src/components/ui/yourprimitive.tsx` (use shadcn/ui CLI or follow existing pattern)
|
||||||
|
|
||||||
|
**New Custom Hook:**
|
||||||
|
- Place in `frontend/src/hooks/useYourHook.ts`
|
||||||
|
- Follow pattern from `useUpdates.ts`: export a function returning state and callbacks
|
||||||
|
|
||||||
|
**New TypeScript Type:**
|
||||||
|
- Add to `frontend/src/types/diun.ts` if related to the DIUN domain
|
||||||
|
- Create new file in `frontend/src/types/` for unrelated domains
|
||||||
|
|
||||||
|
**New Utility Function:**
|
||||||
|
- Add to `frontend/src/lib/` in an existing file or new file by domain
|
||||||
|
- Time-related: `frontend/src/lib/time.ts`
|
||||||
|
- CSS/styling: `frontend/src/lib/utils.ts`
|
||||||
|
|
||||||
|
**New Go Package:**
|
||||||
|
- Create under `pkg/yourpackage/` following Go conventions
|
||||||
|
- Import from `awesomeProject/pkg/yourpackage` (module name is `awesomeProject`)
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**`frontend/dist/`:**
|
||||||
|
- Purpose: Production build output served by Go file server at `/`
|
||||||
|
- Generated: Yes, by `bun run build` in `frontend/`
|
||||||
|
- Committed: No (gitignored)
|
||||||
|
|
||||||
|
**`docs/.vitepress/dist/`:**
|
||||||
|
- Purpose: Documentation site build output
|
||||||
|
- Generated: Yes, by `bun run build` in `docs/`
|
||||||
|
- Committed: No (gitignored)
|
||||||
|
|
||||||
|
**`.planning/codebase/`:**
|
||||||
|
- Purpose: GSD codebase analysis documents for AI-assisted development
|
||||||
|
- Generated: Yes, by codebase mapping agents
|
||||||
|
- Committed: Yes
|
||||||
|
|
||||||
|
**`.idea/`:**
|
||||||
|
- Purpose: JetBrains IDE project settings
|
||||||
|
- Generated: Yes, by GoLand/IntelliJ
|
||||||
|
- Committed: Partially (has its own `.gitignore`)
|
||||||
|
|
||||||
|
## Build Artifacts and Outputs
|
||||||
|
|
||||||
|
**Go Binary:**
|
||||||
|
- Built by: `go build -o server ./cmd/diunwebhook/main.go` (in Docker) or `go run ./cmd/diunwebhook/` (local)
|
||||||
|
- Output: `./server` binary (in Docker build stage)
|
||||||
|
|
||||||
|
**Frontend Bundle:**
|
||||||
|
- Built by: `bun run build` (runs `tsc -b && vite build`)
|
||||||
|
- Output: `frontend/dist/` directory
|
||||||
|
- Consumed by: Go file server at `/` route, copied into Docker image at `/app/frontend/dist/`
|
||||||
|
|
||||||
|
**Docker Image:**
|
||||||
|
- Built by: `docker build -t diun-webhook-dashboard .`
|
||||||
|
- Multi-stage: frontend build (Bun) -> Go build (golang) -> runtime (Alpine)
|
||||||
|
- Contains: Go binary at `/app/server`, frontend at `/app/frontend/dist/`
|
||||||
|
|
||||||
|
**SQLite Database:**
|
||||||
|
- Created at runtime by `InitDB()`
|
||||||
|
- Default path: `./diun.db` (overridable via `DB_PATH` env var)
|
||||||
|
- Docker: `/data/diun.db` with volume mount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-03-23*
|
||||||
309
.planning/codebase/TESTING.md
Normal file
309
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-03-23
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- Go standard `testing` package
|
||||||
|
- No third-party test frameworks (no testify, gomega, etc.)
|
||||||
|
- Config: none beyond standard Go tooling
|
||||||
|
|
||||||
|
**Assertion Style:**
|
||||||
|
- Manual assertions using `t.Errorf` and `t.Fatalf` (no assertion library)
|
||||||
|
- `t.Fatalf` for fatal precondition failures that should stop the test
|
||||||
|
- `t.Errorf` for non-fatal check failures
|
||||||
|
|
||||||
|
**Run Commands:**
|
||||||
|
```bash
|
||||||
|
go test -v -coverprofile=coverage.out -coverpkg=./... ./... # All tests with coverage
|
||||||
|
go test -v -run TestWebhookHandler ./pkg/diunwebhook/ # Single test
|
||||||
|
go tool cover -func=coverage.out # View coverage by function
|
||||||
|
go tool cover -html=coverage.out # View coverage in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Organization
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Co-located with source code in `pkg/diunwebhook/`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `pkg/diunwebhook/diunwebhook_test.go` - All tests (external test package `package diunwebhook_test`)
|
||||||
|
- `pkg/diunwebhook/export_test.go` - Test-only exports (internal package `package diunwebhook`)
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
- Test functions: `Test<Function>_<Scenario>` (e.g., `TestWebhookHandler_BadRequest`, `TestDismissHandler_NotFound`)
|
||||||
|
- Helper functions: lowercase descriptive names (e.g., `postTag`, `postTagAndGetID`)
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
pkg/diunwebhook/
|
||||||
|
├── diunwebhook.go # All production code
|
||||||
|
├── diunwebhook_test.go # All tests (external package)
|
||||||
|
└── export_test.go # Test-only exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**External Test Package:**
|
||||||
|
Tests use `package diunwebhook_test` (not `package diunwebhook`), which forces testing through the public API only. The production package is imported with an alias:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package diunwebhook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
diun "awesomeProject/pkg/diunwebhook"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Initialization:**
|
||||||
|
`TestMain` resets the database to an in-memory SQLite instance before all tests:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
diun.UpdatesReset()
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Individual Test Pattern:**
|
||||||
|
Each test resets state at the start, then performs arrange-act-assert:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestDismissHandler_Success(t *testing.T) {
|
||||||
|
diun.UpdatesReset() // Reset DB
|
||||||
|
err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}) // Arrange
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil) // Act
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
diun.DismissHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent { // Assert
|
||||||
|
t.Errorf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
m := diun.GetUpdatesMap()
|
||||||
|
if !m["nginx:latest"].Acknowledged {
|
||||||
|
t.Errorf("expected entry to be acknowledged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper Functions:**
|
||||||
|
Test helpers use `t.Helper()` for proper error line reporting:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func postTag(t *testing.T, name string) (int, int) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]string{"name": name})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
diun.TagsHandler(rec, req)
|
||||||
|
return rec.Code, rec.Body.Len()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
**Framework:** No mocking framework used
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- In-memory SQLite database via `InitDB(":memory:")` replaces the real database
|
||||||
|
- `httptest.NewRequest` and `httptest.NewRecorder` for HTTP handler testing
|
||||||
|
- `httptest.NewServer` for integration-level tests
|
||||||
|
- Custom `failWriter` struct to simulate broken `http.ResponseWriter`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type failWriter struct{ http.ResponseWriter }
|
||||||
|
|
||||||
|
func (f failWriter) Header() http.Header { return http.Header{} }
|
||||||
|
func (f failWriter) Write([]byte) (int, error) { return 0, errors.New("forced error") }
|
||||||
|
func (f failWriter) WriteHeader(_ int) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Mock:**
|
||||||
|
- Database: use in-memory SQLite (`:memory:`)
|
||||||
|
- HTTP layer: use `httptest` package
|
||||||
|
- ResponseWriter errors: use custom struct implementing `http.ResponseWriter`
|
||||||
|
|
||||||
|
**What NOT to Mock:**
|
||||||
|
- Handler logic (test through the HTTP interface)
|
||||||
|
- JSON encoding/decoding (test with real payloads)
|
||||||
|
|
||||||
|
## Fixtures and Factories
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
Events are constructed inline with struct literals:
|
||||||
|
|
||||||
|
```go
|
||||||
|
event := diun.DiunEvent{
|
||||||
|
DiunVersion: "1.0",
|
||||||
|
Hostname: "host",
|
||||||
|
Status: "new",
|
||||||
|
Provider: "docker",
|
||||||
|
Image: "nginx:latest",
|
||||||
|
HubLink: "https://hub.docker.com/nginx",
|
||||||
|
MimeType: "application/json",
|
||||||
|
Digest: "sha256:abc",
|
||||||
|
Created: time.Now(),
|
||||||
|
Platform: "linux/amd64",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal events are also used when only the image field matters:
|
||||||
|
|
||||||
|
```go
|
||||||
|
event := diun.DiunEvent{Image: "nginx:latest"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- No separate fixtures directory; all test data is inline in `pkg/diunwebhook/diunwebhook_test.go`
|
||||||
|
|
||||||
|
## Test-Only Exports
|
||||||
|
|
||||||
|
**File:** `pkg/diunwebhook/export_test.go`
|
||||||
|
|
||||||
|
These functions are only accessible to test packages (files ending in `_test.go`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func GetUpdatesMap() map[string]UpdateEntry // Convenience wrapper around GetUpdates()
|
||||||
|
func UpdatesReset() // Re-initializes DB with in-memory SQLite
|
||||||
|
func ResetTags() // Clears tag_assignments and tags tables
|
||||||
|
func ResetWebhookSecret() // Sets webhookSecret to ""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
**Requirements:** CI warns (does not fail) when coverage drops below 80%
|
||||||
|
|
||||||
|
**CI Coverage Check:**
|
||||||
|
```bash
|
||||||
|
go test -v -coverprofile=coverage.out -coverpkg=./... ./...
|
||||||
|
go tool cover -func=coverage.out | tee coverage.txt
|
||||||
|
cov=$(go tool cover -func=coverage.out | grep total: | awk '{print substr($3, 1, length($3)-1)}')
|
||||||
|
cov=${cov%.*}
|
||||||
|
if [ "$cov" -lt 80 ]; then
|
||||||
|
echo "::warning::Test coverage is below 80% ($cov%)"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**View Coverage:**
|
||||||
|
```bash
|
||||||
|
go test -coverprofile=coverage.out -coverpkg=./... ./...
|
||||||
|
go tool cover -func=coverage.out # Text summary
|
||||||
|
go tool cover -html=coverage.out # Browser view
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Pipeline
|
||||||
|
|
||||||
|
**Platform:** Gitea Actions (Forgejo-compatible)
|
||||||
|
|
||||||
|
**CI Workflow:** `.gitea/workflows/ci.yml`
|
||||||
|
- Triggers: push to `develop`, PRs targeting `develop`
|
||||||
|
- Container: custom Docker image with Go and Node.js
|
||||||
|
- Steps:
|
||||||
|
1. `gofmt -l .` - Formatting check (fails build if unformatted)
|
||||||
|
2. `go vet ./...` - Static analysis
|
||||||
|
3. `go test -v -coverprofile=coverage.out -coverpkg=./... ./...` - Tests with coverage
|
||||||
|
4. Coverage threshold check (80%, warning only)
|
||||||
|
5. `go build ./...` - Build verification
|
||||||
|
|
||||||
|
**Release Workflow:** `.gitea/workflows/release.yml`
|
||||||
|
- Triggers: manual dispatch with version bump type (patch/minor/major)
|
||||||
|
- Runs the same build-test job, then creates a Docker image and Gitea release
|
||||||
|
|
||||||
|
**Missing from CI:**
|
||||||
|
- No frontend build or type-check step
|
||||||
|
- No frontend test step (no frontend tests exist)
|
||||||
|
- No linting beyond `gofmt` and `go vet`
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- Handler tests using `httptest.NewRequest` / `httptest.NewRecorder`
|
||||||
|
- Direct function tests: `TestUpdateEventAndGetUpdates`
|
||||||
|
- All tests in `pkg/diunwebhook/diunwebhook_test.go`
|
||||||
|
|
||||||
|
**Concurrency Tests:**
|
||||||
|
- `TestConcurrentUpdateEvent` - 100 concurrent goroutines writing to the database via `sync.WaitGroup`
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- `TestMainHandlerIntegration` - Full HTTP server via `httptest.NewServer`, tests webhook POST followed by updates GET
|
||||||
|
|
||||||
|
**Error Path Tests:**
|
||||||
|
- `TestWebhookHandler_BadRequest` - invalid JSON body
|
||||||
|
- `TestWebhookHandler_EmptyImage` - missing required field
|
||||||
|
- `TestWebhookHandler_MethodNotAllowed` - wrong HTTP methods
|
||||||
|
- `TestWebhookHandler_Unauthorized` / `TestWebhookHandler_WrongToken` - auth failures
|
||||||
|
- `TestDismissHandler_NotFound` - dismiss nonexistent entry
|
||||||
|
- `TestDismissHandler_EmptyImage` - empty path parameter
|
||||||
|
- `TestUpdatesHandler_EncodeError` - broken ResponseWriter
|
||||||
|
- `TestCreateTagHandler_DuplicateName` - UNIQUE constraint
|
||||||
|
- `TestCreateTagHandler_EmptyName` - validation
|
||||||
|
|
||||||
|
**Behavioral Tests:**
|
||||||
|
- `TestDismissHandler_ReappearsAfterNewWebhook` - acknowledged state resets on new webhook
|
||||||
|
- `TestDeleteTagHandler_CascadesAssignment` - tag deletion cascades to assignments
|
||||||
|
- `TestTagAssignmentHandler_Reassign` - reassigning image to different tag
|
||||||
|
- `TestDismissHandler_SlashInImageName` - image names with slashes in URL path
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
- Not implemented
|
||||||
|
- No frontend tests of any kind (no test runner configured, no test files)
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**Frontend (no tests at all):**
|
||||||
|
- `frontend/src/App.tsx` - main application component
|
||||||
|
- `frontend/src/hooks/useUpdates.ts` - polling, acknowledge, tag assignment logic
|
||||||
|
- `frontend/src/hooks/useTags.ts` - tag CRUD logic
|
||||||
|
- `frontend/src/components/ServiceCard.tsx` - image name parsing, registry detection
|
||||||
|
- `frontend/src/lib/time.ts` - time formatting utilities
|
||||||
|
- `frontend/src/lib/serviceIcons.ts` - icon lookup logic
|
||||||
|
- Priority: Medium (pure utility functions like `getShortName`, `getRegistry`, `timeAgo` would benefit from unit tests)
|
||||||
|
|
||||||
|
**Backend gaps:**
|
||||||
|
- `cmd/diunwebhook/main.go` - server startup, graceful shutdown, env var reading (not tested)
|
||||||
|
- `TagsHandler` and `TagByIDHandler` method-not-allowed paths for unsupported HTTP methods
|
||||||
|
- `TagAssignmentHandler` bad request paths (missing image, invalid tag_id)
|
||||||
|
- Priority: Low (main.go is thin; handler edge cases are minor)
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**HTTP Handler Testing:**
|
||||||
|
```go
|
||||||
|
func TestSomeHandler(t *testing.T) {
|
||||||
|
diun.UpdatesReset()
|
||||||
|
// arrange: create test data
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// act
|
||||||
|
diun.SomeHandler(rec, req)
|
||||||
|
|
||||||
|
// assert status code
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// assert response body
|
||||||
|
var got SomeType
|
||||||
|
json.NewDecoder(rec.Body).Decode(&got)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Reset Pattern:**
|
||||||
|
Every test calls `diun.UpdatesReset()` at the start, which re-initializes the in-memory SQLite database. This ensures test isolation without needing parallel-safe fixtures.
|
||||||
|
|
||||||
|
**Auth Testing Pattern:**
|
||||||
|
```go
|
||||||
|
diun.SetWebhookSecret("my-secret")
|
||||||
|
defer diun.ResetWebhookSecret()
|
||||||
|
// ... test with/without Authorization header
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-03-23*
|
||||||
Reference in New Issue
Block a user