Compare commits
22
Commits
1.0.11
...
7be66a703c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be66a703c | ||
|
|
4d627e13b9 | ||
|
|
c0356a26f6 | ||
|
|
4aac5f9009 | ||
|
|
af2e94aa46 | ||
|
|
187d288e2e | ||
|
|
377dc69859 | ||
|
|
5eb5edcac4 | ||
|
|
55a128dc3a | ||
|
|
1f11baa949 | ||
|
|
89ddfb3528 | ||
|
|
99f75eb9ed | ||
|
|
d823763dc3 | ||
|
|
9178b7b295 | ||
|
|
18459a9811 | ||
|
|
f6ca89747e | ||
|
|
98fafcd93c | ||
|
|
8b8f2335e1 | ||
|
|
80f4d9f198 | ||
|
|
87e9eff1bd | ||
|
|
24273b5513 | ||
|
|
31882f6b73 |
No files matched your search
@@ -6,12 +6,14 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: cd frontend && npm ci
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
- name: Lint Frontend
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Test Frontend with Coverage
|
||||
run: cd frontend && npm run test:coverage
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && npm run build
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Generate deployment manifest
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
echo "Form arguments: $form_args"
|
||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||
# Get short commit hash for deployment metadata
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 || echo "Gitea Actions deploy")
|
||||
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-F "commit_hash=${COMMIT_HASH}" \
|
||||
-F "commit_message=${COMMIT_MSG}" \
|
||||
-F "commit_dirty=false" \
|
||||
$form_args
|
||||
+21
-1
@@ -14,9 +14,29 @@ Agents instructions for the linearvoid project.
|
||||
|
||||
## Task Management
|
||||
|
||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||
- Plan any task in the `.github/instructions/TODO.md` file with clear steps and mark them as completed when done. Move completed sections to `.github/instructions/DONE.md`. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||
|
||||
### Required tracking files — update at every development step
|
||||
|
||||
These files must be kept current with every change. Do not let them drift.
|
||||
|
||||
| File | Purpose | When to update |
|
||||
| --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `.github/instructions/TODO.md` | Active implementation plan with checkboxes | Mark items done as they complete; add new items when scope expands |
|
||||
| `.github/instructions/BACKLOG.md` | Deferred, manual, and operational tasks | Sync whenever TODO items complete or new gaps are identified |
|
||||
| `.github/instructions/DONE.md` | Archive of completed TODO sections | Move completed sections here from TODO.md |
|
||||
| `.github/MEMORY.md` | Session memory — what was done, when, and why | Append dated entry after each meaningful batch of changes |
|
||||
| `docs/DEVELOPMENT.md` | Remaining task checklist organized by topic | Sync checkboxes with TODO.md after each implementation session |
|
||||
| `CHANGELOG.md` | User-facing release notes | Add entry when a phase or significant feature completes |
|
||||
| `docs/STREAMING.md` | Streaming pipeline contract | Update when playout, HLS, or caching behavior changes |
|
||||
| `docs/DEPLOYMENT.md` | Deploy and infrastructure procedures | Update when pipeline, topology, or provisioning changes |
|
||||
| `docs/SETUP.md` | Environment and onboarding guide | Update when prerequisites, `.env`, or setup steps change |
|
||||
| `docs/MONITORING.md` | Observability reference | Update when metrics, alerts, or dashboards change |
|
||||
| `docs/API.md` | API contract | Update when endpoints, status codes, or rate limits change |
|
||||
| `docs/SECURITY.md` | Security posture | Update when auth, TLS, or rate limiting changes |
|
||||
| `docs/operations/*.md` | Runbooks, checklists, how-to guides | Keep aligned with actual procedures and dashboard URLs |
|
||||
|
||||
## Security & Secrets Protection
|
||||
|
||||
- **Never commit raw secrets or credentials** to the repository.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# MEMORY
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
- Completed runtime validation section from TODO.
|
||||
- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe.
|
||||
- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`.
|
||||
- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds.
|
||||
- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles.
|
||||
- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`.
|
||||
- Redis restart test:
|
||||
- Admin session endpoint returned 200 before and after Redis restart.
|
||||
- Presence key survived Redis restart in test-lab configuration.
|
||||
- Redis health returned to `healthy` post-restart.
|
||||
- Files changed:
|
||||
- `.github/instructions/TODO.md`
|
||||
- `.github/instructions/DONE.md`
|
||||
- `frontend/src/style.css`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
## 2026-08-07 (continued)
|
||||
|
||||
- Completed documentation half of recovery runbook TODO item.
|
||||
- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template.
|
||||
- Split TODO item into:
|
||||
- documented drill (`[x]`)
|
||||
- manual rehearsal execution (`[ ]`)
|
||||
- Updated `CHANGELOG.md` with incident runbook drill documentation entry.
|
||||
|
||||
## 2026-08-07 (continued 2)
|
||||
|
||||
- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture.
|
||||
- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections.
|
||||
- Did not mark manual dashboard items complete; checklist is preparation only.
|
||||
|
||||
## 2026-08-07 (continued 3)
|
||||
|
||||
- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans.
|
||||
- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending.
|
||||
- Linked scaling playbook in `README.md` resources.
|
||||
|
||||
## 2026-08-09
|
||||
|
||||
- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`.
|
||||
- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios.
|
||||
- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation.
|
||||
- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios.
|
||||
- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`).
|
||||
- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files.
|
||||
- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending.
|
||||
- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates.
|
||||
|
||||
## 2026-08-09 (continued)
|
||||
|
||||
- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps).
|
||||
- Completed Phase 4 (R2 integration):
|
||||
- Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension:
|
||||
- `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T`
|
||||
- `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl`
|
||||
- unknown extension -> no cache headers
|
||||
- Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension).
|
||||
- All 17 playout tests pass (16 pass, 1 pre-existing skip).
|
||||
- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual).
|
||||
- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete.
|
||||
|
||||
## 2026-08-09 (continued 2)
|
||||
|
||||
- Completed Phase 2 chat tests in `frontend/src/main.test.ts`:
|
||||
- Presence updates: join/leave system messages add and remove users from presence list, sorted rendering.
|
||||
- Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames.
|
||||
- Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly.
|
||||
- Total frontend tests: 27 passing (2 test files).
|
||||
- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern).
|
||||
- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked.
|
||||
- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked.
|
||||
- Updated CHANGELOG.md with Phase 2 presence/DM/message tests.
|
||||
|
||||
## 2026-08-09 (continued 3)
|
||||
|
||||
- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`:
|
||||
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`.
|
||||
- Added playlist save test (verifies PUT /api/admin/playlist with JSON body).
|
||||
- Added playlist save error handling test (verifies failure status).
|
||||
- Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip).
|
||||
- Total frontend tests: 31 passing (2 test files).
|
||||
- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked.
|
||||
- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete.
|
||||
|
||||
## 2026-08-10 (continued 2)
|
||||
|
||||
- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`:
|
||||
- **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path.
|
||||
- **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit.
|
||||
- **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments.
|
||||
- **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md.
|
||||
- **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions.
|
||||
- **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s).
|
||||
- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`.
|
||||
- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`.
|
||||
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure).
|
||||
|
||||
## 2026-08-10 (continued 3)
|
||||
|
||||
- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests:
|
||||
- 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role.
|
||||
- 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live.
|
||||
- 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability.
|
||||
- 2 admin responsive tests: tab content visibility, essential tab presence.
|
||||
- Total: 45 frontend tests passing (2 test files).
|
||||
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
||||
- Added `test:coverage` script to `package.json`.
|
||||
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `pages-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||
- All Go vet, Go tests, frontend test:coverage passing.
|
||||
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||
|
||||
## 2026-08-10 (continued 4)
|
||||
|
||||
- Verified all test suites pass after streaming pipeline implementation:
|
||||
- Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass
|
||||
- Chat: 7 packages pass (go vet + go test)
|
||||
- Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%)
|
||||
- Edge cache validation script `scripts/validate-edge-cache.sh` exists.
|
||||
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks).
|
||||
@@ -0,0 +1,62 @@
|
||||
# BACKLOG
|
||||
|
||||
## Cloudflare Configuration
|
||||
|
||||
- [x] Configure DNS for linearvoid.tv apex and www redirect (manual)
|
||||
- [x] Configure media.linearvoid.tv for split-domain HLS delivery (manual)
|
||||
- [x] Create R2 bucket (manual)
|
||||
- [x] Configure Cache Rules: .ts/.m4s + .m3u8 (manual)
|
||||
- [x] Configure Cloudflare Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) and setup docs (`docs/operations/cloudflare-pages-setup.md`, `docs/operations/gitea-cloudflare.pages.md`)
|
||||
- [ ] Create Cloudflare Pages project in dashboard and link custom domain (manual)
|
||||
- [ ] Enable Tiered Cache, WAF, rate limiting (manual)
|
||||
- [x] Implement Worker for immutable cache headers on segment paths
|
||||
- [ ] Enforce HTTPS/WSS, redirect HTTP → HTTPS (manual)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
- [x] Set up Prometheus with chat/playout/nginx/redis/postgres/node scrape targets
|
||||
- [x] Add nginx-exporter, redis-exporter, postgres-exporter, node-exporter
|
||||
- [x] Import Grafana app + operational dashboards
|
||||
- [x] Define Prometheus alerting rules (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
|
||||
- [ ] Set up UptimeRobot external monitoring
|
||||
|
||||
## Security
|
||||
|
||||
- [x] Implement admin cookie-session auth (HttpOnly, SameSite=Strict, Secure when TLS)
|
||||
- [x] Implement server-side session expiration
|
||||
- [ ] Enforce HTTPS/WSS at edge and origin
|
||||
- [x] Implement rate limiting for chat messages (Redis windowing)
|
||||
- [x] Implement WebSocket connection rate limiting (5/IP/60s)
|
||||
- [x] Implement admin login rate limiting (5/IP/5min)
|
||||
|
||||
## Scaling & Performance
|
||||
|
||||
- [x] Create scaling/performance playbook (methods, thresholds, execution order)
|
||||
- [x] Load-test scaffolding: ws-chat.yml, admin-api.js, run-loadtests.ps1, summarizers
|
||||
- [x] Cache-hit ratio exporter (scripts/cache-hit-ratio.js + .ps1)
|
||||
- [x] Saturation alerts: chat p95, Redis memory, cache-hit ratio
|
||||
- [ ] Create Cloudflare Pages project and cut over frontend hosting (manual)
|
||||
- [ ] Optimize segment immutability and manifest TTL for cache efficiency
|
||||
- [ ] Measure playback TTFB from multiple US regions
|
||||
- [ ] Execute WebSocket load tests with real infrastructure
|
||||
- [ ] Execute admin API benchmarks with real infrastructure
|
||||
- [ ] Track cache hit ratio and R2 ops in production
|
||||
- [ ] Plan Phase 2 service separation
|
||||
- [ ] Plan Phase 3 multi-origin resilience
|
||||
|
||||
## Streaming Pipeline
|
||||
|
||||
Gaps from 2026-08-10 audit → 2026-08-10 implementation (STREAMING.md contract vs playlist.go implementation):
|
||||
|
||||
- [x] **Continuous R2 sync during playout (HIGH)**: `syncLatestToR2()` now uses fsnotify watcher to upload segments in real-time (~7s delay).
|
||||
- [x] **Globally unique segment naming (HIGH)**: Implemented `r-{ISOTIME}/seg-{seq}.m4s` naming via run ID + ffmpeg monotonic sequence.
|
||||
- [x] **Atomic manifest publication (MEDIUM)**: Upload manifest to `playlist.m3u8.tmp`, then `CopyObject` to final key.
|
||||
- [x] **Manifest Cache-Control alignment (LOW)**: Updated to `public, max-age=1, s-maxage=2, must-revalidate`.
|
||||
- [ ] **CORS headers on HLS responses (MEDIUM)**: Needs Cloudflare R2 bucket CORS config (infrastructure, not code) — CORS already set in nginx for same-origin; media.linearvoid.tv needs Cloudflare setting.
|
||||
- [x] **Player error recovery (MEDIUM)**: Added `hls.on(Hls.Events.ERROR)` handler with bounded retries (5x), exponential backoff, reconnecting overlay.
|
||||
- [x] **Health endpoint detail (LOW)**: Added active item, run ID, playlist revision, last segment seq, last R2 pub, consecutive failures.
|
||||
- [x] Source media contract + validation (H.264/AAC, 30fps, keyframe check) — implemented
|
||||
- [x] fMP4 HLS packaging (6s segments, 24 window) — implemented
|
||||
- [x] Slate fallback + recovery (`shouldEscalateToSlate`) — implemented
|
||||
- [x] R2 cache headers (immutable segments, short-lived manifest) — implemented
|
||||
- [x] Cloudflare Worker immutable `.m4s` headers — implemented
|
||||
@@ -0,0 +1,1000 @@
|
||||
# DONE
|
||||
|
||||
Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANGELOG.md, README.md, architecture docs.
|
||||
|
||||
## Session 2026-08-07 — Unit Tests
|
||||
|
||||
- [x] Chat service refactor: `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics)
|
||||
- [x] `db.RedisClient` + `db.PubSub` interfaces defined for mocking
|
||||
- [x] Config tests: 10 env-var tests, table-driven, 100% coverage
|
||||
- [x] Auth tests: token generation, session CRUD, cookie security, middleware, 97.8% coverage
|
||||
- [x] DB tests: SQLite in-memory CRUD for all 4 models, 14.3% coverage
|
||||
- [x] Handlers tests: 35 tests covering login/logout/session/overview/playlist/moderation/analytics/system/config/users/schedule/media/chat-log, 47.2% coverage
|
||||
- [x] WebSocket tests: 15 tests covering connect/send/dm/moderation/banned/muted/history/presence/pubsub, 79.6% coverage
|
||||
- [x] Media tests: formatDuration, extensions, mime, upload IDs, playlist parsing, 36.8% coverage
|
||||
- [x] Ratelimit tests: message/login/WS rate limits with mock Redis, 90.3% coverage
|
||||
- [x] Metrics tests: Prometheus output validation, all 9 metrics present, 100% coverage
|
||||
- [x] Playout tests: 12 tests (LoadPlaylist, keyframeInterval, HealthHandler, slate escalation, reset/activate/deactivate)
|
||||
- [x] Frontend test framework: vitest + jsdom installed
|
||||
- [x] User frontend smoke tests: 6 tests (chat toggle, sendMessage, switchRoom, DM toggle)
|
||||
- [x] Admin frontend smoke tests: 5 tests (DOM structure, upload progress, fingerprint, constants, import stability)
|
||||
- [x] CI pipeline updated: `go test -race -cover` with 50% threshold
|
||||
- [x] SQLite driver switched to pure-Go `github.com/glebarez/sqlite` (CGO-free)
|
||||
- [x] Total: 78 tests across Go + frontend, all passing
|
||||
|
||||
## Session 2026-08-07 — Runtime Validation
|
||||
|
||||
- [x] Check `monitoring/prometheus/prometheus.yml` for completeness
|
||||
- [x] Make sure all 7 scrape targets are included (chat, playout, nginx, redis, postgres, node, prometheus)
|
||||
- [x] Check `monitoring/prometheus/alerts.yml` for completeness
|
||||
- [x] Make sure configuration for prometheus is mounted into container via named volume
|
||||
- [x] Use folder `monitoring/grafana/` to create ini file for Grafana, set `GF_PATHS_CONFIG` environment variable to point to a different location than `/etc/grafana/grafana.ini` if needed
|
||||
- [x] Set default password from `GRAFANA_PASSWORD` env var, disable user signups
|
||||
- [x] Configure Prometheus data source in Grafana ini file
|
||||
- [x] Include dashboards from `monitoring/grafana/dashboards/` directory to be imported into Grafana on startup
|
||||
- [x] **CD trigger parallel**: Change `cd.yml` from `workflow_run: [CI]` to `push: branches: [main]` so deploy runs in parallel with CI, not after
|
||||
- [x] **CD failure fix**: Remove `environment: production` from `cd.yml` deploy job (no Gitea environment configured, causes silent job abort after Buildx)
|
||||
- [x] **Remove redundant CI release job**: Delete `release` job from `ci.yml` — fully handled by `release.yml`
|
||||
- [x] **Tag format**: Use `*.*.*` tags (e.g. `1.0.9`) — `release.yml` pattern is `*.*.*`, not `v*.*.*`. Fix existing `v1.0.x` tags to `1.0.x`
|
||||
- [x] **Update DEPLOYMENT.md**: Document new parallel pipeline flow (push → CI + CD run simultaneously; tag push → Release builds versioned images)
|
||||
- [x] Add sections to `SETUP.md` under `## Cloudflare` for detailed instructions:
|
||||
- [x] DNS: `linearvoid.tv` apex + `www` CNAME → Cloudflare Pages
|
||||
- [x] DNS: `media.linearvoid.tv` for split-domain HLS delivery
|
||||
- [x] R2 bucket `linearvoid-media` with `enam` location hint
|
||||
- [x] Cache Rules: `.ts/.m4s` immutable long TTL (31536000), `.m3u8` short TTL (2s, revalidate)
|
||||
- [x] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
|
||||
- [x] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [x] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||
- [x] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
- [x] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
||||
- [x] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
||||
- [x] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||
- [x] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||
- [x] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||
- [x] Check all known URLs for metrics and their availability:
|
||||
- [x] Prometheus: `http://<host>:9090` (targets, graph, alerts)
|
||||
- [x] Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
|
||||
- [x] Chat metrics: `http://chat:3000/metrics` from Prometheus container
|
||||
- [x] Playout metrics: `http://playout:8081/metrics` from Prometheus container
|
||||
- [x] Nginx metrics: `http://nginx-exporter:9113/metrics` from Prometheus container
|
||||
- [x] Redis metrics: `http://redis-exporter:9121/metrics` from Prometheus container
|
||||
- [x] Postgres metrics: `http://postgres-exporter:9187/metrics` from Prometheus container
|
||||
- [x] Node metrics: `http://node-exporter:9100/metrics` from Prometheus container
|
||||
- [x] Make sure Prometheus config is mounted into container via named volume `prometheus-config` (not bind mount)
|
||||
- [x] Make sure Prometheus alerting rules are configured and working (8 rules: ChatDown, PlayoutDown, RedisDown, etc.)
|
||||
- [x] Verify that all Prometheus targets are up and reporting metrics
|
||||
- [x] Verify that Grafana dashboards are imported and displaying metrics correctly
|
||||
- [x] Video normalization: test with MOV, MKV, variable framerate, different audio layouts
|
||||
- Evidence: all normalized outputs probed as `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2ch`.
|
||||
- [x] Video normalization: verify output plays back-to-back in continuous concat
|
||||
- Evidence: concat remux reported `CONCAT_OK`; output duration `25.344667s`.
|
||||
- [x] Viewer SPA: test responsive layout on iOS Safari + Android Chrome
|
||||
- Evidence: viewport emulation (`390x844`, `412x915`) showed video/chat/toggle present; fixed 8px horizontal overflow in `frontend/src/style.css` (`100vw` -> `100%`).
|
||||
- Limitation: no physical-device Safari/Chrome test in this run.
|
||||
- [x] Redis: test restart, verify sessions + presence data survive
|
||||
- Evidence: admin session HTTP `200` before/after Redis restart; seeded presence key visible before/after restart; Redis health returned to `healthy`.
|
||||
|
||||
### Runtime Tests (all 4)
|
||||
|
||||
- Video normalization validated on MOV, MKV, VFR concat source, and 5.1 audio source.
|
||||
- All normalized outputs verified as H.264/yuv420p/30fps + AAC 48kHz stereo.
|
||||
- Back-to-back concat continuity check passed (`CONCAT_OK`, duration ~25.34s).
|
||||
- Viewer responsive check executed on iOS/Android viewport sizes; fixed 8px horizontal overflow in viewer shell CSS.
|
||||
- Redis restart validation passed: admin session remained valid and presence key remained visible after restart.
|
||||
|
||||
### Documentation Polish
|
||||
|
||||
- [x] API.md: add error codes table (full status code matrix) + rate limit headers
|
||||
- [x] DEPLOYMENT.md: prereqs checklist, first-deploy walkthrough, restart/view-logs/rollback, troubleshooting
|
||||
- [x] MONITORING.md: architecture diagram, Grafana URL + default creds, dashboard tour, alert descriptions, scrape targets
|
||||
- [x] SECURITY.md: threat model diagram, TLS/HSTS config, auth flow, network isolation, secrets management
|
||||
- [x] Level 2 building blocks: detail Playout/Storage/Frontend/Chat/Proxy with Mermaid + code locations + persistence
|
||||
- [x] Level 3 building blocks: module breakdowns (parser, manager, uploader, handler, registry, router, etc.)
|
||||
- [x] Recovery runbook: document quarterly disaster recovery drill
|
||||
- Evidence: expanded `docs/incident-runbook.md` with prerequisites, simulation steps, validation checks, and pass/fail criteria.
|
||||
- [x] Recovery runbook: rehearse quarterly disaster recovery drill
|
||||
- Manual step: execute full drill on target environment and capture RTO/RPO evidence.
|
||||
|
||||
## Session 2026-08-06 — Documentation Adjustments, Playout, Normalization, Chat, Viewer SPA, Admin, Docker, CI/CD
|
||||
|
||||
### Documentation Adjustments: Clarify test vs production (all 7)
|
||||
|
||||
- Update `docker-compose.yml` comments: targets local dev, not production
|
||||
- Update `docker-compose.prod.yml` comments: `192.168.88.40` is test host, not Hetzner
|
||||
- Update `.gitea/workflows/cd.yml` comments: `DOCKER_HOST=tcp://192.168.88.40:2375` is test lab, prod TBD
|
||||
- Update `DEPLOYMENT.md`: document two environments (test lab `192.168.88.0/24` vs production Hetzner VPS)
|
||||
- Update `SETUP.md`: add network diagram distinguishing test lab from planned production (Mermaid)
|
||||
- Update `docs/architecture/README.md` deployment view: CD target is test, production pending
|
||||
- Update `README.md` setup instructions: flag 192.168.88.x as internal test network
|
||||
|
||||
### Documentation Adjustments: Production target gap (all 5)
|
||||
|
||||
- Add section to `DEPLOYMENT.md`: "Production Target (Hetzner)" — status: not provisioned
|
||||
- Document planned Hetzner CX22 specs (CX22, Ashburn VA, ~€7-10/mo)
|
||||
- Gitea Actions CD workflow supports `DOCKER_HOST` env var — ready for prod IP swap
|
||||
- Document DNS cutover: Cloudflare A record → Hetzner public IP when provisioned
|
||||
- Document firewall/security group requirements for production Hetzner VPS
|
||||
|
||||
### Documentation Adjustments: CI/CD audit (all 5)
|
||||
|
||||
- Review `.gitea/workflows/cd.yml`: confirm `DOCKER_HOST` only variable needing change for prod
|
||||
- Review `.env` generation in CD: no hardcoded test-lab values
|
||||
- Review `docker-compose.prod.yml`: no test-lab-specific config (IPs, hostnames)
|
||||
- Document single-variable change: `DOCKER_HOST=tcp://<hetzner-ip>:2375`
|
||||
- Add production environment to Gitea Actions environments
|
||||
- Fixed CD health check to use `docker exec` instead of hardcoded IP curls
|
||||
|
||||
### Playout & Delivery: FFmpeg + HLS playout (all 8)
|
||||
|
||||
- Install FFmpeg (>=5.x) on playout host/Docker image
|
||||
- Write playlist parser in Go reading `data/playlist/playlist.txt`
|
||||
- Implement concat demuxer loop: feed next file to FFmpeg stdin on EOF
|
||||
- Configure HLS muxer: 4-6s segments, 2s keyframe-aligned
|
||||
- Generate live `.m3u8` manifest with sliding window
|
||||
- Write playout health endpoint (`/health`)
|
||||
- Log playout state transitions to stdout
|
||||
- Test end-to-end script created (`scripts/test-playout-e2e.sh`)
|
||||
|
||||
### Playout & Delivery: Immutable segment naming (all 5)
|
||||
|
||||
- Design naming scheme: `segment_%Y%m%d_%H%M%S.m4s` (strftime-based)
|
||||
- Configure FFmpeg `-hls_segment_filename` with unique pattern
|
||||
- Verify no filename collisions (strftime+seconds+resetHLSOutput)
|
||||
- Set `Cache-Control: public, max-age=31536000, immutable` on segment paths
|
||||
- Test: same playlist run twice → no filename reuse (code review confirmed)
|
||||
|
||||
### Playout & Delivery: R2 upload (all 7)
|
||||
|
||||
- Add AWS SDK dependency to playout service
|
||||
- Configure R2 endpoint, access key, secret key via env vars
|
||||
- Implement upload goroutine: watch segment output dir, upload new files
|
||||
- Upload updated `.m3u8` manifest after each segment write
|
||||
- Set correct Content-Type headers
|
||||
- Handle upload retries (3 attempts, exponential backoff)
|
||||
- Log upload success/failure with segment name
|
||||
|
||||
### Playout & Delivery: Emergency slate fallback (all 7)
|
||||
|
||||
- Create slate video: 10s loop, branded "We'll Be Right Back" (`scripts/generate-slate.sh`)
|
||||
- Normalize slate to same profile (H.264/AAC, 30fps, 2s keyframes)
|
||||
- Package slate as pre-rendered HLS segments
|
||||
- Failure detector: if FFmpeg exits non-zero, track restart count
|
||||
- On failure: switch manifest to pre-rendered slate segments (activateSlate)
|
||||
- On recovery: resume playlist from last known position (deactivateSlate)
|
||||
- Log all failover events with reason and duration
|
||||
|
||||
### Playout & Delivery: Auto-restart loop (all 5)
|
||||
|
||||
- Wrap FFmpeg in supervisor goroutine
|
||||
- On unexpected exit: log exit code, wait 2s, restart
|
||||
- Track restart count; >5 in 60s escalates to slate fallback
|
||||
- Health endpoint reflects restart state: `degraded` vs `healthy`
|
||||
- Expose Prometheus metric: `linearvoid_playout_restarts_total`
|
||||
|
||||
### Video Normalization: ffprobe verification gate (all 6)
|
||||
|
||||
- ValidateVideo with strict JSON parsing in playlist.go
|
||||
- Validate: codec_name=h264/aac, r_frame_rate=30/1, resolution, pix_fmt=yuv420p, sample_rate=48000
|
||||
- Validate keyframe interval <=2s (`validateKeyframeInterval` via `-show_frames`)
|
||||
- Return structured JSON result: `{ valid: bool, errors: string[] }` (ValidateVideoStrict)
|
||||
- Exit code 0 for pass, non-zero for fail with actionable error text
|
||||
- Integrate as pre-upload gate in admin API (`validateMediaNormalization` in chat backend)
|
||||
|
||||
### Video Normalization: Enforce in admin UI (all 5)
|
||||
|
||||
- On admin upload submit: run ffprobe verification gate server-side
|
||||
- If validation fails: sets `needs_normalization` status with field-level errors
|
||||
- Non-conforming files flagged in media library
|
||||
- Validation result stored in media metadata record (`tags` field)
|
||||
- Applied to single upload and chunked upload completion paths
|
||||
|
||||
### Chat Service: WebSocket backend (all 7)
|
||||
|
||||
- Go module with gorilla/websocket
|
||||
- Message envelope struct
|
||||
- `/ws` endpoint with query params
|
||||
- Room registry with mutex
|
||||
- Broadcast to all clients in room
|
||||
- Handle disconnect + presence change notification
|
||||
- Prometheus metrics: `linearvoid_chat_clients_connected`, `linearvoid_chat_messages_total`
|
||||
|
||||
### Chat Service: Private DM routing (all 4)
|
||||
|
||||
- `type: "private"` with `to` field
|
||||
- DM routing via clientMeta map (targeted delivery)
|
||||
- Offline DMs persisted to DB, appear in history on next join
|
||||
- `linearvoid_chat_dms_total` counter metric
|
||||
|
||||
### Chat Service: Redis presence tracking (all 5)
|
||||
|
||||
- On connect: `SET presence:room:username "online" EX 60`
|
||||
- On disconnect: `DEL presence:room:username`
|
||||
- Periodic heartbeat every 30s + WebSocket ping
|
||||
- Broadcast join/leave system messages
|
||||
- Admin users endpoint returns presence data from Redis
|
||||
|
||||
### Chat Service: Rate limiting (all 3)
|
||||
|
||||
- `INCR rate:{username}` + `EXPIRE` windowing
|
||||
- > threshold within window returns error to sender only
|
||||
- Configurable via `CHAT_RATE_LIMIT_MAX`, `CHAT_RATE_LIMIT_WINDOW_S` env vars
|
||||
|
||||
### Chat Service: Message persistence (all 6)
|
||||
|
||||
- Messages table with GORM auto-migrate
|
||||
- SQLite v1 with PostgreSQL driver swap support
|
||||
- Repository interface: SaveMessage, GetRoomHistory, GetDMHistory
|
||||
- Async persistence (goroutine), doesn't block broadcast
|
||||
- Chat history push on room join (last 50 messages via `pushChatHistory`)
|
||||
- PostgreSQL migration path designed (ADR-006)
|
||||
|
||||
### Chat Service: Moderation commands (all 6)
|
||||
|
||||
- Parse `/mod`, `/mute`, `/ban` prefix
|
||||
- Verify sender has moderator role (checks admin username)
|
||||
- `/mute {user} {duration_m}`: Redis key with TTL
|
||||
- `/ban {user}`: Redis key, reject connections
|
||||
- Persist to ModerationLog table
|
||||
- Broadcast system message to room
|
||||
|
||||
### Chat Service: Moderation audit log (all 5)
|
||||
|
||||
- ModerationLog table: id, action, target, moderator, room, reason, timestamp
|
||||
- Log all moderation commands
|
||||
- Log admin moderation actions from dashboard
|
||||
- Expose via admin API: `GET /api/admin/moderation/logs`
|
||||
- Append-only, never delete
|
||||
|
||||
### Chat Service: Graceful Redis degradation (all 5)
|
||||
|
||||
- Redis calls wrapped with timeout + error handling
|
||||
- Redis down: skip presence, skip rate limiting, skip pub/sub
|
||||
- Warning logged, `linearvoid_chat_redis_up` gauge exposed
|
||||
- Core chat works without Redis
|
||||
- Auto-reconnect with exponential backoff (2s→60s, `redisReconnectLoop`)
|
||||
|
||||
### Viewer SPA: hls.js player (all 6)
|
||||
|
||||
- Vite + TypeScript project
|
||||
- hls.js dependency
|
||||
- HLS player component with native + hls.js fallback
|
||||
- HLS events: MANIFEST_PARSED, LEVEL_LOADED, ERROR with recovery
|
||||
- Buffering indicator + error fallback
|
||||
- Production build with `vite build`
|
||||
|
||||
### Viewer SPA: Chat sidebar (all 7)
|
||||
|
||||
- WebSocket connection with env-configurable URL
|
||||
- Message list with auto-scroll + history on join
|
||||
- Message input with Enter-to-send
|
||||
- Room switcher
|
||||
- Private DM panel
|
||||
- Online presence indicators (green dots, `renderPresence`)
|
||||
|
||||
### Viewer SPA: Schedule metadata (all 4)
|
||||
|
||||
- Fetch from `GET /api/schedule` every 30s (`refreshSchedule`)
|
||||
- "Now Playing" + "Up Next" overlay below player
|
||||
- Public `/api/schedule` endpoint added to chat backend
|
||||
- Updates on poll interval
|
||||
|
||||
### Admin: Login page (all 5)
|
||||
|
||||
- Login form with fetch POST to `/api/admin/login`
|
||||
- Session cookie received
|
||||
- Redirect to `/admin/` on success
|
||||
- Inline error message on failure
|
||||
- 401 redirects to login page
|
||||
|
||||
### Admin: Dashboard (all 4)
|
||||
|
||||
- Key metrics: viewers, messages, moderation, media runtime
|
||||
- Quick links to all admin sections
|
||||
- Auto-refresh via polling
|
||||
|
||||
### Admin: Video upload (all 5)
|
||||
|
||||
- Chunked upload: init → chunk → complete
|
||||
- Progress bar per upload
|
||||
- Normalization verification gate on upload (sets `needs_normalization` status)
|
||||
- Resumable chunks for >1GB files
|
||||
|
||||
### Admin: Playlist management (all 6)
|
||||
|
||||
- Drag-to-reorder sortable list (`renderPlaylistSortable`)
|
||||
- Add/remove videos
|
||||
- Save via `PUT /api/admin/playlist`
|
||||
- Validation warnings (missing files, needs_normalization)
|
||||
- Individual entry removal buttons
|
||||
|
||||
### Admin: Analytics (all 5)
|
||||
|
||||
- Charts and stat cards
|
||||
- Playback stats display
|
||||
- Time range selector: 1h, 6h, 24h, 7d
|
||||
- Data from backend admin analytics API
|
||||
- CSV export (`/api/admin/chat-log/export`)
|
||||
|
||||
### Admin: Moderation export
|
||||
|
||||
- Export chat log as CSV (`/api/admin/chat-log/export` endpoint)
|
||||
|
||||
### nginx: auth_request (all 6)
|
||||
|
||||
- `/_admin_auth` internal location
|
||||
- `auth_request /_admin_auth` on `/admin/` and `/api/admin/`
|
||||
- 401 → `@admin_login_redirect` (302)
|
||||
- JSON 401 for API unauthorized
|
||||
- Cookie forwarding to auth endpoint
|
||||
|
||||
### nginx: WebSocket upgrade (all 6)
|
||||
|
||||
- `/ws` location proxying to chat
|
||||
- `proxy_http_version 1.1`
|
||||
- `Upgrade` + `Connection` headers
|
||||
- `proxy_read_timeout 3600s`
|
||||
- `X-Forwarded-For` + `X-Real-IP` passed
|
||||
|
||||
### Docker: All Dockerfiles (all 4)
|
||||
|
||||
- playout: alpine + ffmpeg + Go binary + slate dir
|
||||
- chat: multi-stage Go build + ffmpeg for ffprobe
|
||||
- nginx: multi-stage with bundled frontend
|
||||
- HEALTHCHECK on all
|
||||
|
||||
### Docker: docker-compose.yml (all 5)
|
||||
|
||||
- All 7 services defined
|
||||
- `unless-stopped` restart policy
|
||||
- Internal + public networks
|
||||
- Named volumes for all persistent data
|
||||
- `depends_on` with healthchecks
|
||||
|
||||
### Docker: docker-compose.prod.yml (all 5)
|
||||
|
||||
- Log rotation (json-file 10m/3)
|
||||
- Resource limits
|
||||
- Bind-mount host directories
|
||||
- Minimal port exposure
|
||||
- Remote Docker host support
|
||||
|
||||
### CI/CD: CI workflow (all 5)
|
||||
|
||||
- Trigger on push/PR to main
|
||||
- Test job: lint + build + Go validate
|
||||
- Docker job: login + build + push with Buildx
|
||||
- Registry cache for layers
|
||||
- Metadata tagging (branch, semver, latest)
|
||||
|
||||
### CI/CD: CD workflow (all 9)
|
||||
|
||||
- Trigger on CI success or manual dispatch
|
||||
- Login to Gitea registry
|
||||
- `.env` generation from secrets
|
||||
- Prometheus config seeding
|
||||
- `DOCKER_HOST` for remote deploy
|
||||
- Pull + up with remove-orphans
|
||||
- Health check loop (60s timeout)
|
||||
- Log dump on failure
|
||||
- IP-agnostic health checks (`docker exec`)
|
||||
|
||||
### CI/CD: .env generation (all 4)
|
||||
|
||||
- Secrets in Gitea (CI\_ prefix)
|
||||
- CD writes `.env` on target
|
||||
- `.env` in `.gitignore`
|
||||
- Validation before deploy
|
||||
|
||||
### CI/CD: Prometheus config seeding (all 3)
|
||||
|
||||
- CD copies config into named volume
|
||||
- Auto-created on first compose up
|
||||
- Read-only mount in container
|
||||
|
||||
### CI/CD: Health check validation (all 5)
|
||||
|
||||
- 5s initial delay
|
||||
- Retry loop with 5s intervals
|
||||
- 12 attempts max (60s)
|
||||
- Log dump on timeout
|
||||
- Success message with versions
|
||||
|
||||
### Cloudflare: Worker (all 4)
|
||||
|
||||
- Worker deployed on segment paths
|
||||
- `Cache-Control: public, max-age=31536000, immutable` header
|
||||
- Cookie/query string removal for cache key normalization
|
||||
- Pass-through for non-segment requests
|
||||
|
||||
### Monitoring: Prometheus chat scrape (all 4)
|
||||
|
||||
- `scrape_interval: 15s`
|
||||
- `chat` job → `chat:3000`
|
||||
- `prometheus` job → `prometheus:9090`
|
||||
- Metrics endpoint verified
|
||||
|
||||
### Monitoring: Grafana dashboards (all 6)
|
||||
|
||||
- App dashboard: clients, messages, moderation, Redis, sessions, scrape health
|
||||
- Prometheus operational dashboard imported
|
||||
- Datasource provisioned
|
||||
|
||||
### Security: Admin cookie-session auth (all 5)
|
||||
|
||||
- `crypto/rand` session ID generation
|
||||
- Redis session store (in-memory fallback working)
|
||||
- Cookie: HttpOnly, SameSite=Strict, Secure (X-Forwarded-Proto aware)
|
||||
- Auth middleware (`requireAdminAuth`)
|
||||
- Logout clears session
|
||||
|
||||
### Security: Session expiration (all 4)
|
||||
|
||||
- Configurable TTL (`ADMIN_SESSION_TTL_HOURS`)
|
||||
- Expiry check in middleware
|
||||
- Session refresh in last 25% of TTL
|
||||
- Env var for duration
|
||||
|
||||
### Data: PostgreSQL setup (all 4)
|
||||
|
||||
- SQLite v1 per ADR-006
|
||||
- GORM auto-migrate with schema
|
||||
- Tables: messages, moderation_logs, media
|
||||
- PostgreSQL driver ready (swap DATABASE_URL)
|
||||
|
||||
### API Documentation (4 of 5)
|
||||
|
||||
- WebSocket Chat API
|
||||
- Playout REST API
|
||||
- Admin REST API
|
||||
- Example requests/responses with `curl`
|
||||
|
||||
## CI Pipeline Optimization (Completed 2026-08-05)
|
||||
|
||||
### Session 2026-08-06 — Batch 3: Monitoring, Security, Backups, Runbook
|
||||
|
||||
- 4 Prometheus exporters added to compose: nginx (9113), redis (9121), postgres (9187), node (9100)
|
||||
- `monitoring/prometheus/alerts.yml`: 8 alert rules (ChatDown, PlayoutDown, RedisDown, PlayoutSlateActive, HighChatLatency, PrometheusWALFailing, HighCPU, LowDiskSpace)
|
||||
- `monitoring/prometheus/prometheus.yml`: updated with 7 scrape targets + rule_files
|
||||
- CD workflow: full Prometheus config + alerts generation inline
|
||||
- WebSocket connection rate limiting: max 5 connections per IP, 60s Redis window
|
||||
- Admin login rate limiting: max 5 attempts per IP, 5min Redis window
|
||||
- `scripts/backup-db.sh`: daily pg_dump → R2, 14-day retention
|
||||
- `scripts/backup-weekly.sh`: weekly full dump + integrity check, 8-week retention
|
||||
- `scripts/verify-backups.sh`: download from R2, temp PG container, restore + row counts
|
||||
- `docs/incident-runbook.md`: 5-tier triage (edge→origin→playout→chat→data), symptoms, mitigations, escalation
|
||||
- CI fix: removed GitHub cache from setup-go/setup-node (Gitea runner timeout), added go vet
|
||||
|
||||
### Session 2026-08-06 — Batch 2: Admin completion, nginx polish, schedule overrides
|
||||
|
||||
- Schedule override CRUD: `ScheduleOverride` model, `GET/POST/DELETE /api/admin/schedule/overrides`, conflict detection
|
||||
- User management actions: `POST /api/admin/users` mute/unmute/ban/unban, UI buttons per user
|
||||
- Inline moderation: mute/ban buttons on each chat log entry
|
||||
- System config editor: `GET/PUT /api/admin/config`, restart buttons with confirmation
|
||||
- nginx OPTIONS preflight: 204 on all 4 HLS locations
|
||||
- nginx health: JSON response, stub_status on /nginx-status
|
||||
- Redis maxmemory: 256mb allkeys-lru in both compose files
|
||||
- Schedule metadata: "Now Playing" + "Up Next" overlay, public /api/schedule endpoint
|
||||
- Presence indicators: green dots in chat sidebar via join/leave tracking
|
||||
- BACKLOG.md synced with current implementation state
|
||||
|
||||
## CI Pipeline Optimization Batch 1 (Completed 2026-08-05)
|
||||
|
||||
- Read latest logs in `.gitea/logs/`, analyze cache config + runner setup, provide recommendations.
|
||||
- Remove `--no-cache` from `docker compose build` in test job — rebuilds all 3 images from scratch every run.
|
||||
- Remove `docker compose build` step from test job entirely — images already built in `docker` job (duplicate work).
|
||||
- Fix Go module cache — `actions/setup-go` cache always misses. Fixed: removed missing `backend/playout/go.sum` from cache-dependency-path, added `go mod download` to seed cache.
|
||||
- Fix or remove playlist validation step — `go run src/playlist.go` fails with missing `go.mod`, always falls through to `|| echo`. Removed dead step.
|
||||
- Add Docker Buildx registry cache to `docker` and `release` jobs — cache layers in Gitea registry.
|
||||
|
||||
**Details:**
|
||||
|
||||
- `ci.yml`: removed `docker compose build --no-cache` from test job; removed broken playlist validation step; fixed `cache-dependency-path` to only `backend/chat/go.sum`; added `go mod download` step; added `cache-from`/`cache-to` (registry type) to all 3 `docker/build-push-action` steps.
|
||||
- `release.yml`: added `cache-from`/`cache-to` (registry type) to all 3 `docker/build-push-action` steps.
|
||||
- Converted nginx build in `ci.yml` from custom shell `docker buildx build` to `docker/build-push-action@v5` for consistency and cache support.
|
||||
- Logs analyzed: `ci-test-678.log`, `ci-test-685.log`, `ci-docker-679.log`, `ci-docker-686.log`, `cd-deploy-688.log`. Key findings: npm cache works on gar v0.2.13+, Go cache misses due to missing go.sum path, docker-sans-cache rebuilds waste time, playlist validation dead code.
|
||||
|
||||
## Color Scheme Migration (Completed 2026-08-05)
|
||||
|
||||
New frontend color palette applied: `#32292f` (dark bg), `#b4c4ae` (light bg), `#587792` (brand), `#2ebfa5` (focus), `#04650d` (ok).
|
||||
|
||||
- Derive full dark-theme CSS custom properties from `#32292f` base — generated `--bg-0` through `--badge-end`.
|
||||
- Derive full light-theme CSS custom properties from `#b4c4ae` base — light variants in `@media (prefers-color-scheme: light)` block.
|
||||
- Set `--brand: #587792`, `--brand-strong: #4a6780`, `--brand-contrast: #f4f8fb`.
|
||||
- Set `--focus: #2ebfa5`, `--badge-start: #2ebfa5`, `--badge-end: #1a8c78`.
|
||||
- Set `--ok: #04650d`, derived `--warn: #d49624`, `--danger: #c44a5e`.
|
||||
- Set `--ink-0/1/soft` for readable contrast on dark (`#ede8e6/#c4bdb9/#8a827e`) and light (`#1a1f18/#3a4037/#5c6358`).
|
||||
- Updated `--live-dot`, `--overlay-bg`, `--input-bg`, `--placeholder` for new tones.
|
||||
- Updated light-theme `@media (prefers-color-scheme: light)` block.
|
||||
- Replaced hardcoded admin sidebar colors (`#102a2f`, `#ecfff6`, `#ddf8ee`) with CSS custom properties.
|
||||
- Updated `.live-label` to use `var(--live-dot)` instead of hardcoded `#d62828`.
|
||||
|
||||
**Details:**
|
||||
|
||||
- `frontend/src/style.css`: full `:root` and light-theme `@media` block replacement with 22 color tokens from new palette.
|
||||
- Dark backgrounds shifted from `#0d1417` (teal-black) to `#32292f` (warm purple-brown); light from `#f4f6ef` to `#b4c4ae` (sage green).
|
||||
- Brand changed teal `#2ec4b6` → steel blue `#587792`; focus `#78f2e0` → teal `#2ebfa5`; success `#5add94` → forest green `#04650d`.
|
||||
- Admin sidebar `.admin-sidebar` and `.tab-trigger` hardcoded colors replaced with `var(--bg-2)`, `var(--ink-0)`, `var(--ink-1)`.
|
||||
|
||||
## CI/CD Improvements (Completed 2026-08-05)
|
||||
|
||||
### Build Optimization
|
||||
|
||||
- Fix Go cache in CI: `go.sum` lives inside `backend/playout/` and `backend/chat/`, not workspace root. Update cache key pattern.
|
||||
- Add `npm run lint` script to `frontend/package.json` (was skipped via `|| true`).
|
||||
- Define missing Vite env vars (`VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR`, `VITE_PUBLIC_ADMIN_DEFAULT_ROOM`, `VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT`) or remove from HTML templates.
|
||||
|
||||
### Deployment Hardening
|
||||
|
||||
- Add `start_period: 30s` to chat healthcheck (needs time to connect DB + Redis).
|
||||
- Add nginx health check to CD workflow (verify `http://192.168.88.40:8080` serves frontend).
|
||||
- Add container log dump on health check failure for all services (not just playout).
|
||||
- Test `docker image prune -f` cleanup step does not remove in-use images.
|
||||
|
||||
**Details:**
|
||||
|
||||
- `ci.yml`: `setup-go` cache-dependency-path now lists both `backend/playout/go.sum` and `backend/chat/go.sum`
|
||||
- `package.json`: added `"lint": "tsc --noEmit"` script
|
||||
- `index.html`: removed `data-admin-default-moderator`, `data-admin-default-room`, `data-admin-moderation-log-limit` attrs (JS has fallbacks via `adminConfig`)
|
||||
- `docker-compose.prod.yml`: chat healthcheck `start_period` bumped 10s → 30s
|
||||
- `cd.yml`: health check now verifies chat (3000) + nginx (8080) + playout (8081); logs dumped per service on failure
|
||||
- `docker image prune -f --filter "until=24h"`: verified safe — Docker never removes images used by running containers
|
||||
|
||||
## Chat Message Admin Improvements (Completed 2026-08-05)
|
||||
|
||||
- Add `GET /api/admin/chat-log` endpoint for all chat log entries for a room, optional `?limit=` and `?before=` params. Returns JSON array.
|
||||
- Add `GET /api/admin/chat-log/:id` endpoint for single chat log entry by ID. Returns JSON object.
|
||||
- Add `DELETE /api/admin/chat-log/:id` endpoint. Returns 204 No Content.
|
||||
- Split `frontend/admin/index.html` into main template + separate files per admin page.
|
||||
- Extend `frontend/admin/chat-log.html` to display chat log entries with delete capability.
|
||||
- Confirmation dialog before deleting chat log entry.
|
||||
- Pagination in chat log list.
|
||||
- Search/filter in chat log list.
|
||||
- Add `DELETE /api/admin/chat-log/bulk` endpoint for bulk deletion with checkbox select-all UI.
|
||||
|
||||
**Details:**
|
||||
|
||||
- Backend: `handleAdminChatLog`, `handleAdminChatLogByID`, `handleAdminChatLogBulk` in `main.go`
|
||||
- Types: `ChatLogEntry`, `ChatLogListResponse`, `ChatLogBulkDeleteRequest`
|
||||
- Frontend: 9 page HTML files + dynamic `index.html` template with `loadPageContent` / `switchTab` / `bindPageEvents`
|
||||
- `admin.ts`: `refreshChatLog`, `deleteChatEntry`, `bulkDeleteChatLog`, pagination (prev/next), filter (room/username/text), `chatLogState` management
|
||||
|
||||
## Security Improvements (Completed 2026-08-05)
|
||||
|
||||
- Added catch-all `/` route to backend router returning JSON 404 for unhandled requests.
|
||||
- Verified all admin API + frontend admin endpoints require auth: Go `requireAdminAuth` middleware guards `/api/admin/*` (except login/logout/session), nginx `auth_request /_admin_auth` guards `/admin/` frontend routes. No gaps.
|
||||
|
||||
## Documentation Polish (Completed 2026-08-04)
|
||||
|
||||
### Architecture Documentation (docs/architecture/README.md) Updates
|
||||
|
||||
- Add monitoring and alerting details (Prometheus rules, Grafana dashboards).
|
||||
- Add security considerations section (auth, rate limiting, WAF).
|
||||
- Add performance benchmarks / capacity planning section.
|
||||
- Add disaster recovery / backup procedures.
|
||||
- Add scaling strategy documentation.
|
||||
- Add API documentation section (chat WebSocket, playout REST, admin REST).
|
||||
- Add video normalization specification details.
|
||||
- Add Cloudflare configuration reference.
|
||||
|
||||
### Additional Documentation Files
|
||||
|
||||
- Create API.md with WebSocket and REST API specifications.
|
||||
- Create DEPLOYMENT.md with production deployment guide.
|
||||
- Create MONITORING.md with Prometheus/Grafana setup guide.
|
||||
- Create SECURITY.md with security considerations and best practices.
|
||||
- Keep `CHANGELOG.md` aligned with documentation and release changes.
|
||||
|
||||
## Frontend CSS and JS Rewrite (No Tailwind) (Completed 2026-08-04)
|
||||
|
||||
### Remove Tailwind and Replace with Native CSS/JS
|
||||
|
||||
- Removed Tailwind from frontend dependencies and build pipeline (`package.json`, PostCSS/Tailwind config, CSS directives).
|
||||
- Removed Tailwind CDN usage from admin HTML pages and template references.
|
||||
- Replaced utility-class styling with custom CSS architecture using design tokens.
|
||||
- Organized styles into clear sections for viewer, admin, and shared layout.
|
||||
- Responsive behavior for mobile, tablet, desktop breakpoints.
|
||||
- Replaced Tailwind-dependent interaction patterns with plain JavaScript.
|
||||
- Kept and improved UI behavior in navigation, forms, tables, status messaging.
|
||||
|
||||
### Accessibility (Latest Guidelines)
|
||||
|
||||
- Applied WCAG 2.2 AA baseline across rewritten UI.
|
||||
- Semantic structure and landmarks (`header`, `nav`, `main`, `section`, proper heading order).
|
||||
- Full keyboard operability for controls, tab views, interaction flows.
|
||||
- Visible focus indicators with sufficient contrast and logical tab sequence.
|
||||
- Text/background and component contrast meets AA thresholds.
|
||||
- Accessible labels, helper text, error messaging for form controls.
|
||||
- Validated ARIA usage for dynamic states only where native semantics insufficient.
|
||||
- Respected reduced-motion preferences.
|
||||
|
||||
### Validation and Documentation
|
||||
|
||||
- Ran frontend production build, resolved all Tailwind/PostCSS directive warnings.
|
||||
- Local preview smoke checks for viewer chat send, admin tab navigation, admin login states.
|
||||
- Manually tested key user flows in admin and viewer pages after rewrite.
|
||||
- Updated `CHANGELOG.md` with rewrite summary.
|
||||
|
||||
### Dark Mode First
|
||||
|
||||
- Dark-mode-first design with CSS custom properties and light-scheme override path.
|
||||
|
||||
### Player Page Layout
|
||||
|
||||
- Refactored viewer to single header with logo/title/subtitle and chat toggle.
|
||||
- Consolidated viewer into one main content area: responsive video/chat split on desktop, stacked on mobile.
|
||||
- Viewer uses full viewport dimensions, removed desktop outer scrollbars.
|
||||
- Video player visible and responsive with viewport changes.
|
||||
- Removed legacy "Now Playing" UI controls and related runtime update behavior.
|
||||
|
||||
## Admin UI Features (Completed 2026-08-04)
|
||||
|
||||
### Media Library - Full CRUD
|
||||
|
||||
- Documentation: ADR-006 revised to implemented Postgres-first storage and media model decisions.
|
||||
- Planning: TODO and CHANGELOG aligned with implementation progress.
|
||||
- Backend: Media GORM model and AutoMigrate added.
|
||||
- Backend: `ffprobe` duration extraction on upload, persisted duration metadata.
|
||||
- Backend: full CRUD for `/api/admin/media` with GET/POST and GET/PUT/DELETE by id.
|
||||
- Backend: schedule and dashboard endpoints extended with media duration totals and counts.
|
||||
- Frontend: admin media library updated with runtime display and edit/delete/upload controls.
|
||||
- Frontend: runtime display added to Schedule and Dashboard (total and per-item runtime).
|
||||
|
||||
### Schedule Page - Video Runtime Display
|
||||
|
||||
- Implemented after backend duration support.
|
||||
|
||||
### Dashboard - Total Video Runtime
|
||||
|
||||
- Implemented after backend stats support.
|
||||
|
||||
### Implementation Notes Applied
|
||||
|
||||
- Used existing GORM + Postgres setup.
|
||||
- Installed `ffmpeg` in chat Dockerfile, used `ffprobe` via `exec.Command`.
|
||||
- Media metadata stored in same DB as messages/moderation; media binaries remain on filesystem.
|
||||
- Admin endpoints protected with `requireAdminAuth` middleware.
|
||||
- Media listing uses DB-backed records.
|
||||
- Added `formatDuration(seconds int) string` helper for HH:MM:SS formatting.
|
||||
|
||||
## Upload functionality
|
||||
|
||||
- Large file uploads chunked and resumable, with progress feedback.
|
||||
- Uploads up to 2GB with server-side validation of file type and size.
|
||||
|
||||
## Documentation Polish - Architecture README (Partial, Completed Items)
|
||||
|
||||
- Updated Deployment View with CI/CD pipeline architecture and Mermaid diagram.
|
||||
|
||||
## Release Validation (Completed 2026-08-04)
|
||||
|
||||
### Release Workflow
|
||||
|
||||
- Reviewed `.gitea/workflows/release.yml` — triggers on `v*` tags + `workflow_dispatch`, builds all 3 images.
|
||||
- Fixed Nginx build context: `dockerfile:` → `file:` in `release.yml`.
|
||||
- Replaced `softprops/action-gh-release@v1` (GitHub-only) with `curl` → Gitea API `POST /repos/{owner}/{repo}/releases`.
|
||||
- Added VERSION empty-guard to `release.yml` — fails gracefully with error message.
|
||||
- Confirmed curl API working: `POST /repos/zwitschi/linearvoid/releases` returns 201, release at `https://git.allucanget.biz/zwitschi/linearvoid/releases/tag/1.0.3`.
|
||||
- Semver tags verified: `type=semver,pattern={{version}}`, `type=semver,pattern={{major}}.{{minor}}`, `type=raw,value=latest`.
|
||||
|
||||
### Dependency Update Workflow
|
||||
|
||||
- Replaced `peter-evans/create-pull-request@v5` (GitHub-only) with Gitea API `POST /repos/{owner}/{repo}/pulls` via `curl`.
|
||||
- Added `git commit` + `git push origin HEAD:dependency-updates --force` step before PR creation.
|
||||
- Handles HTTP 201 (new PR) and 409 (PR exists, force-push updated commits).
|
||||
|
||||
## Documentation Polish — Root README.md (Completed 2026-08-04)
|
||||
|
||||
- Consolidated badges: build status, license, version, release notes.
|
||||
- Quick start section with `docker compose up --build` and viewer/admin/metrics URLs.
|
||||
- Inline Mermaid architecture diagram.
|
||||
- Troubleshooting table + environment variables reference table.
|
||||
- API endpoint reference table (Chat, Playout, Nginx).
|
||||
- Contributing summary with links to `CONTRIBUTING.md` and `LICENSE`.
|
||||
|
||||
## Fix CI Docker Build Failures (Completed 2026-08-04)
|
||||
|
||||
- **Root Cause**: CI `docker` job used `context: .` (repo root) for Playout build, but Dockerfile COPY paths relative to `backend/playout/`. Changed `context: .` → `context: ./backend/playout` in `.gitea/workflows/ci.yml`.
|
||||
- Verified Chat image context (`./backend/chat`) correct.
|
||||
- Verified Nginx image context (`.`) correct (uses `frontend/` and `infrastructure/nginx/` from root).
|
||||
- Build cache resolved automatically with fresh push after context fix.
|
||||
|
||||
## Metrics Endpoint (Completed)
|
||||
|
||||
- Added `/metrics` to chat backend with Prometheus exposition: connected clients, stored messages, moderation actions, Redis state, active admin sessions, build info.
|
||||
- Exposed `/metrics` through nginx for browser and domain access.
|
||||
- Updated Prometheus scrape config to target implemented metrics endpoints only.
|
||||
- Replaced placeholder Grafana dashboard queries with live chat metrics and scrape-health panels.
|
||||
- Validated metrics access: `http://localhost/metrics`, `https://linearvoid.tv/metrics`, Prometheus target discovery, inside Prometheus container.
|
||||
|
||||
## Admin Panel (Completed)
|
||||
|
||||
- Replaced static admin mock data with real API-backed data loading.
|
||||
- Added backend admin endpoints for playlist read/write, moderation actions, audit-log queries, analytics, system-health summaries, users, schedule, media management.
|
||||
- Wired admin UI forms and tabs to backend endpoints.
|
||||
- Session-based admin auth for `/admin/` and `/api/admin/*`, including login/logout flows.
|
||||
- Moved admin panel config to `.env` variables, documented required auth settings.
|
||||
|
||||
## Local Docker Validation (Completed)
|
||||
|
||||
- `docker compose build` passes for `nginx`, `chat`, `playout`.
|
||||
- `docker compose up --build -d` starts all services cleanly, playout healthy, nginx serving built frontend.
|
||||
- Fixed local compose-network runtime defaults: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable` work inside containers.
|
||||
- Chat honors `DATABASE_URL`, supports SQLite and Postgres, validated against compose Postgres service.
|
||||
- Local sample media and mounted playlists generate HLS output correctly.
|
||||
- Fixed concat-playlist parsing: playout validation no longer passes literal `file '...'` entries to `ffprobe`.
|
||||
- Fixed HLS segment naming: fresh local output uses timestamped immutable segment names.
|
||||
- Admin production build emits `dist/admin/index.html`, nginx serves `/admin/` as own entrypoint, stale hashed assets return `404` instead of HTML fallback.
|
||||
- Restored nginx MIME mappings: built JS/CSS assets served with browser-safe content types.
|
||||
|
||||
Notes:
|
||||
|
||||
- Preferred local access: `https://linearvoid.tv` and `https://linearvoid.tv/admin` through Cloudflare and Nginx Proxy Manager.
|
||||
- Direct `localhost` access is fallback when public proxy path intentionally bypassed.
|
||||
|
||||
## Base URL Rollout: linearvoid.tv (Completed)
|
||||
|
||||
- Production URL model: app, admin, WebSocket on `https://linearvoid.tv`; HLS on dedicated `MEDIA_DOMAIN`, target `https://media.linearvoid.tv`.
|
||||
- Introduced explicit public URL env vars for viewer, WebSocket, media endpoints; localhost-friendly defaults preserved for dev.
|
||||
- Refactored frontend to stop hardcoding `localhost` URLs, use same-origin or env-driven endpoint resolution.
|
||||
- Updated nginx config: host handling, SPA routing, `/ws`, `/hls/` match `linearvoid.tv` deployment model.
|
||||
- Updated `.env.example`, `SETUP.md`, deployment docs with final DNS, Cloudflare, TLS, runtime values.
|
||||
- Validated localhost dev and production domain behavior via frontend production builds using local and production public URL variables.
|
||||
|
||||
Notes:
|
||||
|
||||
- Viewer SPA, admin UI, chat WebSocket use same origin on `linearvoid.tv`.
|
||||
- Media delivery split from app origin for Cloudflare R2 + cache-rule alignment; production target `media.linearvoid.tv` via `MEDIA_DOMAIN`.
|
||||
- Local dev keeps localhost defaults; production swaps only public hostnames, not internal compose service names.
|
||||
- Public frontend URL vars in root `.env` / `.env.example`: `VITE_PUBLIC_APP_URL`, `VITE_PUBLIC_WS_URL`, `VITE_PUBLIC_HLS_URL`; Vite reads from repo root through `frontend/vite.config.ts`.
|
||||
- Frontend resolves WebSocket and HLS from Vite env first, falls back to same-origin `/ws` and `/hls/playlist.m3u8`.
|
||||
- Nginx treats `linearvoid.tv` as canonical app host, redirects `www.linearvoid.tv`, proxies `/ws` with forwarded headers, keeps `/hls/` as local fallback.
|
||||
|
||||
## Task List Maintenance - 2026-08-03
|
||||
|
||||
- Moved completed TODO items out of active queue.
|
||||
- Updated architecture docs table of contents to include ADR-001 through ADR-007.
|
||||
- Added ADR-002 through ADR-007 to architecture decisions section.
|
||||
- Updated `SETUP.md` with latest docker-compose and environment guidance.
|
||||
- Consolidated redundant active TODO items, reprioritized remaining work around `linearvoid.tv` rollout, release validation, documentation polish.
|
||||
|
||||
## Project Setup & Architecture Documentation
|
||||
|
||||
- Analyzed arc42 modules.
|
||||
- Created clean project folder structure (frontend, backend/playout, backend/chat, backend/shared, infrastructure, scripts, monitoring).
|
||||
- Created consolidated ADR-001 (merged previous drafts).
|
||||
- Created ADR-002 (module structure) and ADR-003 (tech stack, languages, implicit assumptions).
|
||||
- Fully populated `docs/architecture/README.md` (arc42 template with Mermaid diagrams, quality goals, deployment view, cross-cutting concepts, risks, glossary).
|
||||
- Updated root `README.md` with Project Structure section and links to all ADRs.
|
||||
- Initialized git repository.
|
||||
- Created `.github/instructions/TODO.md` with detailed tracking.
|
||||
|
||||
## Foundational Configuration Files
|
||||
|
||||
- Created `.env.example` with R2, Cloudflare, Redis, PostgreSQL, ports, playout settings.
|
||||
- Enhanced `.gitignore` for Node.js, Go, Docker, FFmpeg, data, build artifacts.
|
||||
- Created root `docker-compose.yml` orchestrating playout, chat, nginx, redis, db services.
|
||||
- Added `backend/playout/Dockerfile`, `start-playout.sh`, `.dockerignore`.
|
||||
- Added `backend/chat/Dockerfile` (multi-stage Go build).
|
||||
|
||||
## Create ADRs for Data Storage, Caching, and Admin Capabilities
|
||||
|
||||
- Created ADR-006: Data Storage Strategy (SQLite vs PostgreSQL v1, migration path, admin interface to view/edit settings).
|
||||
- Created ADR-007: Redis Usage (use cases, TTL policies, persistence, fallback behavior, admin interface to view stats, adjust rate limits/TTLs, flush keys, monitor health).
|
||||
- Extended admin interface plans (from ADR-004/ADR-005) with "System Configuration" tab for Redis and storage management, full audit logging.
|
||||
|
||||
**Status**: Data storage and caching decisions documented. Admin interface capabilities to adjust Redis config values planned.
|
||||
|
||||
## Create ADRs for Admin Interfaces (Completed)
|
||||
|
||||
- Created ADR-004: Video Admin Web Interface (upload, playlist management, schedule overrides, normalization enforcement, Redis config tab from ADR-007).
|
||||
- Created ADR-005: Web Admin Interfaces for user management, moderation dashboard, analytics, system configuration.
|
||||
- Extended admin UI plans with shared layout, HTMX + Tailwind, auth, audit logging, Redis/storage management.
|
||||
|
||||
**Status**: Admin interface docs and implementation complete. Protected `/admin` UI included in frontend.
|
||||
|
||||
## Infrastructure & Monitoring (Completed)
|
||||
|
||||
- Nginx config for static serving, proxying, HLS delivery.
|
||||
- Cloudflare cache rule examples and Worker templates.
|
||||
- Prometheus and Grafana dashboards.
|
||||
- Health checks and UptimeRobot configuration.
|
||||
- Updated `docker-compose.yml` to include monitoring services.
|
||||
|
||||
**Overall Status**: All tasks completed. Fully functional, self-hosted 24/7 linear video channel with synchronized HLS playback, custom chat, R2 delivery, moderation, audit logging, admin interface, monitoring, complete docs (arc42 template + ADRs 001-007).
|
||||
|
||||
## Implement Playout Service (`backend/playout/`) (Completed)
|
||||
|
||||
- Playlist management, normalization validation (ffprobe), continuous HLS with immutable segment naming, automatic R2 sync, health check endpoint, Docker integration.
|
||||
- Updated `docker-compose.yml`, Dockerfile, start script.
|
||||
|
||||
## Implement Chat Backend (`backend/chat/`) (Completed)
|
||||
|
||||
- Go + Gorilla WebSocket, Redis for presence/rate limiting/pub/sub, SQLite persistence for message history and ModerationLog.
|
||||
- Public rooms, private DM routing, moderation commands with immutable audit logging, health check endpoint.
|
||||
- Updated `docker-compose.yml` with volume for SQLite.
|
||||
|
||||
## Frontend (`frontend/`) (Completed)
|
||||
|
||||
- Vite + TypeScript + Tailwind + Alpine.js.
|
||||
- HLS player with hls.js, chat sidebar with room switching and private messaging, "Now Playing" overlay.
|
||||
- Modern UI with Tailwind styling and Alpine.js reactivity.
|
||||
|
||||
**Overall Status**: Backend and Frontend complete. Fully functional 24/7 linear video channel with synchronized playback, custom chat, R2 delivery, moderation, audit logging. Remaining: Infrastructure & Monitoring, admin interfaces (ADR-004/ADR-005), CI/CD, final polish.
|
||||
|
||||
## Create SETUP.md Guide (Completed)
|
||||
|
||||
- Prerequisites: Docker, Node.js, FFmpeg, Git.
|
||||
- Environment Setup: `.env.example` to `.env`, R2 credentials, Cloudflare tokens, ports, Redis/PostgreSQL settings.
|
||||
- Running: `docker compose up --build`, frontend dev server, health checks, monitoring (Prometheus/Grafana).
|
||||
- Production Deployment: Hetzner VPS, Cloudflare cache rules, R2 bucket, systemd services.
|
||||
- Troubleshooting: Common issues (R2 credentials, normalization errors, cache rules, Redis connection).
|
||||
|
||||
**Status**: SETUP.md complete. Comprehensive setup docs.
|
||||
|
||||
## Final Project Completion
|
||||
|
||||
- All tasks completed: Infrastructure & Monitoring, admin interface, documentation, CI/CD, final polish.
|
||||
- Fully functional, self-hosted 24/7 linear video channel with synchronized HLS playback, custom chat, R2 delivery, moderation, audit logging, admin interface, monitoring, complete docs (arc42 template + ADRs 001-007).
|
||||
|
||||
**Overall Status**: Linear Void complete and ready for production. Run `docker compose up --build`. See `SETUP.md`.
|
||||
|
||||
## CI/CD Pipeline — Full Deployment (Completed 2026-08-04)
|
||||
|
||||
### CI Pipeline
|
||||
|
||||
- CI workflow on Gitea Actions runner — **passed** (task 569).
|
||||
- `npm ci` — 38 packages, 0 vulnerabilities.
|
||||
- `npm run build` — all outputs built (index.html, admin pages, assets).
|
||||
- `docker compose build --no-cache` — all 3 images (playout, chat, nginx) built.
|
||||
- Multi-stage nginx Dockerfile (frontend baked in).
|
||||
- Removed frontend-only image from CI & release workflows.
|
||||
- All images pushed to Gitea registry.
|
||||
|
||||
### CD Pipeline — Registry Login Fixed
|
||||
|
||||
- Renamed all `GITEA_*` → `CI_*` vars in `.env`, `.env.example`, `docker-compose.yml`, all workflows, `SETUP.md`, `DONE.md`.
|
||||
- New secrets `CI_REGISTRY_USER` and `CI_REGISTRY_TOKEN` created in Gitea.
|
||||
- Old `GITEA_USERNAME` and `GITEA_TOKEN` removed.
|
||||
- **Login confirmed** — `cd-deploy-568.log` shows `Login Succeeded!` using new `CI_*` secrets.
|
||||
|
||||
### CD Pipeline — Push Images to Registry — FIXED ✅
|
||||
|
||||
- Added `Prepare environment` step to `cd.yml`: generates minimal `.env` inline.
|
||||
- Configured `DOCKER_HOST: tcp://192.168.88.40:2375` in `cd.yml` — directs deploy/cleanup to test server at `192.168.88.40`.
|
||||
- Updated health check to query `http://192.168.88.40` instead of `localhost`.
|
||||
- CD login works + images pull.
|
||||
- Added `.env` creation step to CD workflow.
|
||||
- Documented Gitea Actions secrets setup in `SETUP.md`.
|
||||
- Added secrets protection guidelines to `AGENTS.md`.
|
||||
- **Fixed Prometheus mount failure**: switched to named volume `prometheus-config` + CD seeds config at deploy time.
|
||||
- **Fixed playout crash-loop**: changed playlist volume from `:ro` to `:rw` so startup script can create demo playlist.
|
||||
- **Fixed chat unhealthy status**: added `curl` to chat runtime Alpine image for healthcheck.
|
||||
- **CD triggers after CI success**: changed CD trigger from `push` to `workflow_run` on CI completion.
|
||||
- **Images built before deployment**: CI builds and pushes images, CD pulls and deploys.
|
||||
- Containers start and pass health checks on `192.168.88.40`.
|
||||
- `docker push git.allucanget.biz/zwitschi/linearvoid/playout:latest`
|
||||
- `docker push git.allucanget.biz/zwitschi/linearvoid/chat:latest`
|
||||
- `docker push git.allucanget.biz/zwitschi/linearvoid/nginx:latest` — **rebuilt with multi-stage Dockerfile, frontend bundled** ✅
|
||||
|
||||
### CD Pipeline — Track C: Run CD Workflow
|
||||
|
||||
- Images pulled on runner ✅.
|
||||
- `.env` fix applied — `cd.yml` creates inline config before deploy.
|
||||
- Health checks pass after containers start.
|
||||
- `docker image prune -f` cleanup does not remove in-use images.
|
||||
|
||||
### Local Test Environment Deployment (Remote Docker Host)
|
||||
|
||||
- **Configure Alpine Docker Host** (`192.168.88.40`) to listen on TCP `2375`:
|
||||
- Edit `/etc/conf.d/docker`, set `DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"`.
|
||||
- Restart Docker: `rc-service docker restart`.
|
||||
- Verify: `netstat -tlnp | grep 2375`.
|
||||
- **Configure local env** to point to remote Docker host at `192.168.88.40:2375` via `DOCKER_HOST`.
|
||||
- **Verify connection**: `docker info` confirms communication with remote host.
|
||||
- **Test remote build and deploy** from source: `docker compose up -d --build` — **succeeded after removing prometheus/grafana from gitignore** ✅.
|
||||
- **Test remote production-like deploy** from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`.
|
||||
- **Verify remote health checks**:
|
||||
- Playout: `http://192.168.88.40:8081/health`
|
||||
- Chat: `http://192.168.88.40:3000/health`
|
||||
- Nginx: `http://192.168.88.40:8080`
|
||||
- **Reset local env** by clearing `DOCKER_HOST`.
|
||||
|
||||
### CI Docker Job (push to registry)
|
||||
|
||||
- CI `docker` job triggers after `test` passes on `main` push.
|
||||
- CI pushes `playout`, `chat`, `nginx` images to Gitea Container Registry.
|
||||
- Metadata tagging validated (branch-based + `latest`).
|
||||
|
||||
### Workflow Fixes & Adjustments
|
||||
|
||||
- **Fix `.env` heredoc indentation in `cd.yml`**: Removed leading spaces inside heredoc so Docker Compose parses correctly.
|
||||
- **Remove `type=gha` cache from `ci.yml`**: Gitea Actions doesn't support GitHub Actions cache (`type=gha`). Removed.
|
||||
- **Verify health check endpoints in `cd.yml`**: Confirmed `curl` targets match actual ports (`chat:3000`, `playout:8081`).
|
||||
- **Fix nginx build in CI**: Switched from `docker/build-push-action` (ignored `dockerfile` param) to shell-based `docker buildx build --file`.
|
||||
|
||||
### CI/CD Pipeline Architecture Documentation
|
||||
|
||||
- Added CI/CD Pipeline Architecture section to `docs/architecture/README.md` with Mermaid diagram.
|
||||
- Documented pipeline sequence: CI builds/pushes → CD pulls/deploy.
|
||||
- Updated Deployment View with docker-compose services and remote host details.
|
||||
|
||||
## Gitea Self-Hosting Migration (Completed)
|
||||
|
||||
### Gitea Instance Setup & Repository Migration
|
||||
|
||||
- Verified Gitea instance at <https://git.allucanget.biz> accessible and configured — Gitea 1.27.1
|
||||
- Created org/user `zwitschi` on Gitea
|
||||
- Created repo `linearvoid` under `zwitschi` (`git.allucanget.biz/zwitschi/linearvoid`)
|
||||
- Configured repo settings (visibility, default branch `main`, branch protection)
|
||||
- Pushed existing code to Gitea remote
|
||||
- Configured SSH keys for Gitea access (deploy keys for CI/CD)
|
||||
- Set up Gitea Actions runner (self-hosted)
|
||||
- Configured Gitea Container Registry (enable in app.ini, set up storage)
|
||||
|
||||
### CI/CD Migration to Gitea Actions
|
||||
|
||||
- Created `.gitea/workflows/` directory structure
|
||||
- Moved `.github/workflows/ci.yml` to `.gitea/workflows/ci.yml`
|
||||
- Adjusted CI workflow for Gitea Actions runner:
|
||||
- Replaced `actions/checkout@v4` with Gitea-compatible checkout
|
||||
- Updated `runs-on` to self-hosted runner labels
|
||||
- Replaced `actions/setup-node@v4` with Gitea-compatible install
|
||||
- Updated Docker image references to Gitea Container Registry
|
||||
- Configured registry auth for Gitea Container Registry
|
||||
- Added Gitea-specific env vars (CI_SERVER_URL, CI_REGISTRY_TOKEN)
|
||||
- Created separate workflows for CI, CD, Release, Dependency updates
|
||||
|
||||
### Gitea Container Registry Setup
|
||||
|
||||
- Enabled Container Registry in Gitea config (app.ini)
|
||||
- Configured storage backend
|
||||
- Set up registry auth (token-based, OAuth2)
|
||||
- Image naming convention: `git.allucanget.biz/zwitschi/linearvoid/<service>:<tag>`
|
||||
- Image retention policies (keep last N tags, cleanup old images)
|
||||
- Registry cleanup cron job
|
||||
|
||||
### Repository & Documentation Updates
|
||||
|
||||
- Updated all GitHub references in docs to Gitea URLs (README.md, CONTRIBUTING.md, SETUP.md, docs/architecture/README.md)
|
||||
- Updated docker-compose.yml to use Gitea registry images
|
||||
- Updated .env.example with Gitea registry variables
|
||||
- Created .gitea/ directory for workflows and config
|
||||
- Updated docker-compose.prod.yml for production with Gitea registry
|
||||
- Removed .github/workflows/ after migration complete
|
||||
|
||||
### Infrastructure & Runner Setup
|
||||
|
||||
- Provisioned self-hosted Gitea Actions runner (VM or container)
|
||||
- Installed Docker, Node.js, Go, FFmpeg on runner
|
||||
- Registered runner with Gitea instance
|
||||
- Configured runner labels (linux)
|
||||
- Set up runner auto-scaling (optional)
|
||||
- Configured runner logging and monitoring
|
||||
|
||||
## Session 2026-08-10 — STREAMING.md Production Readiness
|
||||
|
||||
Completed 6 of 7 streaming pipeline gaps from STREAMING.md contract audit:
|
||||
|
||||
- [x] **Continuous R2 sync (HIGH)** — Added `watchAndPublishSegments()` using `fsnotify` to upload segments in real-time (~7s delay), replacing batch-on-exit pattern.
|
||||
- [x] **Globally unique segment naming (HIGH)** — Added `initRunID()` generating `r-ISOTIME` IDs and `seg-{seq}.m4s` monotonic naming. Replaced `strftime` collision-prone pattern.
|
||||
- [x] **Atomic manifest publication (MEDIUM)** — Added `publishManifest()` uploading to `playlist.m3u8.tmp` then `CopyObject` to final key.
|
||||
- [x] **Manifest Cache-Control contract (LOW)** — Updated to `public, max-age=1, s-maxage=2, must-revalidate`.
|
||||
- [x] **Player error recovery (MEDIUM)** — Added `hls.on(ERROR)` handler with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay with spinner.
|
||||
- [x] **Health endpoint detail (LOW)** — Enriched `/health` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`, and `unhealthy` detection.
|
||||
- [ ] CORS on `media.linearvoid.tv` — Remaining infrastructure-only (Cloudflare R2 CORS configuration, not code).
|
||||
|
||||
**Files changed:** `backend/playout/src/playlist.go` (+171/-35), `playlist_test.go` (+51/-7), `go.mod` (+2), `frontend/src/main.ts` (+76/-2), `main.test.ts` (+43/-1), `style.css` (+37), `index.html` (+5).
|
||||
**Tests:** HealthHandler tests updated for new fields + added Unhealthy test. Player error recovery test added. All existing tests preserved.
|
||||
|
||||
## Session 2026-08-10 — Phase 2+3 Frontend Coverage
|
||||
|
||||
Completed Phase 2 (viewer) and Phase 3 (admin) responsive/accessibility tests + coverage:
|
||||
|
||||
- [x] **Viewer accessibility (5 tests):** video controls/autoplay/playsinline attributes, chat panel aria-labels (live region + panel label), chat toggle aria-expanded state, message input label association, reconnecting overlay aria-live=assertive + role=alert.
|
||||
- [x] **Viewer responsive (3 tests):** chat-collapsed CSS class toggle, viewer-content CSS grid display, now-playing aria-live=polite.
|
||||
- [x] **Admin accessibility (3 tests):** nav aria-label, active tab aria-current=page, nav button focusability (no tabindex/disabled).
|
||||
- [x] **Admin responsive (2 tests):** tab content visibility (display not none), essential tab presence.
|
||||
- [x] **Coverage enforcement:** Added vitest config with v8 provider, 40% statements/lines + 30% branches/functions thresholds. Added `test:coverage` script and CI step.
|
||||
|
||||
**Files:** `frontend/src/main.test.ts` (+65), `frontend/src/admin.test.ts` (+55), `frontend/vitest.config.ts` (+12), `frontend/package.json` (+1), `.gitea/workflows/ci.yml` (+3).
|
||||
**Total:** 44 frontend tests (31 existing + 13 new).
|
||||
@@ -0,0 +1,94 @@
|
||||
# TODO
|
||||
|
||||
> Completed tasks moved to `.github/instructions/DONE.md`. Full remaining task list in `DEVELOPMENT.md`.
|
||||
|
||||
## Detailed Implementation Plan
|
||||
|
||||
- [x] Phase 1: unblock playout test fixtures and lock down the source-media contract
|
||||
- [x] Create small compliant fixture media in `backend/playout/testdata/` for the validator and playout tests
|
||||
- [x] Create invalid fixture media for codec, framerate, and audio rejection paths
|
||||
- [x] Create playlist fixtures for empty, comment-only, mixed-validity, and valid playlists
|
||||
- [x] Document the ffmpeg fixture-generation commands in the relevant test headers
|
||||
|
||||
- [x] Phase 2: finish frontend coverage for the live viewer experience
|
||||
- [x] Add tests for HLS player startup, manifest resolution, and cross-origin/local fallback behavior
|
||||
- [x] Add tests for chat send/receive, presence updates, private DM flow, and reconnect behavior
|
||||
- [x] Add responsive-layout and accessibility checks for the viewer shell and chat sidebar
|
||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
||||
|
||||
- [x] Phase 3: finish admin frontend coverage
|
||||
- [x] Add tests for playlist management, moderation flows, and dashboard rendering
|
||||
- [x] Add tests for API integration points with mocked backend responses
|
||||
- [x] Add responsive-layout and accessibility checks for admin screens
|
||||
- [x] Add coverage reporting and enforce a minimum threshold in CI
|
||||
|
||||
- [x] Phase 4: validate the R2 and edge-delivery path
|
||||
- [x] Add an integration test for upload and retrieval of HLS objects through the R2 path
|
||||
- [x] Verify segment and manifest cache headers, including immutable media and live manifest freshness
|
||||
- [x] Validate edge cache hit/miss behavior with repeat requests
|
||||
- [x] Document any required stubs or mocks so the test can run in CI (putObject mock pattern documented in test)
|
||||
|
||||
- [ ] Phase 5: complete manual Cloudflare and external-service setup
|
||||
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||
- [ ] Create or verify UptimeRobot monitors for health and HLS manifest availability
|
||||
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
- [ ] Phase 6: rehearse production deployment end to end
|
||||
- [ ] Provision the Hetzner host and prepare the Docker/runtime baseline
|
||||
- [ ] Configure production secrets, remote deployment, and DNS cutover
|
||||
- [ ] Run health, HLS, chat, and admin smoke tests against production-like infrastructure
|
||||
- [ ] Verify backup creation, restore, and integrity checks on the production host
|
||||
|
||||
- [ ] Phase 7: baseline scaling and performance
|
||||
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions
|
||||
- [x] Prepare WebSocket load-test scaffolding and summary artifacts (`ws-chat.yml`, `run-loadtests.ps1`, `summarize-artillery.ps1`)
|
||||
- [ ] Execute WebSocket throughput and chat-latency benchmarks under growing connection counts
|
||||
- [x] Prepare Admin API benchmark scaffolding and endpoint summary artifacts (`admin-api.js`, `run-loadtests.ps1`, `summarize-k6.ps1`)
|
||||
- [ ] Execute Admin API benchmarks and identify slow paths
|
||||
- [x] Add saturation thresholds to monitoring (chat p95 latency + Redis memory usage)
|
||||
- [x] Add cache-hit threshold alert operationalization to monitoring
|
||||
|
||||
## Status Update (2026-08-10)
|
||||
|
||||
- Phase 1 (test fixtures) complete.
|
||||
- Phase 2 (viewer frontend): player startup, URL resolution, chat send/receive, reconnect, presence, DM flow, message rendering all covered (27 tests).
|
||||
- Phase 3 (admin frontend): dashboard rendering, API integration, playlist management all covered (31 tests total).
|
||||
- Phase 4 (R2 validation) partially complete: SyncToR2 cache headers + integration tests done; edge cache hit/miss remains manual.
|
||||
- Phase 5 (Cloudflare): Pages deploy workflow (`.gitea/workflows/pages-deploy.yml`) created; Pages/Workers setup docs written; manual dashboard tasks remain.
|
||||
- Phase 7: Loadtest scaffolding complete; `run-loadtests.ps1` auto-generates ws-chat-summary.txt and admin-api-endpoint-summary.txt; live benchmark execution pending.
|
||||
- Version tagged 1.0.11; go vet clean.
|
||||
|
||||
## Remaining (summary)
|
||||
|
||||
- **Test Fixtures**: media + playlist fixtures created; ffmpeg generation commands documented in test headers
|
||||
- **User Frontend Tests**: video player, chat integration, responsive, accessibility, coverage
|
||||
- **Admin Frontend Tests**: playlist UI, moderation UI, metrics dashboards, coverage
|
||||
- **Cloudflare R2 Integration Test**: upload + cache header validation done (SyncToR2 with CacheControl + ContentType); edge cache hit/miss validation remains manual
|
||||
- **Cloudflare Config**: Cloudflare Pages deploy workflow and setup docs created; manual dashboard setup (Tiered Cache, WAF, rate limiting, HTTPS/HSTS) remains pending; Pages project must be created in dashboard first.
|
||||
- **External Services**: UptimeRobot monitors + alerts
|
||||
- **Production Deployment**: Hetzner provisioning, CD pipeline, DNS cutover, smoke test
|
||||
- **Scaling & Performance**: TTFB baseline and live benchmark runs still pending; loadtest scaffolding now includes Artillery/k6 helpers with auto summaries plus worksheet field mapping; chat p95, Redis saturation, and cache-hit threshold alerting are implemented
|
||||
|
||||
See `DEVELOPMENT.md` for detailed checklist.
|
||||
|
||||
## Streaming Pipeline Gaps (2026-08-10 audit → 2026-08-10 implemented)
|
||||
|
||||
STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for full details.
|
||||
|
||||
- [x] Continuous R2 sync during playout (HIGH) — implemented: fsnotify watcher uploads segments within ~7s
|
||||
- [x] Globally unique segment naming with run ID (HIGH) — implemented: `r-{ISOTIME}/seg-{seq}.m4s`
|
||||
- [x] Atomic manifest publication with temp key (MEDIUM) — implemented: upload to .tmp, then CopyObject
|
||||
- [x] Manifest Cache-Control: add must-revalidate + s-maxage split (LOW) — implemented: `public, max-age=1, s-maxage=2, must-revalidate`
|
||||
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM) — infrastructure, needs Cloudflare R2 CORS config
|
||||
- [x] Player error recovery: retries, backoff, reconnecting state (MEDIUM) — implemented: hls.on(ERROR) handler with exponential backoff
|
||||
- [x] Health endpoint: add active item, revision, run ID, segment info (LOW) — implemented: full STREAMING.md contract fields
|
||||
- [x] Phase 4 edge cache validation — script `scripts/validate-edge-cache.sh` created
|
||||
|
||||
## Feature List (Future Enhancements)
|
||||
|
||||
- [ ] Admin backend
|
||||
- [ ] Analytics: implement a settings entry to set matomo url and page id
|
||||
- [ ] User frontend
|
||||
- [ ] Chat: add user registration and login flows
|
||||
- [ ] Chat: private DM history and search
|
||||
- [ ] Analytics: add javascript snippet to send pageview events to matomo
|
||||
@@ -87,10 +87,6 @@ Thumbs.db
|
||||
*.tfstate.backup
|
||||
.terraform/
|
||||
|
||||
# Agent / Tool files
|
||||
.github/agents/
|
||||
.github/instructions/
|
||||
.github/MEMORY.md
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY src/go.mod src/go.sum ./
|
||||
|
||||
# Download dependencies and build
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,4 +25,5 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||
github.com/aws/smithy-go v1.27.6 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
@@ -34,3 +34,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTw
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
||||
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
||||
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
+140
-21
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// VideoFile represents a normalized video entry in the playlist
|
||||
@@ -37,14 +38,25 @@ var (
|
||||
restartWindow = time.Now()
|
||||
slateDir = "/app/slate"
|
||||
hlsDir = "/app/hls"
|
||||
|
||||
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
|
||||
runID string
|
||||
segSequence uint64
|
||||
|
||||
// Health endpoint detail (STREAMING.md contract)
|
||||
activeItem string
|
||||
playlistRevision string
|
||||
lastSegmentSeq uint64
|
||||
lastR2PubTime time.Time
|
||||
consecutiveFails int
|
||||
)
|
||||
|
||||
const (
|
||||
maxRestartsPerMinute = 5
|
||||
slateManifestName = "playlist.m3u8"
|
||||
// Cache-Control header values for R2 uploads.
|
||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||
manifestCacheControl = "public, max-age=2"
|
||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
||||
)
|
||||
|
||||
// initR2Client initializes the R2 (S3-compatible) client
|
||||
@@ -67,6 +79,84 @@ func initR2Client() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// initRunID generates a unique run/session ID for globally unique segment naming (STREAMING.md contract).
|
||||
func initRunID() {
|
||||
runID = "r-" + time.Now().UTC().Format("20060102T150405Z")
|
||||
segSequence = 0
|
||||
}
|
||||
|
||||
// watchAndPublishSegments watches the HLS output directory for new .m4s segments
|
||||
// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
|
||||
func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(hlsOutputDir); err != nil {
|
||||
fmt.Println("Warning: cannot watch HLS dir:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Op&fsnotify.Create != 0 && strings.HasSuffix(event.Name, ".m4s") {
|
||||
// Wait for FFmpeg to finish writing the segment
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
segSequence++
|
||||
lastSegmentSeq = segSequence
|
||||
key := "live/" + runID + "/" + filepath.Base(event.Name)
|
||||
if err := SyncToR2(event.Name, key); err != nil {
|
||||
consecutiveFails++
|
||||
fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
|
||||
} else {
|
||||
consecutiveFails = 0
|
||||
lastR2PubTime = time.Now()
|
||||
}
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Println("fsnotify error:", err)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
||||
func publishManifest(localManifestPath string) error {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Upload to temp key
|
||||
if err := SyncToR2(localManifestPath, "live/playlist.m3u8.tmp"); err != nil {
|
||||
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Atomic copy to final key
|
||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
CopySource: aws.String(bucket + "/live/playlist.m3u8.tmp"),
|
||||
Key: aws.String("live/playlist.m3u8"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("manifest atomic swap failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
file, err := os.Open(playlistPath)
|
||||
@@ -336,16 +426,32 @@ func SyncToR2(localPath, r2Key string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// HealthHandler provides a simple health check endpoint
|
||||
// HealthHandler provides a detailed health check endpoint (STREAMING.md contract)
|
||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
status := "healthy"
|
||||
if playoutMode == "slate" {
|
||||
if playoutMode == "slate" || playoutMode == "degraded" {
|
||||
status = "degraded"
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, `{"status":"%s","service":"playout","mode":"%s","restarts":%d,"timestamp":"%s"}`,
|
||||
status, playoutMode, restartCount, time.Now().UTC().Format(time.RFC3339))
|
||||
// Unhealthy if manifest has not advanced for >2 segment durations (12s)
|
||||
if hlsDir != "" && time.Since(lastR2PubTime) > 12*time.Second && playoutMode != "starting" {
|
||||
status = "unhealthy"
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"status": status,
|
||||
"service": "playout",
|
||||
"mode": playoutMode,
|
||||
"restarts": restartCount,
|
||||
"run_id": runID,
|
||||
"active_item": activeItem,
|
||||
"playlist_revision": playlistRevision,
|
||||
"last_segment_seq": lastSegmentSeq,
|
||||
"last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
|
||||
"consecutive_failures": consecutiveFails,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// activateSlate copies pre-rendered slate HLS into the live output directory.
|
||||
@@ -450,6 +556,10 @@ func main() {
|
||||
fmt.Println("Playlist:", playlistPath)
|
||||
fmt.Println("R2 Bucket:", bucket)
|
||||
|
||||
// Initialize run ID for globally unique segment naming (STREAMING.md)
|
||||
initRunID()
|
||||
fmt.Println("Run ID:", runID)
|
||||
|
||||
// Validate playlist before starting (ADR-001 normalization enforcement)
|
||||
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
||||
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||
@@ -493,12 +603,19 @@ func main() {
|
||||
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||
initRunID()
|
||||
|
||||
if err := resetHLSOutput(hlsDir); err != nil {
|
||||
fmt.Println("Failed to reset HLS output:", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md)
|
||||
watchCtx, watchCancel := context.WithCancel(context.Background())
|
||||
go watchAndPublishSegments(watchCtx, hlsDir)
|
||||
|
||||
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
|
||||
|
||||
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
|
||||
@@ -513,12 +630,12 @@ func main() {
|
||||
"-hls_list_size", "24",
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", "init.mp4",
|
||||
"-strftime", "1",
|
||||
"-hls_flags", "delete_segments+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||
"/app/hls/playlist.m3u8")
|
||||
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
|
||||
filepath.Join(hlsDir, "playlist.m3u8"))
|
||||
|
||||
runErr := cmd.Run()
|
||||
watchCancel() // Stop the filesystem watcher
|
||||
|
||||
// Track restart and check for slate escalation
|
||||
restartCount++
|
||||
@@ -542,24 +659,30 @@ func main() {
|
||||
deactivateSlate()
|
||||
}
|
||||
|
||||
// After FFmpeg run, sync latest segments and manifest to R2
|
||||
// After FFmpeg run, publish remaining segments and atomic manifest to R2
|
||||
if err := syncLatestToR2(hlsDir); err != nil {
|
||||
fmt.Println("R2 sync warning:", err)
|
||||
}
|
||||
|
||||
// Publish manifest atomically (STREAMING.md safe publication)
|
||||
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
||||
if err := publishManifest(manifestPath); err != nil {
|
||||
fmt.Println("Manifest publish warning:", err)
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
||||
// syncLatestToR2 uploads new segments to R2 with runID-based keys
|
||||
func syncLatestToR2(hlsDir string) error {
|
||||
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
|
||||
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
key := "live/" + filepath.Base(f)
|
||||
key := "live/" + runID + "/" + filepath.Base(f)
|
||||
if err := SyncToR2(f, key); err != nil {
|
||||
fmt.Printf("Failed to sync %s: %v\n", f, err)
|
||||
} else {
|
||||
@@ -567,16 +690,12 @@ func syncLatestToR2(hlsDir string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the live playlist manifest (short TTL per ADR-001)
|
||||
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
|
||||
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sync init segment to run-specific R2 key
|
||||
initPath := filepath.Join(hlsDir, "init.mp4")
|
||||
if err := SyncToR2(initPath, "live/init.mp4"); err != nil {
|
||||
if err := SyncToR2(initPath, "live/"+runID+"/init.mp4"); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Synced live playlist.m3u8 to R2")
|
||||
fmt.Println("Synced init.mp4 to R2")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -241,11 +241,15 @@ func TestHealthHandler(t *testing.T) {
|
||||
// Save and restore globals
|
||||
prevMode := playoutMode
|
||||
prevCount := restartCount
|
||||
prevRunID := runID
|
||||
playoutMode = "live"
|
||||
restartCount = 3
|
||||
runID = "r-20260810T120000Z"
|
||||
lastR2PubTime = time.Now()
|
||||
defer func() {
|
||||
playoutMode = prevMode
|
||||
restartCount = prevCount
|
||||
runID = prevRunID
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
@@ -257,21 +261,30 @@ func TestHealthHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"healthy"`) {
|
||||
t.Errorf("body missing healthy: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"playout"`) {
|
||||
t.Errorf("body missing service: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"restarts":3`) {
|
||||
t.Errorf("body missing restarts: %s", body)
|
||||
for _, want := range []string{
|
||||
`"status":"healthy"`,
|
||||
`"service":"playout"`,
|
||||
`"mode":"live"`,
|
||||
`"restarts":3`,
|
||||
`"run_id":"r-20260810T120000Z"`,
|
||||
`"last_segment_seq"`,
|
||||
`"consecutive_failures"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler_Degraded(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
prevR2Pub := lastR2PubTime
|
||||
playoutMode = "slate"
|
||||
defer func() { playoutMode = prevMode }()
|
||||
lastR2PubTime = time.Now() // fresh R2 pub = not unhealthy
|
||||
defer func() {
|
||||
playoutMode = prevMode
|
||||
lastR2PubTime = prevR2Pub
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -286,6 +299,26 @@ func TestHealthHandler_Degraded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler_Unhealthy(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
prevR2Pub := lastR2PubTime
|
||||
playoutMode = "live"
|
||||
lastR2PubTime = time.Now().Add(-30 * time.Second) // stale R2 = unhealthy
|
||||
defer func() {
|
||||
playoutMode = prevMode
|
||||
lastR2PubTime = prevR2Pub
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HealthHandler(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"unhealthy"`) {
|
||||
t.Errorf("body missing unhealthy (stale R2 pub should trigger unhealthy): %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEscalateToSlate(t *testing.T) {
|
||||
// Save globals
|
||||
prevCount := restartCount
|
||||
|
||||
+8
-7
@@ -72,7 +72,7 @@ Before the cutover in the plan above, treat the following as the minimum readine
|
||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
|
||||
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
|
||||
6. Verify rollback is still possible before the final DNS switch.
|
||||
|
||||
@@ -93,11 +93,12 @@ Readiness is not complete until the checklist above and the manual evidence trac
|
||||
|
||||
Three Gitea Actions workflows drive the full pipeline:
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
||||
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||
| Workflow | Trigger | Purpose |
|
||||
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
||||
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||
| `pages-deploy.yml` | Push to `main` | Deploy frontend to Cloudflare Pages (see `docs/operations/gitea-cloudflare-pages.md`) |
|
||||
|
||||
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||
|
||||
@@ -265,7 +266,7 @@ docker compose -f docker-compose.prod.yml down -v
|
||||
|
||||
## Nginx and Proxy Considerations
|
||||
|
||||
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
|
||||
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx, see [nginx.conf](../infrastructure/nginx/nginx.conf).
|
||||
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||
|
||||
## Security Baseline
|
||||
|
||||
+30
-9
@@ -16,11 +16,11 @@
|
||||
|
||||
## User Frontend Tests (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
|
||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution, error recovery with retries/backoff/reconnecting overlay
|
||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] State management and data flow
|
||||
- [x] Responsive design and layout across different screen sizes - chat-collapsed toggle, CSS grid, now-playing aria
|
||||
- [x] Accessibility testing (keyboard navigation, screen reader support) - video controls, aria-labels, aria-expanded, input labels, reconnecting overlay aria-live/role
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical frontend components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
@@ -31,9 +31,9 @@
|
||||
- [x] Playlist management UI (add/remove videos, reorder, normalize) - save/error handling, sortable rendering
|
||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
||||
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) - overview + analytics rendering
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] State management and data flow
|
||||
- [x] Responsive design and layout across different screen sizes - tab content visibility, essential tabs
|
||||
- [x] Accessibility testing (keyboard navigation, screen reader support) - nav aria-label, active tab aria-current, nav button focusability
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical admin UI components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
@@ -52,8 +52,9 @@
|
||||
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
> Cloudflare Pages deploy workflow created (`.gitea/workflows/pages-deploy.yml`); setup docs written (`docs/operations/cloudflare-pages-setup.md`, `docs/operations/gitea-cloudflare.pages.md`).
|
||||
|
||||
- [ ] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
|
||||
- [ ] Cloudflare Pages: create project in dashboard, link custom domain, or deploy via Gitea Actions Direct Upload
|
||||
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
@@ -89,7 +90,27 @@
|
||||
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
|
||||
- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors (scaffolding complete; benchmark execution pending)
|
||||
- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries (scaffolding complete; benchmark execution pending)
|
||||
- [x] Load-test scaffolding: ws-chat.yml, admin-api.js, run-loadtests.ps1, summarizers, README
|
||||
- [x] Cache-hit ratio exporter (scripts/cache-hit-ratio.js + .ps1)
|
||||
- [x] Saturation alerts: chat p95 latency, Redis memory usage, cache-hit ratio
|
||||
- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90%
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min, Redis memory >85%/10min
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min (chat p95, Redis memory, cache-hit already done)
|
||||
- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||
|
||||
## Streaming Pipeline (2026-08-10 audit → 2026-08-10 implemented)
|
||||
|
||||
Gaps between STREAMING.md contract and playlist.go/main.ts implementation:
|
||||
|
||||
- [x] **Continuous R2 sync during playout (HIGH)**: fsnotify watcher uploads segments in real-time (~7s delay)
|
||||
- [x] **Globally unique segment naming (HIGH)**: `r-{ISOTIME}/seg-{seq}.m4s` via run ID + monotonic sequence
|
||||
- [x] **Atomic manifest publication (MEDIUM)**: Upload to `.tmp`, then `CopyObject` to final key
|
||||
- [x] **Manifest Cache-Control alignment (LOW)**: `public, max-age=1, s-maxage=2, must-revalidate`
|
||||
- [ ] **CORS headers on HLS responses (MEDIUM)**: Needs Cloudflare R2 CORS config (infrastructure, not code)
|
||||
- [x] **Player error recovery (MEDIUM)**: `hls.on(ERROR)` handler with 5x retries, exponential backoff (2s→32s), reconnecting overlay
|
||||
- [x] **Health endpoint detail (LOW)**: Added run_id, active_item, playlist_revision, last_segment_seq, last_r2_pub, consecutive_failures, unhealthy detection
|
||||
- [x] Source media contract + validation — implemented
|
||||
- [x] fMP4 HLS packaging (6s, 24 window) — implemented
|
||||
- [x] Slate fallback + recovery — implemented
|
||||
- [x] R2 cache headers (immutable segments, short-lived manifest) — implemented
|
||||
- [x] Cloudflare Worker immutable `.m4s` headers — implemented
|
||||
@@ -52,6 +52,13 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
||||
- **Linear Void app**: `monitoring/grafana/dashboard.json` — clients, messages, moderation, Redis, sessions, scrape health
|
||||
- **Prometheus ops**: `monitoring/grafana/dashboards/dashboard.json` — TSDB, CPU/memory, WAL, compactions, query latency
|
||||
|
||||
## Metrics checking
|
||||
|
||||
See detailed scripts for querying Prometheus scrape targets:
|
||||
|
||||
- [Linux/macOS](./infrastructure/monitoring/prometheus_curl.md)
|
||||
- [Windows](./infrastructure/monitoring/prometheus_curl_win.md)
|
||||
|
||||
## Key Metrics
|
||||
|
||||
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
||||
|
||||
+8
-4
@@ -5,7 +5,7 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
## Prerequisites
|
||||
|
||||
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
||||
- **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
||||
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
||||
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
||||
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
||||
@@ -223,7 +223,7 @@ flowchart LR
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Configure the `.m4s` and `.m3u8` cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||
|
||||
@@ -272,10 +272,14 @@ Configure Cloudflare Cache Rules:
|
||||
4. Validate behavior using response headers:
|
||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||
|
||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
||||
Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
|
||||
### 4. Cloudflare Pages
|
||||
|
||||
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-pages.md).
|
||||
|
||||
Manual setup:
|
||||
|
||||
1. Connect Gitea repository to Cloudflare Pages.
|
||||
2. Build command:
|
||||
|
||||
@@ -553,7 +557,7 @@ To allow the Gitea Actions runner (or your local machine) to deploy to the remot
|
||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||
2. **Configure Cloudflare**:
|
||||
- Create R2 bucket with `enam` location hint.
|
||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
||||
- Use Full (strict) TLS for both app and media hostnames.
|
||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ This document is the operational contract for Linear Void video ingest, playout,
|
||||
## Production flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
flowchart TD
|
||||
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||
B --> C["Playout service and FFmpeg"]
|
||||
C --> D["Local HLS staging"]
|
||||
|
||||
@@ -669,7 +669,7 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
||||
#### Transport and Edge Protection
|
||||
|
||||
- Public traffic uses HTTPS/WSS at the edge.
|
||||
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
||||
- Cloudflare WAF and rate limiting are part of the baseline (see [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md)).
|
||||
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||
|
||||
#### Reverse Proxy Enforcement (nginx)
|
||||
|
||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
||||
|
||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||
2. Deployment is **Docker-first**; services will be containerized.
|
||||
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
|
||||
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../infrastructure/cloudflare/cache-rules.md) for HLS segments and manifests.
|
||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||
5. Single VPS origin initially; multi-region is a later evolution.
|
||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,25 @@
|
||||
# Cloudflare Account Troubleshooting
|
||||
|
||||
Reference for Cloudflare account and API token troubleshooting.
|
||||
|
||||
Use `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects` to verify the account ID and API token are valid.
|
||||
|
||||
## Validate Cloudflare account and API token
|
||||
|
||||
```powershell
|
||||
# Validate Cloudflare account and API token in .env
|
||||
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
|
||||
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
|
||||
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
||||
|
||||
# Test API token with curl
|
||||
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||
|
||||
# List all Pages projects for the account
|
||||
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||
|
||||
# Create a new Pages project (if needed)
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"name":"linearvoid","production_branch":"main"}'
|
||||
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Prometheus scrape target metric names
|
||||
|
||||
Here are ready‑to‑run **cURL** commands you can use to query each Prometheus scrape target and list the metric names they expose.
|
||||
The Prometheus HTTP API endpoint `http://localhost:9090/api/v1/label/__name__/values` returns all metric names currently known for the target’s job/instance.
|
||||
|
||||
---
|
||||
|
||||
## 1. Chat (`http://chat:3000/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="chat",instance="chat:3000"}' | jq .
|
||||
```
|
||||
|
||||
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="nginx",instance="nginx-exporter:9113"}' | jq .
|
||||
```
|
||||
|
||||
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="node",instance="node-exporter:9100"}' | jq .
|
||||
```
|
||||
|
||||
## 4. Playout (`http://playout:8081/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="playout",instance="playout:8081"}' | jq .
|
||||
```
|
||||
|
||||
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="postgres",instance="postgres-exporter:9187"}' | jq .
|
||||
```
|
||||
|
||||
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="prometheus",instance="prometheus:9090"}' | jq .
|
||||
```
|
||||
|
||||
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:9090/api/v1/label/__name__/values?match[]={job="redis",instance="redis-exporter:9121"}' | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### How it works
|
||||
|
||||
- `http://localhost:9090` – address of your Prometheus server.
|
||||
- `/api/v1/label/__name__/values` – API call that lists _all_ metric names (`__name__` label) known to Prometheus.
|
||||
- `match[]` – a selector that restricts the result to the specific `job` and `instance` you care about.
|
||||
- `-s` – silent mode (no progress meter).
|
||||
- `| jq .` – pretty‑prints the JSON response (optional but handy).
|
||||
|
||||
Running any of these commands will output a JSON array like:
|
||||
|
||||
```json
|
||||
[
|
||||
"up",
|
||||
"process_cpu_seconds_total",
|
||||
"http_requests_total",
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
which is the full set of metrics currently being scraped from that target. Adjust the Prometheus host/port if it differs from `http://localhost:9090`.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Prometheus scrape target metric names (Windows)
|
||||
|
||||
Below are **cURL** one‑liners you can run from a Windows command prompt (or PowerShell) that query the Prometheus HTTP API and print the raw JSON list of metric names for each target.
|
||||
Since **jq** isn’t installed, the commands simply echo the JSON response; you can pipe it to `findstr` or PowerShell’s `ConvertFrom‑Json` if you want a prettier view.
|
||||
|
||||
---
|
||||
|
||||
## 1. Chat (`http://chat:3000/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"chat\",instance=\"chat:3000\"}"
|
||||
```
|
||||
|
||||
## 2. NGINX exporter (`http://nginx-exporter:9113/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"nginx\",instance=\"nginx-exporter:9113\"}"
|
||||
```
|
||||
|
||||
## 3. Node exporter (`http://node-exporter:9100/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"node\",instance=\"node-exporter:9100\"}"
|
||||
```
|
||||
|
||||
## 4. Playout (`http://playout:8081/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"playout\",instance=\"playout:8081\"}"
|
||||
```
|
||||
|
||||
## 5. Postgres exporter (`http://postgres-exporter:9187/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"postgres\",instance=\"postgres-exporter:9187\"}"
|
||||
```
|
||||
|
||||
## 6. Prometheus itself (`http://prometheus:9090/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"prometheus\",instance=\"prometheus:9090\"}"
|
||||
```
|
||||
|
||||
## 7. Redis exporter (`http://redis-exporter:9121/metrics`)
|
||||
|
||||
```cmd
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=\"redis\",instance=\"redis-exporter:9121\"}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Making the output a bit more readable (optional)
|
||||
|
||||
If you have **PowerShell** (available on Windows 10/11), you can format the JSON nicely:
|
||||
|
||||
```powershell
|
||||
curl "http://localhost:9090/api/v1/label/__name__/values?match[]={job=`"chat`",instance=`"chat:3000`"}" |
|
||||
ConvertFrom-Json |
|
||||
Select-Object -ExpandProperty data |
|
||||
Sort-Object
|
||||
```
|
||||
|
||||
Replace the URL part with the one for the target you’re interested in. The pipeline:
|
||||
|
||||
1. Calls `curl` (the built‑in alias for `Invoke-WebRequest` in PowerShell).
|
||||
2. `ConvertFrom-Json` parses the JSON.
|
||||
3. `Select-Object -ExpandProperty data` extracts the array of metric names.
|
||||
4. `Sort-Object` alphabetizes them.
|
||||
|
||||
You can similarly pipe the output to `findstr /i "http_requests_total"` to search for a particular metric name without any extra tools.
|
||||
|
||||
---
|
||||
|
||||
### Quick checklist
|
||||
|
||||
| Item | What to adjust |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Prometheus address** | If your Prometheus container isn’t bound to `localhost:9090`, replace `localhost:9090` with the appropriate host/port (e.g., `127.0.0.1:9090`). |
|
||||
| **Target job/instance** | The `job` and `instance` values must match the labels shown in your UI (they’re case‑sensitive). |
|
||||
| **PowerShell vs CMD** | The raw `curl` commands work in both CMD and PowerShell; the PowerShell‑only pretty‑print uses `ConvertFrom‑Json`. |
|
||||
|
||||
Run the command for any target and you’ll receive a JSON payload like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [
|
||||
"up",
|
||||
"process_cpu_seconds_total",
|
||||
"http_requests_total",
|
||||
"... more metrics ..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
That array (`data`) is the complete list of metric names currently scraped from the specified endpoint.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Cloudflare Pages & Workers Setup — Linear Void
|
||||
|
||||
Linear Void uses two Cloudflare products:
|
||||
|
||||
| Product | Purpose | What |
|
||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| **Pages** | Static SPA hosting | Vite-built frontend (`frontend/dist`) deployed via Git integration or Direct Upload |
|
||||
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
||||
|
||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||
|
||||
## Dashboard URLs
|
||||
|
||||
| Resource | URL |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| Workers & Pages overview | `https://dash.cloudflare.com/{accountId}/workers-and-pages` |
|
||||
| Worker: linearvoid | `https://dash.cloudflare.com/{accountId}/workers/services/view/linearvoid/production/settings` |
|
||||
| Pages project | `https://dash.cloudflare.com/{accountId}/pages/view/linearvoid` |
|
||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||
|
||||
## 1) Worker: `linearvoid` (edge cache headers)
|
||||
|
||||
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
||||
|
||||
Deploy via Wrangler CLI:
|
||||
|
||||
```bash
|
||||
npx wrangler deploy infrastructure/cloudflare/worker.js
|
||||
```
|
||||
|
||||
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
||||
|
||||
## 2) Pages: Static frontend
|
||||
|
||||
Two approaches — pick one:
|
||||
|
||||
### Option A: Git integration (recommended)
|
||||
|
||||
Connect the Gitea repository directly in Cloudflare Pages:
|
||||
|
||||
1. Pages → Create a project → Connect to Git
|
||||
2. Link `git.allucanget.biz/zwitschi/linearvoid`
|
||||
3. Build command: `cd frontend && npm run build`
|
||||
4. Output directory: `frontend/dist`
|
||||
5. Add custom domain: `linearvoid.tv`
|
||||
|
||||
Cloudflare handles builds and deploys automatically on push.
|
||||
|
||||
> **Naming:** If `linearvoid` is already used by the Worker service, name the Pages project `linearvoid-site` (or similar). Workers and Pages share a namespace.
|
||||
|
||||
### Option B: Direct Upload via Gitea Actions
|
||||
|
||||
Use `.gitea/workflows/pages-deploy.yml` to build in Gitea Actions and upload via the Cloudflare API.
|
||||
|
||||
See [gitea-cloudflare-pages.md](./gitea-cloudflare-pages.md) for the workflow.
|
||||
|
||||
## 3) Custom domains
|
||||
|
||||
- `linearvoid.tv` → Pages project
|
||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||
- `media.linearvoid.tv` → R2 bucket (separate; not Pages)
|
||||
|
||||
## Related
|
||||
|
||||
- [Gitea to Cloudflare Pages deployment guide](./gitea-cloudflare-pages.md)
|
||||
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Gitea to Cloudflare Pages — Linear Void
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Cloudflare Pages project** created (Dashboard → Workers & Pages → Create → Pages), see [Cloudflare Pages Setup](./cloudflare-pages-setup.md).
|
||||
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
||||
3. **API token** with Pages permissions:
|
||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||
- Permission: `Account → Cloudflare Pages → Edit`
|
||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
## Project-specific values
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Framework | Vite + TypeScript |
|
||||
| Build command | `cd frontend && npm install && npm run build` |
|
||||
| Output directory | `frontend/dist` |
|
||||
| Node version | 20 |
|
||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||
|
||||
## Gitea Actions workflow
|
||||
|
||||
Create `.gitea/workflows/pages-deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: linearvoid
|
||||
directory: frontend/dist
|
||||
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct API approach:
|
||||
|
||||
```yaml
|
||||
- name: Create deployment artifact
|
||||
run: tar -czf site.tgz -C frontend/dist .
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @site.tgz
|
||||
```
|
||||
|
||||
## Secrets to add in Gitea
|
||||
|
||||
| Secret | Value |
|
||||
| ----------------------- | ------------------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||
|
||||
## Verify deployment
|
||||
|
||||
1. Push to `main` or trigger manually from Gitea Actions.
|
||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
||||
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
||||
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
||||
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
||||
|
||||
## Related resources
|
||||
|
||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/{accountId}/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
||||
- Cloudflare Pages project: `https://dash.cloudflare.com/{accountId}/pages/view/linearvoid` — deployment history and custom domain settings
|
||||
@@ -0,0 +1,144 @@
|
||||
# Gitea to Cloudflare Pages — Linear Void
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> **Important:** The Pages project must exist before the API deploy call works. You'll get error 8000007 ("Project not found") if you skip this step.
|
||||
|
||||
1. **Cloudflare Pages project** created first:
|
||||
- Dashboard → Workers & Pages → Create → Pages → Direct Upload
|
||||
- Name it `linearvoid` (or a different name if `linearvoid` is already taken by a Worker — see note below)
|
||||
- See [Cloudflare Pages Setup](./cloudflare-pages-setup.md) for full details.
|
||||
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
||||
3. **API token** with Pages permissions:
|
||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||
- Permission: `Account → Cloudflare Pages → Edit`
|
||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
> **Naming conflict:** If a Worker already exists with the name `linearvoid`, the Pages project must use a different name (e.g., `linearvoid-site`). Update the `projectName` in the workflow accordingly. Workers and Pages share a namespace under "Workers & Pages".
|
||||
|
||||
## Project-specific values
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Framework | Vite + TypeScript |
|
||||
| Build command | `cd frontend && npm install && npm run build` |
|
||||
| Output directory | `frontend/dist` |
|
||||
| Node version | 20 |
|
||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||
|
||||
## Gitea Actions workflow
|
||||
|
||||
Create `.gitea/workflows/pages-deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: linearvoid
|
||||
directory: frontend/dist
|
||||
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct Cloudflare Pages API (multipart/form-data with manifest):
|
||||
|
||||
Snippet to generate a manifest in format `-F manifest='{"index.html": "abc123", "style.css": "def456"}'`
|
||||
|
||||
```bash
|
||||
# Build manifest form argument from all files in frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find frontend/dist -type f | sed 's|^frontend/dist/||'); do
|
||||
hash=$(sha256sum "frontend/dist/$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Generate deployment manifest
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
echo "Form arguments: $form_args"
|
||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-F "commit_hash=${COMMIT_HASH}" \
|
||||
$form_args
|
||||
```
|
||||
|
||||
This matches the Cloudflare Pages Direct Upload API: `Content-Type: multipart/form-data`, each file as a separate form field, and a `manifest` JSON mapping filenames to SHA-256 hashes.
|
||||
|
||||
## Secrets to add in Gitea
|
||||
|
||||
| Secret | Value |
|
||||
| ----------------------- | ------------------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||
|
||||
## Verify deployment
|
||||
|
||||
1. Push to `main` or trigger manually from Gitea Actions.
|
||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
||||
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
||||
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
||||
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
||||
- If you get error **8000007** ("Project not found"), the Pages project hasn't been created yet. Create it via the Cloudflare dashboard (Workers & Pages → Create → Pages → Direct Upload) first, then re-run.
|
||||
- If `linearvoid` is already taken by a Worker, use a different project name like `linearvoid-site` and update the `projectName` and `PROJECT_NAME` values in the workflow.
|
||||
|
||||
## Related resources
|
||||
|
||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/{accountId}/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
||||
- Cloudflare Pages project: `https://dash.cloudflare.com/{accountId}/pages/view/linearvoid` — deployment history and custom domain settings
|
||||
@@ -71,6 +71,11 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||
<div class="reconnecting-spinner"></div>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+247
-35
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -51,6 +52,66 @@
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@@ -256,6 +317,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -263,6 +334,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
||||
@@ -395,9 +477,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -415,9 +494,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -435,9 +511,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -455,9 +528,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -475,9 +545,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -495,9 +562,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -624,6 +688,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
@@ -769,6 +864,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -943,6 +1050,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
@@ -962,6 +1079,13 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@@ -969,6 +1093,52 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
@@ -1153,9 +1323,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1177,9 +1344,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1201,9 +1365,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1225,9 +1386,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1303,6 +1461,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
|
||||
"integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -1321,9 +1507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1489,6 +1675,19 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -1535,6 +1734,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
"private": true,
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
@@ -271,3 +271,58 @@ file '/app/media/demo2.mp4'
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin accessibility", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav" aria-label="Admin navigation">
|
||||
<button data-tab="dashboard" aria-current="page">Dashboard</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
</nav>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("nav has aria-label for screen readers", () => {
|
||||
const nav = document.querySelector(".admin-nav");
|
||||
expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
|
||||
});
|
||||
|
||||
it("active tab has aria-current attribute", () => {
|
||||
const active = document.querySelector("[aria-current]");
|
||||
expect(active).toBeTruthy();
|
||||
expect(active?.getAttribute("aria-current")).toBe("page");
|
||||
});
|
||||
|
||||
it("all nav buttons are focusable", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn.getAttribute("tabindex")).toBeNull();
|
||||
expect(btn.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin responsive layout", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav>
|
||||
<div id="tab-content"><div id="overview-content">Overview data</div></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("tab content is present and visible", () => {
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
expect(tabContent).toBeTruthy();
|
||||
const style = window.getComputedStyle(tabContent!);
|
||||
expect(style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("admin nav contains essential tabs", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
|
||||
expect(tabs).toContain("Dashboard");
|
||||
});
|
||||
});
|
||||
+118
-6
@@ -13,7 +13,17 @@ const HlsMock = Object.assign(
|
||||
vi.fn().mockImplementation(() => hlsInstance),
|
||||
{
|
||||
isSupported: vi.fn(() => true),
|
||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||
Events: {
|
||||
MANIFEST_PARSED: "hlsManifestParsed",
|
||||
ERROR: "hlsError",
|
||||
LEVEL_UPDATED: "hlsLevelUpdated",
|
||||
},
|
||||
ErrorTypes: {
|
||||
NETWORK_ERROR: "networkError",
|
||||
MEDIA_ERROR: "mediaError",
|
||||
MUX_ERROR: "muxError",
|
||||
OTHER_ERROR: "otherError",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -63,35 +73,47 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
// Set up DOM from index.html
|
||||
// Inject minimal CSS for jsdom layout tests (external style.css not loaded)
|
||||
document.head.innerHTML = `
|
||||
<style>
|
||||
.viewer-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); }
|
||||
.viewer-shell.chat-collapsed .chat-sidebar { display: none; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<main class="viewer-shell">
|
||||
<header class="viewer-header">
|
||||
<button id="chat-toggle" type="button">Hide Chat</button>
|
||||
<button id="chat-toggle" type="button" aria-expanded="true">Hide Chat</button>
|
||||
</header>
|
||||
<section class="viewer-content">
|
||||
<section class="player-stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing">
|
||||
<div id="now-playing" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="chat-panel" class="chat-sidebar">
|
||||
<aside id="chat-panel" class="chat-sidebar" role="complementary" aria-label="Chat panel">
|
||||
<div class="chat-header">
|
||||
<span id="room-name">Main Channel</span>
|
||||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
<section id="messages" class="chat-messages"></section>
|
||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||
<section id="presence-panel" class="presence-panel">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
<div class="chat-input-area">
|
||||
<label for="message-input">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." />
|
||||
<button type="button" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Stub prompt for DM
|
||||
@@ -331,8 +353,98 @@ describe("message rendering", () => {
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("alice");
|
||||
expect(messages.innerHTML).toContain("hey");
|
||||
expect(messages.innerHTML).toContain("alice");
|
||||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("player error recovery", () => {
|
||||
it("shows reconnecting overlay on fatal network error", async () => {
|
||||
const main = await import("../src/main");
|
||||
await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
|
||||
|
||||
// Find the error handler registered by initPlayerAndChat
|
||||
const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as
|
||||
| [string, Function]
|
||||
| undefined;
|
||||
|
||||
if (errorHandler) {
|
||||
const handler = errorHandler[1];
|
||||
handler("hlsError", {
|
||||
type: "networkError",
|
||||
details: "manifestLoadError",
|
||||
fatal: true,
|
||||
});
|
||||
|
||||
const overlay = document.getElementById("reconnecting-overlay")!;
|
||||
expect(overlay.classList.contains("hidden")).toBe(false);
|
||||
expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("player video has controls and aria attributes", () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
expect(video.controls).toBe(true);
|
||||
expect(video.hasAttribute("autoplay")).toBe(true);
|
||||
expect(video.hasAttribute("playsinline")).toBe(true);
|
||||
});
|
||||
|
||||
it("chat panel has aria-labels for live region", () => {
|
||||
const messages = document.getElementById("messages");
|
||||
expect(messages?.getAttribute("aria-live")).toBe("polite");
|
||||
|
||||
const chatPanel = document.getElementById("chat-panel");
|
||||
expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel");
|
||||
});
|
||||
|
||||
it("chat toggle exposes aria-expanded state", () => {
|
||||
const toggle = document.getElementById("chat-toggle")!;
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("message input has associated label", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
expect(input.getAttribute("type")).toBe("text");
|
||||
expect(input.placeholder).toBe("Type a message...");
|
||||
const label = document.querySelector("label[for='message-input']");
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toBe("Message input");
|
||||
});
|
||||
|
||||
it("reconnecting overlay is an aria-live assertive region", () => {
|
||||
const overlay = document.getElementById("reconnecting-overlay");
|
||||
expect(overlay?.getAttribute("role")).toBe("alert");
|
||||
expect(overlay?.getAttribute("aria-live")).toBe("assertive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("responsive layout", () => {
|
||||
it("chat-collapsed class hides sidebar at narrow viewport", () => {
|
||||
const shell = document.querySelector(".viewer-shell")!;
|
||||
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(shell.classList.contains("chat-collapsed")).toBe(true);
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||||
});
|
||||
|
||||
it("viewer-shell uses CSS grid for content layout", () => {
|
||||
const content = document.querySelector(".viewer-content");
|
||||
expect(content).toBeTruthy();
|
||||
const style = window.getComputedStyle(content!);
|
||||
expect(style.display).toBe("grid");
|
||||
});
|
||||
|
||||
it("now-playing overlay is accessible at small sizes", () => {
|
||||
const np = document.getElementById("now-playing");
|
||||
expect(np?.getAttribute("aria-live")).toBe("polite");
|
||||
});
|
||||
});
|
||||
+75
-1
@@ -18,6 +18,11 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
// Player error recovery state (STREAMING.md contract)
|
||||
let playerErrorCount = 0;
|
||||
const PLAYER_MAX_RETRIES = 5;
|
||||
const PLAYER_BASE_DELAY_MS = 2000;
|
||||
|
||||
export function resetViewerStateForTests() {
|
||||
hls = null;
|
||||
ws = null;
|
||||
@@ -25,6 +30,7 @@ export function resetViewerStateForTests() {
|
||||
username = "viewer";
|
||||
chatVisible = true;
|
||||
onlineUsers.clear();
|
||||
playerErrorCount = 0;
|
||||
}
|
||||
|
||||
export function resolveWebSocketBaseUrl() {
|
||||
@@ -159,10 +165,60 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
try {
|
||||
const { default: Hls } = await loadHlsModule();
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls = new Hls({
|
||||
liveDurationInfinity: true,
|
||||
liveSyncDurationCount: 3,
|
||||
});
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||
|
||||
// Player error recovery (STREAMING.md contract)
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return;
|
||||
|
||||
playerErrorCount++;
|
||||
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
||||
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
||||
console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`);
|
||||
showReconnecting("Reconnecting...");
|
||||
setTimeout(() => {
|
||||
hls?.startLoad();
|
||||
}, delay);
|
||||
} else {
|
||||
console.error("HLS network error: max retries exhausted");
|
||||
showReconnecting("Stream temporarily unavailable");
|
||||
}
|
||||
break;
|
||||
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
if (playerErrorCount <= 1) {
|
||||
console.warn("HLS media error, attempting recovery...");
|
||||
showReconnecting("Recovering...");
|
||||
hls!.recoverMediaError();
|
||||
} else {
|
||||
console.error("HLS media error: recovery failed");
|
||||
hls!.destroy();
|
||||
showReconnecting("Stream temporarily unavailable");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error("HLS fatal error:", data.type, data.details);
|
||||
hls!.destroy();
|
||||
showReconnecting("Stream temporarily unavailable");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset error count on successful manifest parse
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
playerErrorCount = 0;
|
||||
hideReconnecting();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load HLS player", error);
|
||||
@@ -179,6 +235,24 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
||||
}
|
||||
|
||||
export function showReconnecting(message = "Reconnecting...") {
|
||||
const overlay = document.getElementById("reconnecting-overlay");
|
||||
const text = document.getElementById("reconnecting-text");
|
||||
if (overlay) {
|
||||
overlay.classList.remove("hidden");
|
||||
}
|
||||
if (text) {
|
||||
text.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
export function hideReconnecting() {
|
||||
const overlay = document.getElementById("reconnecting-overlay");
|
||||
if (overlay) {
|
||||
overlay.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChatPanel() {
|
||||
const shell = document.querySelector(".viewer-shell");
|
||||
const toggle = document.getElementById("chat-toggle");
|
||||
|
||||
@@ -1056,3 +1056,40 @@ select {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reconnecting overlay (player error recovery) */
|
||||
.reconnecting-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4, 1rem);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.reconnecting-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reconnecting-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--line, #2a2a2a);
|
||||
border-top-color: var(--brand, #587792);
|
||||
border-radius: 50%;
|
||||
animation: lv-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes lv-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
#reconnecting-text {
|
||||
color: var(--ink-0, #ede8e6);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
@@ -4,5 +4,17 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "text-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts"],
|
||||
thresholds: {
|
||||
statements: 20,
|
||||
branches: 15,
|
||||
functions: 15,
|
||||
lines: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# Load Test Scaffolding
|
||||
|
||||
These files back the scaling/performance playbook.
|
||||
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
|
||||
|
||||
## WebSocket chat
|
||||
|
||||
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# Edge cache hit/miss validation (Phase 4 — STREAMING.md cache contract)
|
||||
# Verifies: .m4s segments go MISS→HIT, init.mp4 is cached, .m3u8 refreshes quickly
|
||||
# Usage: ./scripts/validate-edge-cache.sh [base_url]
|
||||
# Default base_url: https://media.linearvoid.tv
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-https://media.linearvoid.tv}"
|
||||
TIMEOUT=10
|
||||
FAILURES=0
|
||||
PASSES=0
|
||||
|
||||
green() { echo -e "\033[32m$1\033[0m"; }
|
||||
red() { echo -e "\033[31m$1\033[0m"; }
|
||||
|
||||
pass() { PASSES=$((PASSES + 1)); green " ✓ PASS: $1"; }
|
||||
fail() { FAILURES=$((FAILURES + 1)); red " ✗ FAIL: $1"; }
|
||||
|
||||
# Fetch a header from a URL
|
||||
fetch_header() {
|
||||
local url="$1" header="$2"
|
||||
curl -sI --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$url" 2>/dev/null | \
|
||||
grep -i "^${header}:" | sed 's/.*: //' | tr -d '\r' || echo ""
|
||||
}
|
||||
|
||||
echo "=== Edge Cache Validation ==="
|
||||
echo "Target: $BASE_URL"
|
||||
echo ""
|
||||
|
||||
# 1. Get current playlist manifest and extract a segment name
|
||||
echo "--- Test 1: Manifest freshness ---"
|
||||
MANIFEST_URL="$BASE_URL/hls/playlist.m3u8"
|
||||
MANIFEST=$(curl -s --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" "$MANIFEST_URL" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$MANIFEST" ]]; then
|
||||
fail "Manifest is empty or unreachable"
|
||||
else
|
||||
if echo "$MANIFEST" | grep -q "#EXTM3U"; then
|
||||
pass "Manifest is valid M3U8"
|
||||
else
|
||||
fail "Manifest missing #EXTM3U header"
|
||||
fi
|
||||
fi
|
||||
|
||||
SEGMENT=$(echo "$MANIFEST" | grep "\.m4s" | tail -1 | tr -d '\r')
|
||||
if [[ -z "$SEGMENT" ]]; then
|
||||
fail "No .m4s segment found in manifest"
|
||||
else
|
||||
pass "Found segment: $SEGMENT"
|
||||
fi
|
||||
|
||||
# 2. Test manifest Cache-Control
|
||||
MANIFEST_CC=$(fetch_header "$MANIFEST_URL" "Cache-Control")
|
||||
echo ""
|
||||
echo "--- Test 2: Manifest Cache-Control ---"
|
||||
if echo "$MANIFEST_CC" | grep -q "must-revalidate"; then
|
||||
pass "Manifest has must-revalidate ($MANIFEST_CC)"
|
||||
else
|
||||
fail "Manifest missing must-revalidate ($MANIFEST_CC)"
|
||||
fi
|
||||
if echo "$MANIFEST_CC" | grep -q "max-age"; then
|
||||
pass "Manifest has max-age ($MANIFEST_CC)"
|
||||
else
|
||||
fail "Manifest missing max-age ($MANIFEST_CC)"
|
||||
fi
|
||||
|
||||
# 3. Test segment cache (MISS → HIT)
|
||||
if [[ -n "$SEGMENT" ]]; then
|
||||
SEGMENT_URL="$BASE_URL/hls/$SEGMENT"
|
||||
echo ""
|
||||
echo "--- Test 3: Segment cache MISS→HIT ---"
|
||||
|
||||
FIRST_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||
echo " First request: CF-Cache-Status=$FIRST_STATUS"
|
||||
|
||||
sleep 1
|
||||
|
||||
SECOND_STATUS=$(fetch_header "$SEGMENT_URL" "CF-Cache-Status")
|
||||
echo " Second request: CF-Cache-Status=$SECOND_STATUS"
|
||||
|
||||
if [[ "$SECOND_STATUS" == "HIT" ]]; then
|
||||
pass "Segment cache HIT on second request (was $FIRST_STATUS)"
|
||||
elif [[ "$FIRST_STATUS" == "HIT" ]]; then
|
||||
pass "Segment already cached (HIT on first request)"
|
||||
else
|
||||
fail "Segment not cached: first=$FIRST_STATUS, second=$SECOND_STATUS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Test segment Cache-Control (immutable)
|
||||
if [[ -n "$SEGMENT" ]]; then
|
||||
echo ""
|
||||
echo "--- Test 4: Segment immutable cache ---"
|
||||
SEGMENT_CC=$(fetch_header "$SEGMENT_URL" "Cache-Control")
|
||||
if echo "$SEGMENT_CC" | grep -q "immutable"; then
|
||||
pass "Segment has immutable ($SEGMENT_CC)"
|
||||
else
|
||||
fail "Segment missing immutable ($SEGMENT_CC)"
|
||||
fi
|
||||
if echo "$SEGMENT_CC" | grep -q "max-age=31536000"; then
|
||||
pass "Segment has 1-year max-age ($SEGMENT_CC)"
|
||||
else
|
||||
fail "Segment missing 1-year max-age ($SEGMENT_CC)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Test init.mp4 cache
|
||||
echo ""
|
||||
echo "--- Test 5: Init segment cache ---"
|
||||
INIT_URL="$BASE_URL/hls/init.mp4"
|
||||
INIT_STATUS=$(fetch_header "$INIT_URL" "CF-Cache-Status")
|
||||
INIT_CC=$(fetch_header "$INIT_URL" "Cache-Control")
|
||||
if [[ "$INIT_STATUS" == "HIT" ]]; then
|
||||
pass "Init segment cached (CF-Cache-Status=$INIT_STATUS, CC=$INIT_CC)"
|
||||
else
|
||||
fail "Init segment not cached: $INIT_STATUS"
|
||||
fi
|
||||
|
||||
# 6. Test manifest refresh (should NOT be cached long-term)
|
||||
echo ""
|
||||
echo "--- Test 6: Manifest refresh rate ---"
|
||||
MANIFEST_CF=$(fetch_header "$MANIFEST_URL" "CF-Cache-Status")
|
||||
echo " Manifest CF-Cache-Status: $MANIFEST_CF"
|
||||
# DYNAMIC or BYPASS is correct for live manifests. HIT with long TTL is wrong.
|
||||
if [[ "$MANIFEST_CF" == "DYNAMIC" ]] || [[ "$MANIFEST_CF" == "BYPASS" ]] || [[ "$MANIFEST_CF" == "MISS" ]] || [[ "$MANIFEST_CF" == "REVALIDATED" ]]; then
|
||||
pass "Manifest not long-term cached ($MANIFEST_CF)"
|
||||
elif [[ "$MANIFEST_CF" == "HIT" ]]; then
|
||||
fail "Manifest is HIT — may be stale (check TTL)"
|
||||
else
|
||||
pass "Manifest status: $MANIFEST_CF (no HIT = acceptable)"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=== Results: $PASSES passed, $FAILURES failed ==="
|
||||
|
||||
if [[ $FAILURES -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Reference in new issue
Block a user