Compare commits
16
Commits
1.0.10
...
f6ca89747e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ca89747e | ||
|
|
98fafcd93c | ||
|
|
8b8f2335e1 | ||
|
|
80f4d9f198 | ||
|
|
87e9eff1bd | ||
|
|
24273b5513 | ||
|
|
31882f6b73 | ||
|
|
f73928103a | ||
|
|
b427353672 | ||
|
|
f9aba11b90 | ||
|
|
926a663c23 | ||
|
|
6b9830de05 | ||
|
|
914a518f20 | ||
|
|
8064e459f2 | ||
|
|
587561f9fc | ||
|
|
887023a491 |
No files matched your search
@@ -0,0 +1,38 @@
|
|||||||
|
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: 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
|
||||||
+21
-1
@@ -14,9 +14,29 @@ Agents instructions for the linearvoid project.
|
|||||||
|
|
||||||
## Task Management
|
## 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.
|
- 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
|
## Security & Secrets Protection
|
||||||
|
|
||||||
- **Never commit raw secrets or credentials** to the repository.
|
- **Never commit raw secrets or credentials** to the repository.
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- Fixed `go vet` issue in `metrics.go`: `time.Since` inside `defer` was evaluated at registration time. Wrapped in closure `func() { ObserveRequestDuration(time.Since(startedAt)) }()`.
|
||||||
|
- Tagged version `1.0.11`: updated `package.json`, `README.md` badge, `CHANGELOG.md` with Phase 1-4 entries.
|
||||||
|
- Cloudflare Pages/Workers documentation:
|
||||||
|
- Created `docs/operations/gitea-cloudflare.pages.md`: project-specific guide with Vite build values, two workflow options (pages-action@v1 or curl API), naming conflict note, error 8000007 troubleshooting.
|
||||||
|
- Created `docs/operations/cloudflare-pages-setup.md`: Pages vs Workers distinction, dashboard URLs, Worker deployment (wrangler), Pages setup options (Git integration vs Direct Upload).
|
||||||
|
- Updated `docs/SETUP.md` Cloudflare Pages section to reference the new guide.
|
||||||
|
- Updated `docs/DEPLOYMENT.md` pipeline table to include `pages-deploy.yml` workflow.
|
||||||
|
- Created `.gitea/workflows/pages-deploy.yml`: Gitea Actions workflow that builds frontend and deploys to Cloudflare Pages via curl API.
|
||||||
|
- Updated `docs/STREAMING.md` (user edit).
|
||||||
|
- All Go vet, Go tests, and frontend tests (31) passing.
|
||||||
@@ -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 (STREAMING.md contract vs playlist.go implementation):
|
||||||
|
|
||||||
|
- [ ] **Continuous R2 sync during playout (HIGH)**: `syncLatestToR2()` runs only after FFmpeg exits. During long playout loops, R2 is stale — viewers see no new segments. Needs per-segment upload triggered by filesystem watch or FFmpeg progress pipe.
|
||||||
|
- [ ] **Globally unique segment naming (HIGH)**: STREAMING.md requires `hls/r-{runID}/seg-{seq}.m4s`. Current code uses `strftime` (`segment_%Y%m%d_%H%M%S.m4s`) with no run ID — same-second collisions on restart; names can be reused. Add run/session UUID and monotonic sequence.
|
||||||
|
- [ ] **Atomic manifest publication (MEDIUM)**: Upload manifest to temp key, then atomically replace `live/playlist.m3u8`. Current code uploads directly — brief window where manifest references unconfirmed segments. Use R2 copy/rename or write-then-swap.
|
||||||
|
- [ ] **Manifest Cache-Control alignment (LOW)**: STREAMING.md specifies `public, max-age=1, s-maxage=2, must-revalidate`. Code uses `public, max-age=2`. Missing `must-revalidate` and browser/CDN split.
|
||||||
|
- [ ] **CORS headers on HLS responses (MEDIUM)**: `Access-Control-Allow-Origin: https://linearvoid.tv` required for cross-origin hls.js from `media.linearvoid.tv`. Neither Worker nor R2 config sets CORS. Add to Worker or R2 custom domain.
|
||||||
|
- [ ] **Player error recovery (MEDIUM)**: STREAMING.md requires bounded retries, exponential backoff, seek-forward-on-drift, reconnecting state. Current `main.ts` uses basic hls.js init with no custom error handlers. Add `hls.on(Hls.Events.ERROR, ...)` handler.
|
||||||
|
- [ ] **Health endpoint detail (LOW)**: Add active item, playlist revision, run ID, last segment sequence, last R2 pub timestamp, consecutive failures to `/health`. Currently only reports mode + restarts.
|
||||||
|
- [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,972 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] Add responsive-layout and accessibility checks for the viewer shell and chat sidebar
|
||||||
|
- [ ] Add coverage reporting and enforce a minimum threshold in CI
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] Add responsive-layout and accessibility checks for admin screens
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] 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)
|
||||||
|
|
||||||
|
STREAMING.md contract vs playlist.go/main.ts implementation. See Backlog for full details.
|
||||||
|
|
||||||
|
- [ ] Continuous R2 sync during playout (HIGH) — currently only on FFmpeg exit
|
||||||
|
- [ ] Globally unique segment naming with run ID (HIGH) — currently strftime only
|
||||||
|
- [ ] Atomic manifest publication with temp key (MEDIUM)
|
||||||
|
- [ ] CORS headers on HLS responses from media.linearvoid.tv (MEDIUM)
|
||||||
|
- [ ] Player error recovery: retries, drift, reconnecting state (MEDIUM)
|
||||||
|
- [ ] Manifest Cache-Control: add must-revalidate + s-maxage split (LOW)
|
||||||
|
- [ ] Health endpoint: add active item, revision, run ID, segment info (LOW)
|
||||||
|
|
||||||
|
## 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
|
||||||
+4
-4
@@ -19,6 +19,10 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Benchmark artifacts
|
||||||
|
artifacts/
|
||||||
|
scripts/loadtest/artifacts/
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
@@ -83,10 +87,6 @@ Thumbs.db
|
|||||||
*.tfstate.backup
|
*.tfstate.backup
|
||||||
.terraform/
|
.terraform/
|
||||||
|
|
||||||
# Agent / Tool files
|
|
||||||
.github/agents/
|
|
||||||
.github/instructions/
|
|
||||||
.github/MEMORY.md
|
|
||||||
|
|
||||||
# Temporary architecture artifacts
|
# Temporary architecture artifacts
|
||||||
**/*.tmp
|
**/*.tmp
|
||||||
|
|||||||
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [1.0.11] - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Phase 1 test fixtures**: Media and playlist test fixtures in `backend/playout/testdata/` with ffmpeg generation commands documented in test headers.
|
||||||
|
- **Phase 4 R2 integration**: `SyncToR2` now sets `CacheControl` + `ContentType` based on file extension (immutable segments, short-lived manifests). Three integration tests added.
|
||||||
|
- **Phase 2 viewer tests**: Presence updates (join/leave/sorted), private DM flow, message rendering (system/self/other) in `main.test.ts` (27 total tests).
|
||||||
|
- **Phase 3 admin tests**: Playlist management (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts` for testing.
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-08-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WebSocket summary helper**: Added `scripts/loadtest/summarize-artillery.ps1` to extract counters, rates, and available percentile stats from Artillery JSON output.
|
||||||
|
- **Playbook worksheet mapping**: Added artifact-to-worksheet mapping section in `docs/operations/scaling-performance-playbook.md` for WebSocket and Admin API benchmark artifacts.
|
||||||
|
- **Test fixture generation docs**: Added ffmpeg commands for generating valid and invalid media fixtures as comments in `backend/playout/src/playlist_test.go`. Phase 1 test fixtures now complete.
|
||||||
|
- **R2 cache header support**: `SyncToR2` now sets `CacheControl` and `ContentType` based on file extension:
|
||||||
|
- `.m4s`/`.ts`/`.mp4`/`.m4a` → immutable (max-age=31536000)
|
||||||
|
- `.m3u8` → short-lived (max-age=2s)
|
||||||
|
- unknown → no cache headers
|
||||||
|
- **R2 integration tests**: Added `TestSyncToR2_ManifestCacheHeaders` and `TestSyncToR2_NoCacheHeadersForUnknownExt` verifying cache header behavior for segment, manifest, and unknown file types.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Loadtest runner**: `scripts/loadtest/run-loadtests.ps1` now generates `ws-chat-summary.txt` automatically for `ws` and `all` scenarios.
|
||||||
|
- **Loadtest docs**: `scripts/loadtest/README.md` now documents `summarize-artillery.ps1` usage and the new `ws-chat-summary.txt` artifact.
|
||||||
|
- **Planning status wording**: Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while benchmark execution remains pending.
|
||||||
|
- **Phase 1 complete**: Test fixture creation and ffmpeg documentation marked done.
|
||||||
|
- **Phase 4 partially complete**: R2 upload + cache header validation done; edge cache hit/miss validation remains manual.
|
||||||
|
- **Phase 2 frontend tests**: Added presence updates (join/leave rendering), private DM flow, and message rendering tests in `main.test.ts` (27 total tests passing).
|
||||||
|
- **Phase 3 admin frontend tests**: Added playlist management tests (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||||
|
|
||||||
## [1.0.9] - 2026-08-06
|
## [1.0.9] - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -206,11 +206,11 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
|
|
||||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
- [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||||
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
- [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
|
||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
- [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
- [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
- [SECURITY.md](docs/SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||||
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
||||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||||
@@ -232,4 +232,4 @@ Since this is a linear project, we also rely on a linear development and release
|
|||||||
|
|
||||||
[MIT License](LICENSE)
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
The project is ready for use. See [`SETUP.md`](docs/SETUP.md) for detailed installation instructions.
|
||||||
+25
-25
@@ -62,32 +62,32 @@ func main() {
|
|||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
http.HandleFunc("/ws", hub.HandleConnection)
|
http.HandleFunc("/ws", hub.HandleConnection)
|
||||||
http.HandleFunc("/api/admin/login", handlers.HandleAdminLogin(deps))
|
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
|
||||||
http.HandleFunc("/api/admin/logout", handlers.HandleAdminLogout(deps))
|
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
|
||||||
http.HandleFunc("/api/admin/session", handlers.HandleAdminSession(deps))
|
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
|
||||||
http.HandleFunc("/api/admin/overview", requireAdmin(handlers.HandleAdminOverview(deps)))
|
http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
|
||||||
http.HandleFunc("/api/admin/playlist", requireAdmin(handlers.HandleAdminPlaylist(deps)))
|
http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
|
||||||
http.HandleFunc("/api/admin/moderation/logs", requireAdmin(handlers.HandleAdminModerationLogs(deps)))
|
http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
|
||||||
http.HandleFunc("/api/admin/moderation/actions", requireAdmin(handlers.HandleAdminModerationAction(deps)))
|
http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
|
||||||
http.HandleFunc("/api/admin/analytics", requireAdmin(handlers.HandleAdminAnalytics(deps)))
|
http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
|
||||||
http.HandleFunc("/api/admin/system", requireAdmin(handlers.HandleAdminSystemSummary(deps)))
|
http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
|
||||||
http.HandleFunc("/api/admin/config", requireAdmin(handlers.HandleAdminConfig(deps)))
|
http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
|
||||||
http.HandleFunc("/api/admin/restart", requireAdmin(handlers.HandleAdminRestart(deps)))
|
http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
|
||||||
http.HandleFunc("/api/admin/users", requireAdmin(handlers.HandleAdminUsers(deps)))
|
http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps))))
|
||||||
http.HandleFunc("/api/admin/schedule", requireAdmin(handlers.HandleAdminSchedule(deps)))
|
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
|
||||||
http.HandleFunc("/api/admin/schedule/overrides", requireAdmin(handlers.HandleAdminScheduleOverrides(deps)))
|
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
|
||||||
http.HandleFunc("/api/schedule", handlers.HandlePublicSchedule(deps))
|
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
|
||||||
http.HandleFunc("/api/admin/media", requireAdmin(handlers.HandleAdminMedia(deps)))
|
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads", requireAdmin(handlers.HandleAdminMediaUploads(deps)))
|
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
http.HandleFunc("/api/admin/media/uploads/", requireAdmin(handlers.HandleAdminMediaUploads(deps)))
|
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
|
||||||
http.HandleFunc("/api/admin/media/", requireAdmin(handlers.HandleAdminMediaByID(deps)))
|
http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
|
||||||
http.HandleFunc("/api/admin/chat-log/bulk", requireAdmin(handlers.HandleAdminChatLogBulk(deps)))
|
http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
|
||||||
http.HandleFunc("/api/admin/chat-log/export", requireAdmin(handlers.HandleAdminChatLogExport(deps)))
|
http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
|
||||||
http.HandleFunc("/api/admin/chat-log/", requireAdmin(handlers.HandleAdminChatLogByID(deps)))
|
http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
|
||||||
http.HandleFunc("/api/admin/chat-log", requireAdmin(handlers.HandleAdminChatLog(deps)))
|
http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
|
||||||
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
|
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
|
||||||
http.HandleFunc("/health", metrics.HandleHealth())
|
http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
|
||||||
http.HandleFunc("/", metrics.HandleNotFound())
|
http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
|
||||||
|
|
||||||
port := config.ChatPort()
|
port := config.ChatPort()
|
||||||
log.Printf("Chat service starting on :%s", port)
|
log.Printf("Chat service starting on :%s", port)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package metrics
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -15,6 +17,62 @@ import (
|
|||||||
"chat/src/ws"
|
"chat/src/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
|
||||||
|
|
||||||
|
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
|
||||||
|
type requestDurationHistogram struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets []float64
|
||||||
|
counts []uint64
|
||||||
|
sum float64
|
||||||
|
total uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
|
||||||
|
return &requestDurationHistogram{
|
||||||
|
buckets: append([]float64(nil), buckets...),
|
||||||
|
counts: make([]uint64, len(buckets)+1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *requestDurationHistogram) observe(durationSeconds float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
h.total++
|
||||||
|
h.sum += durationSeconds
|
||||||
|
for index, bucket := range h.buckets {
|
||||||
|
if durationSeconds <= bucket {
|
||||||
|
h.counts[index]++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.counts[len(h.counts)-1]++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
counts = append([]uint64(nil), h.counts...)
|
||||||
|
return counts, h.sum, h.total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveRequestDuration records a chat HTTP request duration in seconds.
|
||||||
|
func ObserveRequestDuration(duration time.Duration) {
|
||||||
|
chatRequestDurationHistogram.observe(duration.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapRequestTiming records request duration for the provided handler.
|
||||||
|
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startedAt := time.Now()
|
||||||
|
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||||
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -36,6 +94,9 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
|
|||||||
redisEnabled := 0
|
redisEnabled := 0
|
||||||
redisUp := 0
|
redisUp := 0
|
||||||
redisClients := 0
|
redisClients := 0
|
||||||
|
redisMemoryUsedBytes := int64(0)
|
||||||
|
redisMemoryMaxBytes := int64(0)
|
||||||
|
redisMemoryUsageRatio := 0.0
|
||||||
if redis != nil {
|
if redis != nil {
|
||||||
redisEnabled = 1
|
redisEnabled = 1
|
||||||
if err := redis.Ping(context.Background()); err == nil {
|
if err := redis.Ping(context.Background()); err == nil {
|
||||||
@@ -50,6 +111,19 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if info, err := redis.Info(context.Background(), "memory"); err == nil {
|
||||||
|
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
|
||||||
|
redisMemoryUsedBytes = value
|
||||||
|
}
|
||||||
|
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
|
||||||
|
redisMemoryMaxBytes = value
|
||||||
|
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
|
||||||
|
redisMemoryMaxBytes = value
|
||||||
|
}
|
||||||
|
if redisMemoryMaxBytes > 0 {
|
||||||
|
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +173,18 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
|
|||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
||||||
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
|
||||||
|
|
||||||
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
||||||
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||||
@@ -106,6 +192,19 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
|
|||||||
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||||
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||||
|
|
||||||
|
counts, sum, total := chatRequestDurationHistogram.snapshot()
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
|
||||||
|
cumulative := uint64(0)
|
||||||
|
for index, bucket := range requestDurationBuckets {
|
||||||
|
cumulative += counts[index]
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
|
||||||
|
}
|
||||||
|
cumulative += counts[len(counts)-1]
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +225,54 @@ func HandleNotFound() http.HandlerFunc {
|
|||||||
fmt.Fprint(w, `{"error":"not found"}`)
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseRedisMemoryBytes(info, key string) int64 {
|
||||||
|
for _, line := range strings.Split(info, "\n") {
|
||||||
|
if !strings.HasPrefix(line, key+":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return parseHumanBytes(value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHumanBytes(value string) int64 {
|
||||||
|
value = strings.TrimSpace(strings.ToUpper(value))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplier := float64(1)
|
||||||
|
suffixes := map[string]float64{
|
||||||
|
"B": 1,
|
||||||
|
"K": 1024,
|
||||||
|
"KB": 1024,
|
||||||
|
"M": 1024 * 1024,
|
||||||
|
"MB": 1024 * 1024,
|
||||||
|
"G": 1024 * 1024 * 1024,
|
||||||
|
"GB": 1024 * 1024 * 1024,
|
||||||
|
"T": 1024 * 1024 * 1024 * 1024,
|
||||||
|
"TB": 1024 * 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
for suffix, factor := range suffixes {
|
||||||
|
if strings.HasSuffix(value, suffix) {
|
||||||
|
multiplier = factor
|
||||||
|
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(parsed * multiplier)
|
||||||
|
}
|
||||||
@@ -54,6 +54,10 @@ func TestHandleNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleMetrics(t *testing.T) {
|
func TestHandleMetrics(t *testing.T) {
|
||||||
|
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
ObserveRequestDuration(100 * time.Millisecond)
|
||||||
|
ObserveRequestDuration(750 * time.Millisecond)
|
||||||
|
|
||||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open test DB: %v", err)
|
t.Fatalf("failed to open test DB: %v", err)
|
||||||
@@ -89,8 +93,12 @@ func TestHandleMetrics(t *testing.T) {
|
|||||||
"linearvoid_chat_redis_enabled",
|
"linearvoid_chat_redis_enabled",
|
||||||
"linearvoid_chat_redis_up",
|
"linearvoid_chat_redis_up",
|
||||||
"linearvoid_chat_redis_connected_clients",
|
"linearvoid_chat_redis_connected_clients",
|
||||||
|
"linearvoid_chat_redis_memory_used_bytes",
|
||||||
|
"linearvoid_chat_redis_memory_max_bytes",
|
||||||
|
"linearvoid_chat_redis_memory_usage_ratio",
|
||||||
"linearvoid_chat_admin_sessions_active",
|
"linearvoid_chat_admin_sessions_active",
|
||||||
"linearvoid_chat_build_info",
|
"linearvoid_chat_build_info",
|
||||||
|
"linearvoid_chat_request_duration_seconds",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, metric := range requiredMetrics {
|
for _, metric := range requiredMetrics {
|
||||||
@@ -117,6 +125,12 @@ func TestHandleMetrics(t *testing.T) {
|
|||||||
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
|
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
|
||||||
t.Error("redis_enabled should be 0 (nil redis)")
|
t.Error("redis_enabled should be 0 (nil redis)")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
|
||||||
|
t.Error("request duration histogram should include both observed samples by the 1s bucket")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
|
||||||
|
t.Error("request duration count should be 2")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleMetrics_WrongMethod(t *testing.T) {
|
func TestHandleMetrics_WrongMethod(t *testing.T) {
|
||||||
@@ -132,6 +146,9 @@ func TestHandleMetrics_WrongMethod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleMetrics_WithRedis(t *testing.T) {
|
func TestHandleMetrics_WithRedis(t *testing.T) {
|
||||||
|
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||||
|
ObserveRequestDuration(250 * time.Millisecond)
|
||||||
|
|
||||||
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||||
|
|
||||||
@@ -160,6 +177,15 @@ func TestHandleMetrics_WithRedis(t *testing.T) {
|
|||||||
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
|
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
|
||||||
t.Error("redis_connected_clients should be 5")
|
t.Error("redis_connected_clients should be 5")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
|
||||||
|
t.Error("redis_memory_used_bytes should be 1048576")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
|
||||||
|
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
|
||||||
|
t.Error("request duration count should be 1")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// metricsMockRedis is a minimal mock for metrics tests.
|
// metricsMockRedis is a minimal mock for metrics tests.
|
||||||
@@ -185,6 +211,6 @@ func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||||
return "connected_clients:5\nused_memory_human:1M\n", nil
|
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
|
||||||
}
|
}
|
||||||
func (m *metricsMockRedis) Close() error { return nil }
|
func (m *metricsMockRedis) Close() error { return nil }
|
||||||
@@ -105,9 +105,11 @@ func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
|||||||
opp := len(messages) - 1 - i
|
opp := len(messages) - 1 - i
|
||||||
messages[i], messages[opp] = messages[opp], messages[i]
|
messages[i], messages[opp] = messages[opp], messages[i]
|
||||||
}
|
}
|
||||||
|
h.mu.Lock()
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
conn.WriteJSON(msg)
|
conn.WriteJSON(msg)
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleConnection handles a new WebSocket connection.
|
// HandleConnection handles a new WebSocket connection.
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ FROM golang:1.25-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY src/go.mod src/go.sum ./
|
||||||
|
|
||||||
# Initialize Go module if not present
|
# Download dependencies and build
|
||||||
RUN go mod init playout || true
|
RUN go mod download
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module playout
|
module playout
|
||||||
|
|
||||||
go 1.26.5
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.43.4
|
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type VideoFile struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
r2Client *s3.Client
|
r2Client *s3.Client
|
||||||
|
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
return client.PutObject(context.TODO(), input)
|
||||||
|
}
|
||||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
restartCount = 0
|
restartCount = 0
|
||||||
@@ -39,6 +42,9 @@ var (
|
|||||||
const (
|
const (
|
||||||
maxRestartsPerMinute = 5
|
maxRestartsPerMinute = 5
|
||||||
slateManifestName = "playlist.m3u8"
|
slateManifestName = "playlist.m3u8"
|
||||||
|
// Cache-Control header values for R2 uploads.
|
||||||
|
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||||
|
manifestCacheControl = "public, max-age=2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// initR2Client initializes the R2 (S3-compatible) client
|
// initR2Client initializes the R2 (S3-compatible) client
|
||||||
@@ -203,7 +209,7 @@ func validateKeyframeInterval(videoPath string) error {
|
|||||||
"-v", "quiet",
|
"-v", "quiet",
|
||||||
"-select_streams", "v:0",
|
"-select_streams", "v:0",
|
||||||
"-show_frames",
|
"-show_frames",
|
||||||
"-show_entries", "frame=key_frame,pkt_pts_time",
|
"-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
|
||||||
"-of", "csv=p=0",
|
"-of", "csv=p=0",
|
||||||
videoPath)
|
videoPath)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
@@ -223,8 +229,16 @@ func validateKeyframeInterval(videoPath string) error {
|
|||||||
if len(parts) < 2 || parts[0] != "1" {
|
if len(parts) < 2 || parts[0] != "1" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
var pts float64
|
||||||
if err != nil {
|
var found bool
|
||||||
|
for _, raw := range parts[1:] {
|
||||||
|
if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
|
||||||
|
pts = value
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keyframes = append(keyframes, kfEntry{pts})
|
keyframes = append(keyframes, kfEntry{pts})
|
||||||
@@ -284,7 +298,11 @@ func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
|||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncToR2 uploads a file to R2 and returns the key
|
// SyncToR2 uploads a file to R2 and returns the key.
|
||||||
|
// Cache-Control is set automatically based on file extension:
|
||||||
|
// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
|
||||||
|
// - .m3u8 -> short-lived (2s, live manifest)
|
||||||
|
// - everything else -> no cache header
|
||||||
func SyncToR2(localPath, r2Key string) error {
|
func SyncToR2(localPath, r2Key string) error {
|
||||||
if r2Client == nil {
|
if r2Client == nil {
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
@@ -298,11 +316,23 @@ func SyncToR2(localPath, r2Key string) error {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
_, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{
|
input := &s3.PutObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
Key: aws.String(r2Key),
|
Key: aws.String(r2Key),
|
||||||
Body: file,
|
Body: file,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(r2Key))
|
||||||
|
switch ext {
|
||||||
|
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||||
|
input.CacheControl = aws.String(segmentCacheControl)
|
||||||
|
input.ContentType = aws.String("video/MP2T")
|
||||||
|
case ".m3u8":
|
||||||
|
input.CacheControl = aws.String(manifestCacheControl)
|
||||||
|
input.ContentType = aws.String("application/vnd.apple.mpegurl")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = putObject(r2Client, input)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,97 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
// Test fixture generation commands (run from repository root):
|
||||||
|
//
|
||||||
|
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// backend/playout/testdata/valid_720p30.mp4
|
||||||
|
//
|
||||||
|
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
|
||||||
|
//
|
||||||
|
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
|
||||||
|
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
|
||||||
|
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||||
|
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
|
||||||
|
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||||
|
// backend/playout/testdata/invalid_24fps.mp4
|
||||||
|
//
|
||||||
|
// Test playlist fixtures are plain text files with paths relative to /app/media.
|
||||||
|
// See backend/playout/testdata/*.txt.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadPlaylist(t *testing.T) {
|
func ensureFFProbeInPath(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
t.Helper()
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("ffprobe"); err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
|
||||||
|
if _, err := os.Stat(windowsFFProbe); err == nil {
|
||||||
|
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPlaylist(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
file string
|
||||||
wantLen int
|
wantLen int
|
||||||
wantErr bool
|
wantErr bool
|
||||||
first string
|
first string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple paths",
|
name: "valid playlist",
|
||||||
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
|
file: "valid_playlist.txt",
|
||||||
wantLen: 2,
|
wantLen: 2,
|
||||||
first: "/videos/video1.mp4",
|
first: "/app/media/demo1.mp4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with file prefix",
|
name: "empty playlist",
|
||||||
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
|
file: "empty_playlist.txt",
|
||||||
wantLen: 2,
|
|
||||||
first: "/videos/video1.mp4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with file prefix double quotes",
|
|
||||||
content: "file \"/videos/video1.mp4\"\n",
|
|
||||||
wantLen: 1,
|
|
||||||
first: "/videos/video1.mp4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with comments and blanks",
|
|
||||||
content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n",
|
|
||||||
wantLen: 2,
|
|
||||||
first: "/videos/video1.mp4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty file",
|
|
||||||
content: "",
|
|
||||||
wantLen: 0,
|
wantLen: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only comments",
|
name: "comments only",
|
||||||
content: "# comment\n# another\n",
|
file: "comments_only.txt",
|
||||||
wantLen: 0,
|
wantLen: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mixed valid invalid",
|
||||||
|
file: "mixed_valid_invalid.txt",
|
||||||
|
wantLen: 3,
|
||||||
|
first: "/app/media/demo1.mp4",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
path := filepath.Join(tmpDir, tt.name+".txt")
|
path := filepath.Join("..", "testdata", tt.file)
|
||||||
os.WriteFile(path, []byte(tt.content), 0644)
|
|
||||||
|
|
||||||
videos, err := LoadPlaylist(path)
|
videos, err := LoadPlaylist(path)
|
||||||
if tt.wantErr && err == nil {
|
if tt.wantErr && err == nil {
|
||||||
@@ -149,10 +181,60 @@ func TestValidateKeyframeInterval(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateVideoStrict(t *testing.T) {
|
func TestValidateVideoStrict(t *testing.T) {
|
||||||
// ValidateVideoStrict calls ValidateVideo which calls ffprobe
|
ensureFFProbeInPath(t)
|
||||||
// Test only the result structure when ValidateVideo returns valid=false
|
|
||||||
// Mock not feasible without full ffprobe mock, skip for now
|
tests := []struct {
|
||||||
t.Skip("requires ffprobe mock or test media fixtures")
|
name string
|
||||||
|
file string
|
||||||
|
wantValid bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid h264 fixture",
|
||||||
|
file: "valid_720p30.mp4",
|
||||||
|
wantValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid h265 fixture",
|
||||||
|
file: "invalid_h265.mp4",
|
||||||
|
wantErr: "video codec must be h264",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid framerate fixture",
|
||||||
|
file: "invalid_24fps.mp4",
|
||||||
|
wantErr: "framerate must be 30/1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "testdata", tt.file)
|
||||||
|
result := ValidateVideoStrict(path)
|
||||||
|
|
||||||
|
valid, _ := result["valid"].(bool)
|
||||||
|
if valid != tt.wantValid {
|
||||||
|
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs, _ := result["errors"].([]string)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("errors = %v, want none", errs)
|
||||||
|
}
|
||||||
|
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
|
||||||
|
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
|
||||||
|
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthHandler(t *testing.T) {
|
func TestHealthHandler(t *testing.T) {
|
||||||
@@ -348,6 +430,129 @@ func TestValidatePlaylist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncToR2(t *testing.T) {
|
func TestSyncToR2(t *testing.T) {
|
||||||
// Requires R2/S3 mock
|
prevClient := r2Client
|
||||||
t.Skip("requires S3 client mock")
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "segment.m4s")
|
||||||
|
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotBucket, gotKey, gotCacheControl, gotContentType string
|
||||||
|
var gotBody []byte
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotBucket = aws.ToString(input.Bucket)
|
||||||
|
gotKey = aws.ToString(input.Key)
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
body, err := io.ReadAll(input.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gotBody = body
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotBucket != "linearvoid-media" {
|
||||||
|
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
||||||
|
}
|
||||||
|
if gotKey != "live/segment.m4s" {
|
||||||
|
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
|
||||||
|
}
|
||||||
|
if string(gotBody) != "segment-bytes" {
|
||||||
|
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
||||||
|
}
|
||||||
|
if gotCacheControl != segmentCacheControl {
|
||||||
|
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "video/MP2T" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "playlist.m3u8")
|
||||||
|
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotCacheControl, gotContentType string
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotCacheControl != manifestCacheControl {
|
||||||
|
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "application/vnd.apple.mpegurl" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
||||||
|
prevClient := r2Client
|
||||||
|
prevBucket := bucket
|
||||||
|
prevPutObject := putObject
|
||||||
|
defer func() {
|
||||||
|
r2Client = prevClient
|
||||||
|
bucket = prevBucket
|
||||||
|
putObject = prevPutObject
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "data.bin")
|
||||||
|
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = "linearvoid-media"
|
||||||
|
r2Client = &s3.Client{}
|
||||||
|
var gotCacheControl, gotContentType string
|
||||||
|
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||||
|
gotCacheControl = aws.ToString(input.CacheControl)
|
||||||
|
gotContentType = aws.ToString(input.ContentType)
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
|
||||||
|
t.Fatalf("SyncToR2 error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotCacheControl != "" {
|
||||||
|
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
|
||||||
|
}
|
||||||
|
if gotContentType != "" {
|
||||||
|
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Linear Void test playlist
|
||||||
|
# No media entries here
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
# Valid entries should still parse
|
||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
|
||||||
|
# Invalid or malformed lines are preserved as literal paths by the parser
|
||||||
|
not-a-file-entry
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
|||||||
|
file '/app/media/demo1.mp4'
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
File renamed without changes.
@@ -8,6 +8,7 @@
|
|||||||
- [Production Target (Hetzner)](#production-target-hetzner)
|
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||||
- [Planned Infrastructure](#planned-infrastructure)
|
- [Planned Infrastructure](#planned-infrastructure)
|
||||||
- [Cutover Plan](#cutover-plan)
|
- [Cutover Plan](#cutover-plan)
|
||||||
|
- [Production Preflight](#production-preflight)
|
||||||
- [Firewall / Security Group](#firewall--security-group)
|
- [Firewall / Security Group](#firewall--security-group)
|
||||||
- [Pipeline Readiness](#pipeline-readiness)
|
- [Pipeline Readiness](#pipeline-readiness)
|
||||||
- [Target Topology](#target-topology)
|
- [Target Topology](#target-topology)
|
||||||
@@ -64,6 +65,19 @@ Linear Void runs in two separate environments:
|
|||||||
6. Enable HTTPS, WAF, cache rules on production zone
|
6. Enable HTTPS, WAF, cache rules on production zone
|
||||||
7. Decommission test lab as public target
|
7. Decommission test lab as public target
|
||||||
|
|
||||||
|
### Production Preflight
|
||||||
|
|
||||||
|
Before the cutover in the plan above, treat the following as the minimum readiness gate:
|
||||||
|
|
||||||
|
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 [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.
|
||||||
|
|
||||||
|
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
|
||||||
|
|
||||||
### Firewall / Security Group
|
### Firewall / Security Group
|
||||||
|
|
||||||
| Port | Source | Purpose |
|
| Port | Source | Purpose |
|
||||||
@@ -79,11 +93,12 @@ Linear Void runs in two separate environments:
|
|||||||
|
|
||||||
Three Gitea Actions workflows drive the full pipeline:
|
Three Gitea Actions workflows drive the full pipeline:
|
||||||
|
|
||||||
| Workflow | Trigger | Purpose |
|
| Workflow | Trigger | Purpose |
|
||||||
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
|
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
| `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 |
|
| `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 |
|
| `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.
|
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||||
|
|
||||||
@@ -135,7 +150,7 @@ See .env.example and SETUP.md for full variable reference.
|
|||||||
|
|
||||||
## Pull-and-Run Deployment
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||||
|
|
||||||
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
||||||
|
|
||||||
@@ -251,7 +266,7 @@ docker compose -f docker-compose.prod.yml down -v
|
|||||||
|
|
||||||
## Nginx and Proxy Considerations
|
## 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.
|
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
@@ -1,57 +1,60 @@
|
|||||||
# DEVELOPMENT.md — Linear Void Remaining Tasks
|
# DEVELOPMENT.md — Linear Void Remaining Tasks
|
||||||
|
|
||||||
> Auto-generated 2026-08-07. All completed items moved to `.github/instructions/DONE.md`.
|
## Test Fixtures (COMPLETE)
|
||||||
|
|
||||||
## Test Fixtures (BLOCKED — requires ffmpeg)
|
- [x] Create test media fixtures in `backend/playout/testdata/`:
|
||||||
|
|
||||||
- [ ] Create test media fixtures in `backend/playout/testdata/`:
|
|
||||||
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
|
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
|
||||||
- `invalid_h265.mp4` — wrong codec
|
- `invalid_h265.mp4` — wrong codec
|
||||||
- `invalid_24fps.mp4` — wrong framerate
|
- `invalid_24fps.mp4` — wrong framerate
|
||||||
- Generate via `ffmpeg` commands documented in test file headers
|
- Generate via `ffmpeg` commands documented in test file headers
|
||||||
|
- Windows dev path: `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffmpeg.exe`
|
||||||
|
|
||||||
- [ ] Create test playlist fixtures in `backend/playout/testdata/`:
|
- [x] Create test playlist fixtures in `backend/playout/testdata/`:
|
||||||
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
||||||
|
|
||||||
## User Frontend Tests
|
- [x] Document ffmpeg fixture-generation commands in `playlist_test.go` header
|
||||||
|
|
||||||
- [ ] Video player behavior (play, pause, seek, buffering, error handling)
|
## User Frontend Tests (PARTIALLY COMPLETE)
|
||||||
- [ ] Chat functionality (send/receive messages, presence, rate limiting, moderation)
|
|
||||||
|
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
|
||||||
|
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
|
||||||
- [ ] State management and data flow
|
- [ ] State management and data flow
|
||||||
- [ ] Responsive design and layout across different screen sizes
|
- [ ] Responsive design and layout across different screen sizes
|
||||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||||
- [ ] Integration with backend APIs (mocking API responses for testing)
|
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||||
- [ ] Test coverage for all critical frontend components and interactions
|
- [ ] Test coverage for all critical frontend components and interactions
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
- [ ] Document test cases and expected outcomes
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
## Admin Frontend Tests
|
## Admin Frontend Tests (PARTIALLY COMPLETE)
|
||||||
|
|
||||||
- [ ] Playlist management UI (add/remove videos, reorder, normalize)
|
- [x] Playlist management UI (add/remove videos, reorder, normalize) - save/error handling, sortable rendering
|
||||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
||||||
- [ ] Metrics and monitoring dashboards (Prometheus/Grafana integration)
|
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) - overview + analytics rendering
|
||||||
- [ ] State management and data flow
|
- [ ] State management and data flow
|
||||||
- [ ] Responsive design and layout across different screen sizes
|
- [ ] Responsive design and layout across different screen sizes
|
||||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||||
- [ ] Integration with backend APIs (mocking API responses for testing)
|
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||||
- [ ] Test coverage for all critical admin UI components and interactions
|
- [ ] Test coverage for all critical admin UI components and interactions
|
||||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||||
- [ ] Document test cases and expected outcomes
|
- [ ] Document test cases and expected outcomes
|
||||||
|
|
||||||
## Cloudflare R2 Integration Test
|
## Cloudflare R2 Integration Test (PARTIALLY COMPLETE)
|
||||||
|
|
||||||
- [ ] Upload test HLS segments and manifests to R2 bucket
|
- [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
|
||||||
- [ ] Verify correct cache headers are applied (immutable segments, TTLs)
|
- [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
|
||||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||||
- [ ] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API
|
- [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
|
||||||
- [ ] Document test cases and expected outcomes
|
- [x] Document test cases and expected outcomes (TestSyncToR2 + TestSyncToR2_ManifestCacheHeaders + TestSyncToR2_NoCacheHeadersForUnknownExt)
|
||||||
|
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
||||||
|
|
||||||
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
||||||
|
|
||||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
> 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
@@ -85,9 +88,29 @@
|
|||||||
|
|
||||||
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
||||||
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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%
|
- [ ] 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
|
- [ ] 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 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
|
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||||
|
|
||||||
|
## Streaming Pipeline (2026-08-10 audit)
|
||||||
|
|
||||||
|
Gaps between STREAMING.md contract and playlist.go/main.ts implementation:
|
||||||
|
|
||||||
|
- [ ] **Continuous R2 sync during playout (HIGH)**: `syncLatestToR2()` runs only after FFmpeg exits. Needs per-segment upload via filesystem watch or FFmpeg progress pipe.
|
||||||
|
- [ ] **Globally unique segment naming (HIGH)**: Add run/session UUID + monotonic sequence. Currently `strftime` only — collisions possible.
|
||||||
|
- [ ] **Atomic manifest publication (MEDIUM)**: Upload to temp key then atomically replace `live/playlist.m3u8`.
|
||||||
|
- [ ] **Manifest Cache-Control alignment (LOW)**: Add `must-revalidate` and `s-maxage`/`max-age` split.
|
||||||
|
- [ ] **CORS headers on HLS responses (MEDIUM)**: Add `Access-Control-Allow-Origin` to Worker or R2 custom domain.
|
||||||
|
- [ ] **Player error recovery (MEDIUM)**: Add `hls.on(Hls.Events.ERROR, ...)` with retries, drift, reconnecting state.
|
||||||
|
- [ ] **Health endpoint detail (LOW)**: Add active item, playlist revision, run ID, segment info, R2 pub timestamp.
|
||||||
|
- [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
|
- **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
|
- **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
|
## Key Metrics
|
||||||
|
|
||||||
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
- `linearvoid_chat_connected_clients` — active WebSocket connections
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Docs Folder
|
||||||
|
|
||||||
|
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- [Development](DEVELOPMENT.md) - Remaining tasks and test fixtures for development.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||||
|
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||||
|
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||||
File renamed without changes.
@@ -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`.
|
- 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**:
|
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.
|
- 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.
|
- 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:
|
4. Validate behavior using response headers:
|
||||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
- `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
|
### 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.
|
1. Connect Gitea repository to Cloudflare Pages.
|
||||||
2. Build command:
|
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).
|
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||||
2. **Configure Cloudflare**:
|
2. **Configure Cloudflare**:
|
||||||
- Create R2 bucket with `enam` location hint.
|
- 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.
|
- 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.
|
- Use Full (strict) TLS for both app and media hostnames.
|
||||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Streaming Pipeline
|
||||||
|
|
||||||
|
This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
|
||||||
|
|
||||||
|
## Production flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||||
|
B --> C["Playout service and FFmpeg"]
|
||||||
|
C --> D["Local HLS staging"]
|
||||||
|
D --> E["R2 object storage"]
|
||||||
|
E --> F["Cloudflare edge"]
|
||||||
|
F --> G["hls.js or native HLS"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||||
|
|
||||||
|
## Source-media contract
|
||||||
|
|
||||||
|
Only validated files may enter the production playlist.
|
||||||
|
|
||||||
|
| Property | Required profile |
|
||||||
|
| ---------- | ------------------------------------------------------------------------ |
|
||||||
|
| Container | MP4 |
|
||||||
|
| Video | H.264/AVC, `yuv420p` |
|
||||||
|
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
|
||||||
|
| Frame rate | Constant 30 fps |
|
||||||
|
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||||
|
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||||
|
|
||||||
|
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
|
||||||
|
|
||||||
|
Reference normalization:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 -preset medium -crf 21 \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output-normalized.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playlist and timeline
|
||||||
|
|
||||||
|
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
||||||
|
- The service validates the complete playlist before activating a revision.
|
||||||
|
- Playlist edits are written atomically and receive a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||||
|
- All viewers follow the same live manifest; clients do not calculate the schedule independently.
|
||||||
|
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
||||||
|
- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
|
||||||
|
|
||||||
|
## Playout and HLS packaging
|
||||||
|
|
||||||
|
The playout service wraps FFmpeg, tracks the active item and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||||
|
|
||||||
|
Baseline packaging:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| Protocol | Standard live HLS, not LL-HLS |
|
||||||
|
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
||||||
|
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
||||||
|
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
|
||||||
|
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||||
|
| Expected latency | 15–35 seconds |
|
||||||
|
|
||||||
|
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
||||||
|
|
||||||
|
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hls/r-20260807T170412Z/init-000001.mp4
|
||||||
|
hls/r-20260807T170412Z/seg-000001234.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
|
||||||
|
|
||||||
|
## Safe publication to R2
|
||||||
|
|
||||||
|
Publication order prevents a manifest from referencing an unavailable segment:
|
||||||
|
|
||||||
|
1. FFmpeg writes a segment to a temporary local name and closes it.
|
||||||
|
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
||||||
|
3. It verifies successful upload and records object size/checksum.
|
||||||
|
4. It renders the next media manifest referencing only confirmed objects.
|
||||||
|
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
||||||
|
|
||||||
|
Never upload an actively written segment or publish the manifest first. Use a single publisher instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
|
||||||
|
|
||||||
|
The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||||
|
|
||||||
|
## Cache contract
|
||||||
|
|
||||||
|
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||||
|
| -------------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||||
|
| Live `.m3u8` | Cache eligible; edge TTL 2 seconds | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||||
|
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||||
|
|
||||||
|
Further rules:
|
||||||
|
|
||||||
|
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
||||||
|
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
||||||
|
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
||||||
|
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
|
||||||
|
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
||||||
|
- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
|
||||||
|
|
||||||
|
Expected validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
|
||||||
|
|
||||||
|
## Player behavior
|
||||||
|
|
||||||
|
The SPA uses native HLS where supported and hls.js elsewhere.
|
||||||
|
|
||||||
|
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||||
|
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
||||||
|
- Recover transient network/media errors with bounded retries and exponential backoff.
|
||||||
|
- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
|
||||||
|
- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
|
||||||
|
- Surface a clear reconnecting state. Never silently loop stale media.
|
||||||
|
- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
|
||||||
|
|
||||||
|
## Failure and recovery
|
||||||
|
|
||||||
|
The service retries an item failure with a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
|
||||||
|
|
||||||
|
On process or host restart:
|
||||||
|
|
||||||
|
- create a new run/session ID and continue with globally unique segment paths;
|
||||||
|
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
||||||
|
- never overwrite old segment objects;
|
||||||
|
- keep the last valid manifest available until the replacement stream is playable;
|
||||||
|
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
||||||
|
|
||||||
|
Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
|
||||||
|
|
||||||
|
## Monitoring and acceptance checks
|
||||||
|
|
||||||
|
Monitor and alert on:
|
||||||
|
|
||||||
|
- manifest age and media-sequence advancement;
|
||||||
|
- time since last successfully published segment;
|
||||||
|
- FFmpeg restarts and consecutive item failures;
|
||||||
|
- live/slate mode;
|
||||||
|
- R2 upload latency and errors;
|
||||||
|
- segment 404/5xx rate and manifest/segment request latency;
|
||||||
|
- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
|
||||||
|
- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
|
||||||
|
- local staging disk usage.
|
||||||
|
|
||||||
|
A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
|
||||||
|
|
||||||
|
## Required configuration
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------- |
|
||||||
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
|
||||||
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
|
||||||
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
||||||
|
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||||
|
|
||||||
|
Production values are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||||
|
|
||||||
|
## Explicitly deferred
|
||||||
|
|
||||||
|
Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
|
||||||
@@ -87,10 +87,12 @@
|
|||||||
|
|
||||||
## Companion Documentation
|
## Companion Documentation
|
||||||
|
|
||||||
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
- [API.md](../API.md) - Implemented WebSocket and REST API reference.
|
||||||
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
- [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
- [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
- [SECURITY.md](../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||||
|
- [SETUP.md](../SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||||
|
- [STREAMING.md](../STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
@@ -667,7 +669,7 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng
|
|||||||
#### Transport and Edge Protection
|
#### Transport and Edge Protection
|
||||||
|
|
||||||
- Public traffic uses HTTPS/WSS at the edge.
|
- 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`).
|
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||||
|
|
||||||
#### Reverse Proxy Enforcement (nginx)
|
#### 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).
|
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.
|
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).
|
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.
|
5. Single VPS origin initially; multi-region is a later evolution.
|
||||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||||
|
|||||||
File renamed without changes.
@@ -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)
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
||||||
|
|
||||||
|
Recommended execution order:
|
||||||
|
|
||||||
|
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||||
|
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
||||||
|
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||||
|
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||||
|
5. Capture evidence and fill in the sign-off block.
|
||||||
|
|
||||||
## Status Legend
|
## Status Legend
|
||||||
|
|
||||||
- [ ] Pending
|
- [ ] Pending
|
||||||
@@ -18,6 +26,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- Screenshot of Pages deployment settings
|
- Screenshot of Pages deployment settings
|
||||||
- Deployment URL and latest deployment timestamp
|
- Deployment URL and latest deployment timestamp
|
||||||
|
- Confirmed production build output path in the Pages project
|
||||||
|
|
||||||
## 2) Tiered Cache Enablement and Validation
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
@@ -37,6 +46,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- Headers from first request (expected MISS)
|
- Headers from first request (expected MISS)
|
||||||
- Headers from repeated request (expected HIT)
|
- Headers from repeated request (expected HIT)
|
||||||
|
- Screenshot or export showing Smart Tiered Cache enabled
|
||||||
|
|
||||||
## 3) WAF and Bot Protection
|
## 3) WAF and Bot Protection
|
||||||
|
|
||||||
@@ -50,6 +60,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- WAF ruleset status screenshot
|
- WAF ruleset status screenshot
|
||||||
- Count of blocked/challenged events after cutover
|
- Count of blocked/challenged events after cutover
|
||||||
|
- Note on any false positives and the final allow/exception rules applied
|
||||||
|
|
||||||
## 4) Rate Limiting Rules
|
## 4) Rate Limiting Rules
|
||||||
|
|
||||||
@@ -67,6 +78,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- Rule definitions screenshot
|
- Rule definitions screenshot
|
||||||
- Test result notes (challenge seen / no lockout)
|
- Test result notes (challenge seen / no lockout)
|
||||||
|
- Keep a copy of the test source IP and timestamps used during validation
|
||||||
|
|
||||||
## 5) HTTPS and WSS Hardening
|
## 5) HTTPS and WSS Hardening
|
||||||
|
|
||||||
@@ -89,6 +101,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- Response headers showing HSTS
|
- Response headers showing HSTS
|
||||||
- Successful browser WebSocket connect in devtools
|
- Successful browser WebSocket connect in devtools
|
||||||
|
- TLS mode screenshot from Cloudflare showing Full (strict)
|
||||||
|
|
||||||
## 6) UptimeRobot Monitors
|
## 6) UptimeRobot Monitors
|
||||||
|
|
||||||
@@ -101,6 +114,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
- Monitor dashboard screenshot
|
- Monitor dashboard screenshot
|
||||||
- Test alert delivery confirmation
|
- Test alert delivery confirmation
|
||||||
|
- List of recipients/webhook destinations verified during the alert test
|
||||||
|
|
||||||
## 7) Sign-Off
|
## 7) Sign-Off
|
||||||
|
|
||||||
@@ -109,6 +123,15 @@ Evidence to capture:
|
|||||||
- [ ] TODO items updated to done
|
- [ ] TODO items updated to done
|
||||||
- [ ] Changelog entry added for completion batch
|
- [ ] Changelog entry added for completion batch
|
||||||
|
|
||||||
|
Completion summary:
|
||||||
|
|
||||||
|
- Pages build URL:
|
||||||
|
- Cache validation result:
|
||||||
|
- WAF/rate-limit status:
|
||||||
|
- HTTPS/HSTS status:
|
||||||
|
- UptimeRobot monitor status:
|
||||||
|
- Evidence folder or note link:
|
||||||
|
|
||||||
Sign-off fields:
|
Sign-off fields:
|
||||||
|
|
||||||
- Owner:
|
- Owner:
|
||||||
|
|||||||
@@ -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,118 @@
|
|||||||
|
# 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: 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.
|
||||||
|
- 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
|
||||||
@@ -52,6 +52,38 @@ Acceptance:
|
|||||||
- Warm-cache median < 4s in all tested regions.
|
- Warm-cache median < 4s in all tested regions.
|
||||||
- Outliers documented with likely cause.
|
- Outliers documented with likely cause.
|
||||||
|
|
||||||
|
### TTFB Baseline Worksheet
|
||||||
|
|
||||||
|
Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
|
||||||
|
|
||||||
|
- US East (Virginia)
|
||||||
|
- Date/time:
|
||||||
|
- Test URL: <https://linearvoid.tv>
|
||||||
|
- WebPageTest result URL:
|
||||||
|
- Median TTFB:
|
||||||
|
- p95 TTFB:
|
||||||
|
- Notes:
|
||||||
|
- US Central
|
||||||
|
- Date/time:
|
||||||
|
- Test URL: <https://linearvoid.tv>
|
||||||
|
- WebPageTest result URL:
|
||||||
|
- Median TTFB:
|
||||||
|
- p95 TTFB:
|
||||||
|
- Notes:
|
||||||
|
- US West (California/Oregon)
|
||||||
|
- Date/time:
|
||||||
|
- Test URL: <https://linearvoid.tv>
|
||||||
|
- WebPageTest result URL:
|
||||||
|
- Median TTFB:
|
||||||
|
- p95 TTFB:
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
Suggested capture notes:
|
||||||
|
|
||||||
|
- Use repeat view for the warm-cache measurement.
|
||||||
|
- Keep the same browser profile and test URL across regions.
|
||||||
|
- Note any CDN cache-rule changes or deploys that occurred between samples.
|
||||||
|
|
||||||
## 3) WebSocket Load-Test Plan
|
## 3) WebSocket Load-Test Plan
|
||||||
|
|
||||||
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
||||||
@@ -80,6 +112,26 @@ Acceptance:
|
|||||||
- p95 chat latency <= 1s at target concurrency tier.
|
- p95 chat latency <= 1s at target concurrency tier.
|
||||||
- error rate <= 1% during steady state.
|
- error rate <= 1% during steady state.
|
||||||
|
|
||||||
|
### WebSocket Load-Test Worksheet
|
||||||
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Test URL:
|
||||||
|
- Tool used:
|
||||||
|
- Scenario duration:
|
||||||
|
- Concurrency tiers reached:
|
||||||
|
- p50 message latency:
|
||||||
|
- p95 message latency:
|
||||||
|
- Error rate:
|
||||||
|
- Dropped connections:
|
||||||
|
- CPU/memory notes for chat:
|
||||||
|
- CPU/memory notes for redis:
|
||||||
|
- CPU/memory notes for nginx:
|
||||||
|
- Raw results location:
|
||||||
|
- Conclusion:
|
||||||
|
- Next action:
|
||||||
|
|
||||||
## 4) Admin API Benchmark Plan
|
## 4) Admin API Benchmark Plan
|
||||||
|
|
||||||
Goal: measure admin API p95 and identify slow endpoints.
|
Goal: measure admin API p95 and identify slow endpoints.
|
||||||
@@ -108,6 +160,26 @@ Acceptance:
|
|||||||
- p95 < 1s for primary admin endpoints.
|
- p95 < 1s for primary admin endpoints.
|
||||||
- slow endpoint list with remediation notes.
|
- slow endpoint list with remediation notes.
|
||||||
|
|
||||||
|
### Admin API Benchmark Worksheet
|
||||||
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Test URL:
|
||||||
|
- Tool used:
|
||||||
|
- Scenario duration:
|
||||||
|
- Concurrency / virtual users:
|
||||||
|
- `/api/admin/overview` p95:
|
||||||
|
- `/api/admin/analytics` p95:
|
||||||
|
- `/api/admin/chat-log` p95:
|
||||||
|
- `/api/admin/users` p95:
|
||||||
|
- Error rate:
|
||||||
|
- Slowest endpoint:
|
||||||
|
- Cache or DB notes:
|
||||||
|
- Raw results location:
|
||||||
|
- Conclusion:
|
||||||
|
- Next action:
|
||||||
|
|
||||||
## 5) Cache Hit Ratio Tracking Plan
|
## 5) Cache Hit Ratio Tracking Plan
|
||||||
|
|
||||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||||
@@ -119,10 +191,34 @@ Implementation outline:
|
|||||||
3. Add Grafana panel with 5m/1h/24h windows.
|
3. Add Grafana panel with 5m/1h/24h windows.
|
||||||
4. Alert when hit ratio < 90% for 15m.
|
4. Alert when hit ratio < 90% for 15m.
|
||||||
|
|
||||||
|
Reference exporter:
|
||||||
|
|
||||||
|
- `scripts/cache-hit-ratio.js`
|
||||||
|
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
||||||
|
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
||||||
|
- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
|
||||||
|
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||||
|
|
||||||
Metric target:
|
Metric target:
|
||||||
|
|
||||||
- `cache_hit_ratio >= 0.90`
|
- `cache_hit_ratio >= 0.90`
|
||||||
|
|
||||||
|
### Cache Hit Ratio Worksheet
|
||||||
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Hostname:
|
||||||
|
- Window minutes:
|
||||||
|
- Cloudflare zone tag:
|
||||||
|
- Export command:
|
||||||
|
- Raw exporter output location:
|
||||||
|
- Hit requests:
|
||||||
|
- Total requests:
|
||||||
|
- Cache hit ratio:
|
||||||
|
- Threshold met:
|
||||||
|
- Notes:
|
||||||
|
|
||||||
## 6) Capacity Trigger Rules
|
## 6) Capacity Trigger Rules
|
||||||
|
|
||||||
Define operational triggers:
|
Define operational triggers:
|
||||||
@@ -130,7 +226,7 @@ Define operational triggers:
|
|||||||
- Cache miss ratio > 10% for 15m
|
- Cache miss ratio > 10% for 15m
|
||||||
- Chat p95 latency > 1s for 5m
|
- Chat p95 latency > 1s for 5m
|
||||||
- Host CPU > 80% for 10m
|
- Host CPU > 80% for 10m
|
||||||
- Redis memory > 85% sustained
|
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||||
|
|
||||||
Action mapping:
|
Action mapping:
|
||||||
|
|
||||||
@@ -138,6 +234,22 @@ Action mapping:
|
|||||||
- Level 2: scale service resources (CPU/RAM)
|
- Level 2: scale service resources (CPU/RAM)
|
||||||
- Level 3: activate standby planning path
|
- Level 3: activate standby planning path
|
||||||
|
|
||||||
|
### Capacity Trigger Review
|
||||||
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Trigger hit:
|
||||||
|
- Threshold value:
|
||||||
|
- Observed value:
|
||||||
|
- Duration above threshold:
|
||||||
|
- Level selected:
|
||||||
|
- Immediate action taken:
|
||||||
|
- Services involved:
|
||||||
|
- Follow-up owner:
|
||||||
|
- Raw metrics / alert link:
|
||||||
|
- Notes:
|
||||||
|
|
||||||
## 7) Phase 2 Service Separation Plan
|
## 7) Phase 2 Service Separation Plan
|
||||||
|
|
||||||
Objective: separate bottlenecked services while keeping simple ops.
|
Objective: separate bottlenecked services while keeping simple ops.
|
||||||
@@ -160,6 +272,24 @@ Exit criteria for Phase 2:
|
|||||||
- repeated trigger events over 7 days
|
- repeated trigger events over 7 days
|
||||||
- capacity saturation despite tuning
|
- capacity saturation despite tuning
|
||||||
|
|
||||||
|
### Phase 2 Boundary Worksheet
|
||||||
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Trigger history summary:
|
||||||
|
- Repeated trigger count over 7 days:
|
||||||
|
- Primary bottleneck:
|
||||||
|
- Proposed split:
|
||||||
|
- `playout` target host size:
|
||||||
|
- `chat` target host size:
|
||||||
|
- `db` target host size:
|
||||||
|
- Monitoring separation needed:
|
||||||
|
- Data/state migration notes:
|
||||||
|
- Implementation owner:
|
||||||
|
- Review decision:
|
||||||
|
- Next action:
|
||||||
|
|
||||||
## 8) Phase 3 Warm Standby Plan
|
## 8) Phase 3 Warm Standby Plan
|
||||||
|
|
||||||
Objective: second-region resilience with DNS failover.
|
Objective: second-region resilience with DNS failover.
|
||||||
@@ -178,8 +308,24 @@ Design:
|
|||||||
|
|
||||||
Failover drill requirements:
|
Failover drill requirements:
|
||||||
|
|
||||||
- periodic simulated cutover
|
### Phase 3 Warm Standby Worksheet
|
||||||
- measured RTO/RPO logged in incident records
|
|
||||||
|
- Date/time:
|
||||||
|
- Environment:
|
||||||
|
- Commit SHA / image tag:
|
||||||
|
- Primary region:
|
||||||
|
- Standby region:
|
||||||
|
- DNS failover mode:
|
||||||
|
- Redis strategy:
|
||||||
|
- Postgres strategy:
|
||||||
|
- Health check endpoints:
|
||||||
|
- Simulated cutover performed:
|
||||||
|
- Measured RTO:
|
||||||
|
- Measured RPO:
|
||||||
|
- Open risks:
|
||||||
|
- Implementation owner:
|
||||||
|
- Review decision:
|
||||||
|
- Notes:
|
||||||
|
|
||||||
## 9) Data Capture Template
|
## 9) Data Capture Template
|
||||||
|
|
||||||
@@ -201,3 +347,96 @@ For each benchmark run, capture:
|
|||||||
5. Trigger tuning
|
5. Trigger tuning
|
||||||
6. Phase 2 boundary decision
|
6. Phase 2 boundary decision
|
||||||
7. Phase 3 standby design review
|
7. Phase 3 standby design review
|
||||||
|
|
||||||
|
## 11) Benchmark Run Sheet
|
||||||
|
|
||||||
|
Use the same worksheet for every baseline or re-test so results stay comparable.
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- Confirm the target environment and commit SHA/image tag.
|
||||||
|
- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
|
||||||
|
- Confirm the chat, admin, and playout services are healthy before load testing.
|
||||||
|
|
||||||
|
Run template:
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
| -------------------------- | ----- |
|
||||||
|
| Date/time | |
|
||||||
|
| Environment | |
|
||||||
|
| Commit SHA / image tag | |
|
||||||
|
| Test scenario | |
|
||||||
|
| Concurrency / sample count | |
|
||||||
|
| Command or tool | |
|
||||||
|
| Raw results location | |
|
||||||
|
| Conclusion | |
|
||||||
|
| Next action | |
|
||||||
|
|
||||||
|
Suggested command mapping:
|
||||||
|
|
||||||
|
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
||||||
|
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||||
|
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||||
|
|
||||||
|
## 12) Scaling Decision Gate
|
||||||
|
|
||||||
|
Treat the following as the default review gate after any benchmark batch:
|
||||||
|
|
||||||
|
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
|
||||||
|
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
|
||||||
|
3. If CPU, memory, or Redis saturation appears first, treat the service boundary guidance in Phase 2 as the next action.
|
||||||
|
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
|
||||||
|
|
||||||
|
Decision note:
|
||||||
|
|
||||||
|
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||||
|
|
||||||
|
## 13) Artifact-to-Worksheet Field Mapping
|
||||||
|
|
||||||
|
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||||
|
|
||||||
|
### WebSocket worksheet mapping
|
||||||
|
|
||||||
|
Artifact sources:
|
||||||
|
|
||||||
|
- `artifacts/<timestamp>/ws-chat-report.json`
|
||||||
|
- `artifacts/<timestamp>/ws-chat-summary.txt`
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
|
||||||
|
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
|
||||||
|
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||||
|
- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||||
|
- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||||
|
- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||||
|
- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
|
||||||
|
- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `median=...`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The exact `summary.*` metric names in Artillery output can vary by engine/plugins.
|
||||||
|
- If no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||||
|
|
||||||
|
### Admin API worksheet mapping
|
||||||
|
|
||||||
|
Artifact sources:
|
||||||
|
|
||||||
|
- `artifacts/<timestamp>/admin-api-summary.json`
|
||||||
|
- `artifacts/<timestamp>/admin-api-output.txt`
|
||||||
|
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
|
||||||
|
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
|
||||||
|
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||||
|
- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||||
|
- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||||
|
- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||||
|
- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||||
|
- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||||
|
- Worksheet `Slowest endpoint` -> endpoint row with highest `P95Ms`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
|
||||||
|
- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.10",
|
"version": "1.0.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+134
-3
@@ -1,16 +1,18 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
||||||
let mockFetchResponse: any = { ok: true, json: () => Promise.resolve({}) };
|
let mockFetchResponse: any = { ok: true, payload: {} };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchCalls = [];
|
fetchCalls = [];
|
||||||
globalThis.fetch = vi.fn((url: string, options?: RequestInit) => {
|
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
|
||||||
fetchCalls.push({ url, options });
|
fetchCalls.push({ url, options });
|
||||||
|
const payload = mockFetchResponse.payload ?? {};
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(JSON.stringify(mockFetchResponse.json ? mockFetchResponse.json() : mockFetchResponse), {
|
new Response(JSON.stringify(payload), {
|
||||||
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}),
|
}),
|
||||||
@@ -140,3 +142,132 @@ describe("API calls on tab load", () => {
|
|||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("admin data refresh", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="overview-status"></div>
|
||||||
|
<span id="stat-viewers"></span>
|
||||||
|
<span id="stat-stored-messages"></span>
|
||||||
|
<span id="stat-moderation-actions"></span>
|
||||||
|
<span id="stat-media-runtime"></span>
|
||||||
|
<span id="stat-media-count"></span>
|
||||||
|
<span id="system-stored-messages"></span>
|
||||||
|
<span id="system-redis-clients"></span>
|
||||||
|
<span id="system-redis-enabled"></span>
|
||||||
|
<span id="system-redis-memory"></span>
|
||||||
|
<span id="system-db-driver"></span>
|
||||||
|
<span id="system-generated-at"></span>
|
||||||
|
<div id="analytics-status"></div>
|
||||||
|
<span id="analytics-total-messages"></span>
|
||||||
|
<span id="analytics-messages-24h"></span>
|
||||||
|
<span id="analytics-distinct-rooms"></span>
|
||||||
|
<span id="analytics-moderation-actions"></span>
|
||||||
|
<span id="analytics-generated-at"></span>
|
||||||
|
<table><tbody id="analytics-table-body"></tbody></table>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders overview stats from the admin overview API", async () => {
|
||||||
|
mockFetchResponse = {
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
viewers: 12,
|
||||||
|
storedMessages: 345,
|
||||||
|
moderationActions: 9,
|
||||||
|
mediaTotalRuntime: "01:23:45",
|
||||||
|
mediaCount: 7,
|
||||||
|
redisClients: 4,
|
||||||
|
redisEnabled: true,
|
||||||
|
redisMemoryUsed: "18 MB",
|
||||||
|
databaseDriver: "sqlite",
|
||||||
|
generatedAt: "2026-08-08T12:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshOverview();
|
||||||
|
|
||||||
|
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
|
||||||
|
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
|
||||||
|
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
|
||||||
|
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders analytics rows from the analytics API", async () => {
|
||||||
|
mockFetchResponse = {
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
totalMessages: 1000,
|
||||||
|
messagesLast24Hours: 75,
|
||||||
|
distinctRooms: 3,
|
||||||
|
moderationActions: 11,
|
||||||
|
generatedAt: "2026-08-08T12:30:00Z",
|
||||||
|
topRooms: [
|
||||||
|
{ room: "main", messageCount: 55 },
|
||||||
|
{ room: "lobby", messageCount: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshAnalytics();
|
||||||
|
|
||||||
|
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
|
||||||
|
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
|
||||||
|
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
|
||||||
|
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playlist management", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="playlist-status"></div>
|
||||||
|
<textarea id="playlist-content">file '/app/media/demo1.mp4'
|
||||||
|
file '/app/media/demo2.mp4'
|
||||||
|
</textarea>
|
||||||
|
<ul id="playlist-sortable"></ul>
|
||||||
|
<div id="playlist-warnings" hidden></div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves playlist via PUT request", async () => {
|
||||||
|
mockFetchResponse = { ok: true, payload: {} };
|
||||||
|
|
||||||
|
await savePlaylist();
|
||||||
|
|
||||||
|
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
|
||||||
|
expect(putCall).toBeDefined();
|
||||||
|
expect(putCall?.url).toBe("/api/admin/playlist");
|
||||||
|
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
|
||||||
|
const body = JSON.parse(putCall?.options?.body as string);
|
||||||
|
expect(body.content).toContain("demo1.mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error status on save failure", async () => {
|
||||||
|
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||||
|
|
||||||
|
await savePlaylist();
|
||||||
|
|
||||||
|
const status = document.getElementById("playlist-status");
|
||||||
|
expect(status?.textContent).toContain("Failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders sortable playlist with indexes and paths", () => {
|
||||||
|
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
|
||||||
|
|
||||||
|
const list = document.getElementById("playlist-sortable")!;
|
||||||
|
expect(list.children).toHaveLength(2);
|
||||||
|
expect(list.innerHTML).toContain("demo1.mp4");
|
||||||
|
expect(list.innerHTML).toContain("demo2.mp4");
|
||||||
|
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
|
||||||
|
expect(list.querySelector("[data-remove]")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips comment and blank lines in sortable", () => {
|
||||||
|
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
|
||||||
|
|
||||||
|
const list = document.getElementById("playlist-sortable")!;
|
||||||
|
expect(list.children).toHaveLength(1);
|
||||||
|
expect(list.innerHTML).toContain("demo1.mp4");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -172,7 +172,7 @@ async function switchTab(tabName: string) {
|
|||||||
if (tabName === "system") refreshSystemSummary();
|
if (tabName === "system") refreshSystemSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOverview() {
|
export async function refreshOverview() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/overview");
|
const response = await fetch("/api/admin/overview");
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -198,7 +198,7 @@ async function refreshOverview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAnalytics() {
|
export async function refreshAnalytics() {
|
||||||
setStatus("analytics-status", "Loading analytics...");
|
setStatus("analytics-status", "Loading analytics...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/analytics");
|
const response = await fetch("/api/admin/analytics");
|
||||||
@@ -255,7 +255,7 @@ async function refreshPlaylist() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlaylistSortable(content: string) {
|
export function renderPlaylistSortable(content: string) {
|
||||||
const list = document.getElementById("playlist-sortable");
|
const list = document.getElementById("playlist-sortable");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
@@ -352,7 +352,7 @@ async function showPlaylistWarnings(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePlaylist() {
|
export async function savePlaylist() {
|
||||||
if (state.playlistSaving) return;
|
if (state.playlistSaving) return;
|
||||||
state.playlistSaving = true;
|
state.playlistSaving = true;
|
||||||
setStatus("playlist-status", "Saving playlist...");
|
setStatus("playlist-status", "Saving playlist...");
|
||||||
|
|||||||
+194
-17
@@ -1,18 +1,29 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
||||||
|
|
||||||
|
const hlsInstance = {
|
||||||
|
loadSource: vi.fn(),
|
||||||
|
attachMedia: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const HlsMock = Object.assign(
|
||||||
|
vi.fn().mockImplementation(() => hlsInstance),
|
||||||
|
{
|
||||||
|
isSupported: vi.fn(() => true),
|
||||||
|
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Mock hls.js
|
// Mock hls.js
|
||||||
vi.mock("hls.js", () => ({
|
vi.mock("hls.js", () => ({
|
||||||
default: vi.fn().mockImplementation(() => ({
|
default: HlsMock,
|
||||||
loadSource: vi.fn(),
|
|
||||||
attachMedia: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
destroy: vi.fn(),
|
|
||||||
})),
|
|
||||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock WebSocket
|
// Mock WebSocket
|
||||||
|
const websocketInstances: MockWebSocket[] = [];
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
url: string;
|
url: string;
|
||||||
readyState = 0;
|
readyState = 0;
|
||||||
@@ -23,7 +34,8 @@ class MockWebSocket {
|
|||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
|
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
this.url = url;
|
this.url = String(url);
|
||||||
|
websocketInstances.push(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +53,8 @@ beforeEach(() => {
|
|||||||
origWebSocket = (globalThis as any).WebSocket;
|
origWebSocket = (globalThis as any).WebSocket;
|
||||||
origFetch = globalThis.fetch;
|
origFetch = globalThis.fetch;
|
||||||
(globalThis as any).WebSocket = MockWebSocket;
|
(globalThis as any).WebSocket = MockWebSocket;
|
||||||
|
websocketInstances.length = 0;
|
||||||
|
resetViewerStateForTests();
|
||||||
|
|
||||||
// Mock fetch (schedule, etc.) — return empty schedule
|
// Mock fetch (schedule, etc.) — return empty schedule
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
@@ -82,20 +96,47 @@ beforeEach(() => {
|
|||||||
|
|
||||||
// Stub prompt for DM
|
// Stub prompt for DM
|
||||||
(globalThis as any).prompt = vi.fn();
|
(globalThis as any).prompt = vi.fn();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
(globalThis as any).WebSocket = origWebSocket;
|
(globalThis as any).WebSocket = origWebSocket;
|
||||||
globalThis.fetch = origFetch;
|
globalThis.fetch = origFetch;
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Must import after DOM setup
|
describe("player startup", () => {
|
||||||
import "../src/main";
|
it("starts native HLS playback on loadedmetadata", async () => {
|
||||||
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
|
vi.spyOn(video, "canPlayType").mockReturnValue("probably");
|
||||||
|
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||||
|
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat());
|
||||||
|
|
||||||
|
expect(video.src).toContain("/hls/playlist.m3u8?stream=");
|
||||||
|
video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
expect(playSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts hls.js playback when native HLS is unavailable", async () => {
|
||||||
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
|
Object.defineProperty(video, "canPlayType", {
|
||||||
|
value: vi.fn(() => ""),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||||
|
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
expect(HlsMock.isSupported).toHaveBeenCalled();
|
||||||
|
expect(HlsMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("chat toggle", () => {
|
describe("chat toggle", () => {
|
||||||
it("toggles chat panel visibility", () => {
|
it("toggles chat panel visibility", () => {
|
||||||
const panel = document.getElementById("chat-panel")!;
|
|
||||||
const toggle = document.getElementById("chat-toggle")!;
|
const toggle = document.getElementById("chat-toggle")!;
|
||||||
|
|
||||||
expect(toggle.textContent).toBe("Hide Chat");
|
expect(toggle.textContent).toBe("Hide Chat");
|
||||||
@@ -113,13 +154,7 @@ describe("sendMessage", () => {
|
|||||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||||
input.value = "hello!";
|
input.value = "hello!";
|
||||||
|
|
||||||
// sendMessage requires ws to be non-null — ws set in initPlayerAndChat
|
|
||||||
// The DOMContentLoaded triggers initPlayerAndChat which creates ws
|
|
||||||
// Wait for async init, then set ws manually
|
|
||||||
// For now just verify input state logic
|
|
||||||
window.sendMessage();
|
window.sendMessage();
|
||||||
// ws is null at this point, so message won't send but input won't clear either
|
|
||||||
// Verify input not cleared (ws check fails before clear)
|
|
||||||
expect(input.value).toBe("hello!");
|
expect(input.value).toBe("hello!");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,3 +194,145 @@ describe("togglePrivateDM", () => {
|
|||||||
switchRoomSpy.mockRestore();
|
switchRoomSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WebSocket reconnect", () => {
|
||||||
|
it("reconnects after onclose", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
window.switchRoom("lobby");
|
||||||
|
expect(websocketInstances).toHaveLength(1);
|
||||||
|
|
||||||
|
websocketInstances[0].onclose?.();
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
|
||||||
|
expect(websocketInstances).toHaveLength(2);
|
||||||
|
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("URL resolution", () => {
|
||||||
|
it("falls back to same-origin manifest and websocket URLs", () => {
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
|
||||||
|
vi.stubEnv("VITE_PUBLIC_WS_URL", "");
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: new URL("https://linearvoid.tv/viewer"),
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||||
|
expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an allowed cross-origin manifest URL", () => {
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
|
||||||
|
|
||||||
|
expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to same-origin manifest when cross-origin is not allowed", () => {
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||||
|
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
|
||||||
|
|
||||||
|
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured websocket URL when present", () => {
|
||||||
|
vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
|
||||||
|
|
||||||
|
expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("presence updates", () => {
|
||||||
|
it("adds user to presence list on join system message", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||||
|
|
||||||
|
const presenceList = document.getElementById("presence-list")!;
|
||||||
|
expect(presenceList.textContent).toContain("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes user from presence list on leave system message", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||||
|
|
||||||
|
const presenceList = document.getElementById("presence-list")!;
|
||||||
|
expect(presenceList.textContent).not.toContain("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders multiple users sorted", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||||
|
|
||||||
|
const presenceList = document.getElementById("presence-list")!;
|
||||||
|
const text = presenceList.textContent ?? "";
|
||||||
|
const aliceIdx = text.indexOf("alice");
|
||||||
|
const zoeIdx = text.indexOf("zoe");
|
||||||
|
expect(aliceIdx).toBeLessThan(zoeIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("private DM flow", () => {
|
||||||
|
it("creates dm: room name from sorted usernames", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||||
|
window.togglePrivateDM();
|
||||||
|
|
||||||
|
const roomName = document.getElementById("room-name")!;
|
||||||
|
// username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
|
||||||
|
// switchRoom capitalizes first char: "Dm:bob-viewer Room"
|
||||||
|
expect(roomName.textContent).toContain("bob-viewer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message rendering", () => {
|
||||||
|
it("renders system messages", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
|
||||||
|
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
expect(messages.innerHTML).toContain("Server restarting");
|
||||||
|
expect(messages.querySelector(".message-system")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders self messages as message-self", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
expect(messages.innerHTML).toContain("hello world");
|
||||||
|
expect(messages.querySelector(".message-self")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders other-user messages as message-other", async () => {
|
||||||
|
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||||
|
|
||||||
|
const ws = websocketInstances[0];
|
||||||
|
ws.onmessage?.({
|
||||||
|
data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = document.getElementById("messages")!;
|
||||||
|
expect(messages.innerHTML).toContain("alice");
|
||||||
|
expect(messages.innerHTML).toContain("hey");
|
||||||
|
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-4
@@ -18,7 +18,16 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
|
|||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
const onlineUsers = new Set<string>();
|
const onlineUsers = new Set<string>();
|
||||||
|
|
||||||
function resolveWebSocketBaseUrl() {
|
export function resetViewerStateForTests() {
|
||||||
|
hls = null;
|
||||||
|
ws = null;
|
||||||
|
currentRoom = "main";
|
||||||
|
username = "viewer";
|
||||||
|
chatVisible = true;
|
||||||
|
onlineUsers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWebSocketBaseUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||||
|
|
||||||
if (configuredUrl) {
|
if (configuredUrl) {
|
||||||
@@ -29,7 +38,7 @@ function resolveWebSocketBaseUrl() {
|
|||||||
return `${protocol}//${window.location.host}/ws`;
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveManifestUrl() {
|
export function resolveManifestUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
||||||
const cacheBust = `stream=${Date.now()}`;
|
const cacheBust = `stream=${Date.now()}`;
|
||||||
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
||||||
@@ -139,7 +148,7 @@ async function refreshSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initPlayerAndChat() {
|
export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
|
||||||
const video = document.getElementById("player") as HTMLVideoElement;
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
const manifestUrl = resolveManifestUrl();
|
const manifestUrl = resolveManifestUrl();
|
||||||
|
|
||||||
@@ -148,7 +157,7 @@ async function initPlayerAndChat() {
|
|||||||
video.addEventListener("loadedmetadata", () => video.play());
|
video.addEventListener("loadedmetadata", () => video.play());
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { default: Hls } = await import("hls.js");
|
const { default: Hls } = await loadHlsModule();
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
hls = new Hls();
|
hls = new Hls();
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
|
|||||||
@@ -34,6 +34,33 @@
|
|||||||
"title": "Chat Scrape Health",
|
"title": "Chat Scrape Health",
|
||||||
"type": "stat",
|
"type": "stat",
|
||||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cache Hit Ratio",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "linearvoid_cache_hit_ratio"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Redis Memory Usage",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "linearvoid_chat_redis_memory_usage_ratio"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Chat Request Latency p95",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le))"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -38,13 +38,13 @@ groups:
|
|||||||
description: "Playout has switched to emergency slate. Check playout logs."
|
description: "Playout has switched to emergency slate. Check playout logs."
|
||||||
|
|
||||||
- alert: HighChatLatency
|
- alert: HighChatLatency
|
||||||
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
expr: histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le)) > 1
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Unusually low chat message rate"
|
summary: "Chat request latency is high"
|
||||||
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
|
description: "Chat request p95 has been above 1s for 5 minutes. Check chat handlers and dependencies."
|
||||||
|
|
||||||
- alert: PrometheusWALFailing
|
- alert: PrometheusWALFailing
|
||||||
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||||
@@ -64,6 +64,24 @@ groups:
|
|||||||
summary: "High CPU usage"
|
summary: "High CPU usage"
|
||||||
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
||||||
|
|
||||||
|
- alert: LowCacheHitRatio
|
||||||
|
expr: linearvoid_cache_hit_ratio < 0.9
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Cloudflare cache hit ratio is below target"
|
||||||
|
description: "Cache hit ratio has been below 90% for 15 minutes on {{ $labels.hostname }}."
|
||||||
|
|
||||||
|
- alert: HighRedisMemoryUsage
|
||||||
|
expr: linearvoid_chat_redis_memory_usage_ratio > 0.85
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Redis memory usage is above target"
|
||||||
|
description: "Redis memory usage has been above 85% for 10 minutes on {{ $labels.instance }}."
|
||||||
|
|
||||||
- alert: LowDiskSpace
|
- alert: LowDiskSpace
|
||||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||||
for: 5m
|
for: 5m
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||||
|
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
|
||||||
|
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
|
||||||
|
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
|
||||||
|
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
|
||||||
|
|
||||||
|
if (!apiToken || !zoneTag) {
|
||||||
|
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
const iso = (value) => value.toISOString();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
|
||||||
|
viewer {
|
||||||
|
zones(filter: { zoneTag: $zoneTag }) {
|
||||||
|
requests: httpRequestsAdaptiveGroups(
|
||||||
|
limit: 1000
|
||||||
|
filter: {
|
||||||
|
datetime_geq: $start
|
||||||
|
datetime_lt: $end
|
||||||
|
clientRequestHTTPHost: $hostname
|
||||||
|
requestSource: "eyeball"
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
count
|
||||||
|
dimensions {
|
||||||
|
cacheStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables: {
|
||||||
|
zoneTag,
|
||||||
|
hostname,
|
||||||
|
start: iso(start),
|
||||||
|
end: iso(end),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || payload.errors) {
|
||||||
|
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
|
||||||
|
let totalRequests = 0;
|
||||||
|
let hitRequests = 0;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const count = Number(group?.count || 0);
|
||||||
|
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
|
||||||
|
totalRequests += count;
|
||||||
|
if (status === "HIT") {
|
||||||
|
hitRequests += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
|
||||||
|
);
|
||||||
|
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
|
||||||
|
console.log(
|
||||||
|
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
|
||||||
|
);
|
||||||
|
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
|
||||||
|
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
|
||||||
|
console.log(
|
||||||
|
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
|
||||||
|
);
|
||||||
|
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
|
||||||
|
console.log(
|
||||||
|
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
|
||||||
|
|
||||||
|
& node $nodeScript @args
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Load Test Scaffolding
|
||||||
|
|
||||||
|
These files back the scaling/performance playbook.
|
||||||
|
|
||||||
|
## WebSocket chat
|
||||||
|
|
||||||
|
Run with an explicit WebSocket URL, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Capture output to an artifact file for the worksheet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell equivalent:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||||
|
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||||
|
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
Run with a cookie or session header that can access admin endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Capture machine-readable metrics and a text summary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell equivalent:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
|
||||||
|
$env:ADMIN_SESSION_COOKIE = 'session=...'
|
||||||
|
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||||
|
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
||||||
|
|
||||||
|
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## PowerShell helper
|
||||||
|
|
||||||
|
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
|
||||||
|
|
||||||
|
- The helper resolves scenario paths from its own script location, so it can be run from any current working directory.
|
||||||
|
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
|
||||||
|
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Adjust target URLs for test lab or production.
|
||||||
|
- Keep the same commit SHA or image tag when comparing runs.
|
||||||
|
- Record results using the worksheet in `docs/operations/scaling-performance-playbook.md`.
|
||||||
|
- Store raw outputs under an `artifacts/` folder per run date (for example `artifacts/2026-08-09/`).
|
||||||
|
- The repository `.gitignore` excludes benchmark artifacts by default.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import http from "k6/http";
|
||||||
|
import { check, sleep } from "k6";
|
||||||
|
|
||||||
|
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
|
||||||
|
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
|
||||||
|
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ name: "overview", path: "/api/admin/overview" },
|
||||||
|
{ name: "analytics", path: "/api/admin/analytics" },
|
||||||
|
{ name: "chat_log", path: "/api/admin/chat-log" },
|
||||||
|
{ name: "users", path: "/api/admin/users" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: "30s", target: 10 },
|
||||||
|
{ duration: "60s", target: 50 },
|
||||||
|
{ duration: "60s", target: 100 },
|
||||||
|
{ duration: "30s", target: 0 },
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ["p(95)<1000"],
|
||||||
|
http_req_failed: ["rate<0.01"],
|
||||||
|
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
|
||||||
|
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
|
||||||
|
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
|
||||||
|
"http_req_duration{endpoint:users}": ["p(95)<1000"],
|
||||||
|
"http_req_failed{endpoint:overview}": ["rate<0.01"],
|
||||||
|
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
|
||||||
|
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
|
||||||
|
"http_req_failed{endpoint:users}": ["rate<0.01"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const requests = endpoints.map((endpoint) => [
|
||||||
|
"GET",
|
||||||
|
`${baseUrl}${endpoint.path}`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
...requestParams,
|
||||||
|
tags: {
|
||||||
|
endpoint: endpoint.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const responses = http.batch(requests);
|
||||||
|
|
||||||
|
responses.forEach((response, index) => {
|
||||||
|
const endpointPath = endpoints[index].path;
|
||||||
|
check(response, {
|
||||||
|
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
param(
|
||||||
|
[ValidateSet('ws', 'admin', 'all')]
|
||||||
|
[string]$Scenario = 'all',
|
||||||
|
[string]$ArtifactRoot = 'artifacts',
|
||||||
|
[string]$WsUrl = $env:WS_URL,
|
||||||
|
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
|
||||||
|
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
|
||||||
|
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
|
||||||
|
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
|
||||||
|
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
|
||||||
|
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
|
||||||
|
|
||||||
|
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
||||||
|
$artifactBase = $ArtifactRoot
|
||||||
|
} else {
|
||||||
|
$artifactBase = Join-Path $repoRoot $ArtifactRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Scenario -in @('ws', 'all')) {
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'npx is required for ws scenario but was not found in PATH.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Scenario -in @('admin', 'all')) {
|
||||||
|
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'k6 is required for admin scenario but was not found in PATH.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
|
||||||
|
$artifactDir = Join-Path $artifactBase $timestamp
|
||||||
|
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||||
|
|
||||||
|
function Invoke-WebSocketLoadTest {
|
||||||
|
param(
|
||||||
|
[string]$TargetWsUrl,
|
||||||
|
[string]$OutputPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
|
||||||
|
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:WS_URL = $TargetWsUrl
|
||||||
|
& npx artillery run $wsScenarioPath --output $OutputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-AdminApiLoadTest {
|
||||||
|
param(
|
||||||
|
[string]$BaseUrl,
|
||||||
|
[string]$SessionCookie,
|
||||||
|
[string]$SummaryPath,
|
||||||
|
[string]$OutputPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||||
|
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
|
||||||
|
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:ADMIN_BASE_URL = $BaseUrl
|
||||||
|
$env:ADMIN_SESSION_COOKIE = $SessionCookie
|
||||||
|
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Scenario -in @('ws', 'all')) {
|
||||||
|
if (-not (Test-Path $wsSummaryScriptPath)) {
|
||||||
|
throw "Missing summary helper: $wsSummaryScriptPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
|
||||||
|
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
|
||||||
|
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
|
||||||
|
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Scenario -in @('admin', 'all')) {
|
||||||
|
if (-not (Test-Path $adminSummaryScriptPath)) {
|
||||||
|
throw "Missing summary helper: $adminSummaryScriptPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
|
||||||
|
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
|
||||||
|
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
|
||||||
|
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
|
||||||
|
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Artifacts written to $artifactDir"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ReportFile
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path $ReportFile)) {
|
||||||
|
throw "Report file not found: $ReportFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
|
||||||
|
$aggregate = $report.aggregate
|
||||||
|
|
||||||
|
if (-not $aggregate) {
|
||||||
|
throw 'Artillery report is missing aggregate data.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PropertyValue {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[object]$Object,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $Object) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$property = $Object.PSObject.Properties[$Name]
|
||||||
|
if ($property) {
|
||||||
|
return $property.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-StatLine {
|
||||||
|
param(
|
||||||
|
[string]$Prefix,
|
||||||
|
[object]$Stats
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $Stats) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = @()
|
||||||
|
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
|
||||||
|
$value = Get-PropertyValue -Object $Stats -Name $name
|
||||||
|
if ($null -ne $value) {
|
||||||
|
$parts += "$name=$value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts.Count -eq 0) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$Prefix: $($parts -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
|
||||||
|
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
|
||||||
|
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
|
||||||
|
|
||||||
|
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
|
||||||
|
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
|
||||||
|
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
|
||||||
|
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
|
||||||
|
|
||||||
|
Write-Output '=== Artillery WebSocket Summary ==='
|
||||||
|
Write-Output "source: $ReportFile"
|
||||||
|
|
||||||
|
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
|
||||||
|
if ($timestamp) {
|
||||||
|
Write-Output "timestamp: $timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $created) {
|
||||||
|
Write-Output "vusers.created: $created"
|
||||||
|
}
|
||||||
|
if ($null -ne $completed) {
|
||||||
|
Write-Output "vusers.completed: $completed"
|
||||||
|
}
|
||||||
|
if ($null -ne $failed) {
|
||||||
|
Write-Output "vusers.failed: $failed"
|
||||||
|
}
|
||||||
|
Write-Output "vusers.failure_rate_percent: $errorRate"
|
||||||
|
|
||||||
|
if ($counters) {
|
||||||
|
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
|
||||||
|
$value = Get-PropertyValue -Object $counters -Name $metric
|
||||||
|
if ($null -ne $value) {
|
||||||
|
Write-Output "$metric: $value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rates) {
|
||||||
|
foreach ($property in $rates.PSObject.Properties) {
|
||||||
|
Write-Output "rate.$($property.Name): $($property.Value)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($summaries) {
|
||||||
|
foreach ($property in $summaries.PSObject.Properties) {
|
||||||
|
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
|
||||||
|
if ($line) {
|
||||||
|
Write-Output $line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SummaryFile
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path $SummaryFile)) {
|
||||||
|
throw "Summary file not found: $SummaryFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
|
||||||
|
$metrics = $summary.metrics
|
||||||
|
|
||||||
|
if (-not $metrics) {
|
||||||
|
throw 'Invalid k6 summary file: missing metrics object.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
|
||||||
|
|
||||||
|
function Get-MetricValue {
|
||||||
|
param(
|
||||||
|
[object]$Metric,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $Metric) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
if (-not $Metric.values) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $Metric.values.$Key
|
||||||
|
if ($null -ne $value) {
|
||||||
|
return [double]$value
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
|
||||||
|
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
|
||||||
|
|
||||||
|
Write-Output "k6 Summary: $SummaryFile"
|
||||||
|
if ($null -ne $globalDuration) {
|
||||||
|
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
|
||||||
|
}
|
||||||
|
if ($null -ne $globalFailRate) {
|
||||||
|
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
|
||||||
|
}
|
||||||
|
Write-Output ''
|
||||||
|
|
||||||
|
$rows = foreach ($endpoint in $endpoints) {
|
||||||
|
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
|
||||||
|
$failMetricName = "http_req_failed{endpoint:$endpoint}"
|
||||||
|
|
||||||
|
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
|
||||||
|
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
|
||||||
|
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Endpoint = $endpoint
|
||||||
|
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
|
||||||
|
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows | Format-Table -AutoSize | Out-String | Write-Output
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
config:
|
||||||
|
target: "{{ $processEnvironment.WS_URL }}"
|
||||||
|
phases:
|
||||||
|
- duration: 60
|
||||||
|
arrivalRate: 10
|
||||||
|
name: warmup-10
|
||||||
|
- duration: 120
|
||||||
|
arrivalRate: 100
|
||||||
|
name: sustain-100
|
||||||
|
- duration: 180
|
||||||
|
arrivalRate: 500
|
||||||
|
name: sustain-500
|
||||||
|
- duration: 240
|
||||||
|
arrivalRate: 1000
|
||||||
|
name: sustain-1000
|
||||||
|
engines:
|
||||||
|
ws: {}
|
||||||
|
scenarios:
|
||||||
|
- engine: ws
|
||||||
|
name: chat-presence-and-send
|
||||||
|
flow:
|
||||||
|
- think: 1
|
||||||
|
- send:
|
||||||
|
text: '{"type":"ping","text":"loadtest"}'
|
||||||
|
- think: 1
|
||||||
|
- send:
|
||||||
|
text: '{"type":"ping","text":"loadtest-2"}'
|
||||||
|
- think: 1
|
||||||
Reference in new issue
Block a user