29 Commits
Author SHA1 Message Date
zwitschi e7fc921d6a fix: force-recreate containers on deploy to pick up volume changes
Release / release (push) Successful in 33s
CI / test (push) Successful in 55s
CI / docker (push) Successful in 26s
CD / deploy (push) Successful in 47s
2026-08-06 22:42:08 +02:00
zwitschi 04bb783a12 fix: docker login on remote host before pull private images
Release / release (push) Successful in 36s
CD / deploy (push) Successful in 50s
CI / test (push) Successful in 58s
CI / docker (push) Successful in 25s
2026-08-06 22:22:27 +02:00
zwitschi 68bd241f2c fix: use named volume for prometheus config, populate on remote host
Release / release (push) Successful in 29s
CI / test (push) Successful in 53s
CI / docker (push) Successful in 22s
CD / deploy (push) Failing after 10s
2026-08-06 22:13:24 +02:00
zwitschi 4c02ab9d55 fix: parallel CD trigger, remove env blocker, cleanup release pipeline
CD / deploy (push) Failing after 10s
CI / test (push) Successful in 52s
CI / docker (push) Successful in 21s
Release / release (push) Successful in 26s
2026-08-06 22:01:29 +02:00
zwitschi 74bf7e19db chore: add BACKLOG.md to gitignore 2026-08-06 21:45:50 +02:00
zwitschi 92e074b6d0 chore: remove BACKLOG.md
Release / release (push) Successful in 25s
2026-08-06 21:39:25 +02:00
zwitschi a70bad1528 chore: release v1.0.9 - monitoring provisioning + cleanup tracking files
CI / test (push) Successful in 56s
Release / release (push) Successful in 3m20s
CI / docker (push) Successful in 2m52s
CI / release (push) Skipped
2026-08-06 21:28:53 +02:00
zwitschi b47f2621e1 docs: update backlog 2026-08-06 20:46:54 +02:00
zwitschi 7244c1d658 feat: enhance chat service with Redis pub/sub for multi-instance message handling, add instance ID for deduplication 2026-08-06 20:44:29 +02:00
zwitschi d991d6b6c7 chore: changelog 1.0.8 — doc polish
CI / test (push) Successful in 51s
CI / docker (push) Successful in 22s
CI / release (push) Skipped
2026-08-06 20:01:11 +02:00
zwitschi db3bf96847 docs: complete API error codes, deploy troubleshooting, monitoring/security rewrite, L2+L3 building blocks 2026-08-06 20:00:45 +02:00
zwitschi 0f81a81029 feat: monitoring exporters, alerting, rate limiting, backups, incident runbook
CI / test (push) Successful in 58s
CI / docker (push) Successful in 2m22s
CI / release (push) Skipped
- Add 4 Prometheus exporters: nginx (9113), redis (9121), postgres (9187), node (9100)
- Create 8 alerting rules in monitoring/prometheus/alerts.yml
- Update prometheus.yml with all scrape targets + rule_files
- WS connection rate limiting: max 5/IP/60s (Redis INCR)
- Admin login rate limiting: max 5/IP/5min (Redis INCR)
- 3 backup scripts: daily to R2 (14d), weekly full (8w), verify with restore test
- docs/incident-runbook.md with 5-tier triage + escalation
- Update CD workflow: full prometheus config + alerts seeding
2026-08-06 19:31:40 +02:00
zwitschi b5710548b1 fix: botched edit
CI / test (push) Successful in 2m21s
CI / docker (push) Successful in 3m2s
CI / release (push) Skipped
Release / release (push) Successful in 26s
2026-08-06 18:08:23 +02:00
zwitschi d2e5b64ece fix: chat build syntax errors, disable GitHub cache on Gitea runner
CI / test (push) Failing after 57s
CI / docker (push) Skipped
CI / release (push) Skipped
- Fix 3 orphaned function bodies in main.go (handleAdminSchedule,
  handleAdminSystemSummary, probeMediaDurationSeconds) caused by
  insertions that split function headers from bodies
- Remove cache: true from setup-go and cache: npm from setup-node
  (Gitea runner times out 135s trying to reach GitHub cache API)
- Add go vet ./... step replacing placeholder echo
- Remove || echo fallback from lint (tsc --noEmit is working)
2026-08-06 18:01:34 +02:00
zwitschi 5b5cdc4cd8 chore: release v1.0.7
CI / test (push) Successful in 9m11s
CI / docker (push) Failing after 2m39s
CI / release (push) Skipped
Release / release (push) Canceled after 2m11s
Playout: slate fallback, strict ffprobe validation, Prometheus metrics
Chat: history on join, DM offline delivery, env-config rate limits,
  Redis auto-reconnect, heartbeat, ban/mute enforcement, DM metrics
Viewer: presence indicators, schedule overlay, public /api/schedule
Admin: playlist drag-reorder, schedule overrides CRUD, user actions,
  inline mod buttons, CSV export, config editor, restart buttons,
  upload normalization gate, analytics time range
nginx: CORS preflight, JSON health, stub_status
Docs: test-lab vs production distinction across all configs/docs
Infra: Redis maxmemory, CD health IP-agnostic, slate volume mount
2026-08-06 17:41:52 +02:00
zwitschi 17ac04856b feat: redesign color system to monochrome palette with sharp corners
Release / release (push) Successful in 1m0s
CI / test (push) Successful in 11m6s
CI / docker (push) Successful in 41s
CI / release (push) Skipped
2026-08-05 17:26:30 +02:00
zwitschi 69df0e51b8 release version 1.0.6
CI / test (push) Successful in 9m23s
CI / docker (push) Successful in 3m14s
CI / release (push) Skipped
2026-08-05 17:09:03 +02:00
zwitschi 9a06dc389d feat: update css with new color scheme
CI / test (push) Successful in 7m11s
CI / docker (push) Successful in 51s
CI / release (push) Skipped
2026-08-05 16:31:18 +02:00
zwitschi 51f3e98816 feat: CI pipeline improvements
CI / test (push) Successful in 7m17s
CI / docker (push) Successful in 7m49s
CI / release (push) Skipped
- remove image build duplication
- update workflows to use caching
2026-08-05 16:10:15 +02:00
zwitschi 68ba3a25b9 fix: deployment health check
CI / test (push) Successful in 1m52s
CI / docker (push) Successful in 3m20s
CI / release (push) Skipped
2026-08-05 12:44:40 +02:00
zwitschi 7e9308c48a fix: move admin pages
CI / test (push) Successful in 10m45s
CI / docker (push) Successful in 3m20s
CI / release (push) Skipped
2026-08-05 12:18:44 +02:00
zwitschi 21a34a4bd5 Release version 1.0.5
CI / test (push) Successful in 10m42s
CI / docker (push) Successful in 4m43s
CI / release (push) Skipped
Chat Message Admin Improvements: 3 API endpoints, 9 page HTML split, chat-log UI with delete/confirm/pagination/filter/bulk delete

CI/CD Improvements: Go cache fix, lint script, Vite env cleanup, chat start_period 30s, nginx health check, log dump on fail
2026-08-05 11:44:46 +02:00
zwitschi ee16441b79 docs: update agent instructions 2026-08-05 11:29:52 +02:00
zwitschi 74133ec589 feat: add chat log admin management and refactor admin panel to lazy-load tab content 2026-08-05 11:29:11 +02:00
zwitschi f650a3a94e docs: enhance architecture documentation with detailed sections on requirements, quality goals, context, and scaling strategy 2026-08-05 10:20:33 +02:00
zwitschi c10d98f3b4 Refactor code structure for improved readability and maintainability
CI / test (push) Successful in 3m51s
CI / docker (push) Successful in 6m16s
CI / release (push) Skipped
2026-08-04 21:19:49 +02:00
zwitschi 207401b5fb docs: expand architecture documentation with scaling strategy, API surface reference, and video normalization spec 2026-08-04 20:57:33 +02:00
zwitschi d4d7806189 feat: implement dark mode theme and refactor viewer layout for improved responsiveness 2026-08-04 20:51:22 +02:00
zwitschi 497f7743f2 fix: increase nginx client_max_body_size for resumable admin uploads
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 4m27s
CI / release (push) Skipped
2026-08-04 20:35:24 +02:00
54 changed files with 6467 additions and 858 deletions

No files matched your search

+115 -28
View File
@@ -1,18 +1,18 @@
# CD workflow — TEST LAB DEPLOYMENT
# Deploys to test lab host 192.168.88.40:2375.
# Production Hetzner target TBD — switch DOCKER_HOST when provisioned.
# See DEPLOYMENT.md Production Deployment section for cutover plan.
name: CD name: CD
on: on:
workflow_run: push:
workflows: ["CI"]
types:
- completed
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
runs-on: [ubuntu-latest] runs-on: [ubuntu-latest]
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
environment: production
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,12 +22,11 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry - name: Login to registry on remote host
uses: docker/login-action@v3 env:
with: DOCKER_HOST: tcp://192.168.88.40:2375
registry: git.allucanget.biz run: |
username: ${{ secrets.CI_REGISTRY_USER }} echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin
password: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Prepare environment - name: Prepare environment
run: | run: |
@@ -44,52 +43,140 @@ jobs:
NGINX_PORT=80 NGINX_PORT=80
EOF EOF
- name: Seed Prometheus config - name: Seed Prometheus config on remote host
env:
DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
mkdir -p monitoring/prometheus docker run --rm \
cat > monitoring/prometheus/prometheus.yml << 'EOF' -v prometheus-config:/etc/prometheus \
alpine:latest sh -c '
cat > /etc/prometheus/prometheus.yml << "PROMEOF"
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
scrape_configs: rule_files:
- job_name: 'prometheus' - "/etc/prometheus/alerts.yml"
static_configs:
- targets: ['localhost:9090']
- job_name: 'linearvoid' scrape_configs:
- job_name: "chat"
static_configs: static_configs:
- targets: ['playout:8081', 'chat:3000', 'nginx:80'] - targets: ["chat:3000"]
EOF - job_name: "playout"
static_configs:
- targets: ["playout:8081"]
- job_name: "nginx"
static_configs:
- targets: ["nginx-exporter:9113"]
- job_name: "redis"
static_configs:
- targets: ["redis-exporter:9121"]
- job_name: "postgres"
static_configs:
- targets: ["postgres-exporter:9187"]
- job_name: "node"
static_configs:
- targets: ["node-exporter:9100"]
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
PROMEOF
cat > /etc/prometheus/alerts.yml << "ALERTSEOF"
groups:
- name: linearvoid
rules:
- alert: ChatDown
expr: up{job="chat"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Chat service is down"
- alert: PlayoutDown
expr: up{job="playout"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Playout service is down"
- alert: RedisDown
expr: linearvoid_chat_redis_up == 0
for: 2m
labels:
severity: warning
annotations:
summary: "Redis is unreachable"
- alert: PlayoutSlateActive
expr: linearvoid_playout_mode == 1
for: 1m
labels:
severity: warning
annotations:
summary: "Playout in slate fallback"
- alert: HighCPU
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m
labels:
severity: warning
annotations:
summary: "High CPU usage"
- alert: LowDiskSpace
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
for: 5m
labels:
severity: warning
annotations:
summary: "Low disk space"
ALERTSEOF
'
- name: Deploy with docker-compose - name: Deploy with docker-compose
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
- name: Health check - name: Health check
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
# Wait for playout to stabilize (it may restart during init) # Wait for services to stabilize
sleep 20 sleep 20
curl -f http://192.168.88.40:3000/health || exit 1
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
echo "Checking chat health..."
if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then
echo "Chat health check failed"
docker logs linearvoid-chat --tail 50
exit 1
fi
# Check nginx health
echo "Checking nginx health..."
if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then
echo "Nginx health check failed"
docker logs linearvoid-nginx --tail 50
exit 1
fi
# Retry playout health check since it may still be initializing # Retry playout health check since it may still be initializing
echo "Checking playout health..."
for i in $(seq 1 6); do for i in $(seq 1 6); do
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout is healthy" echo "Playout is healthy"
exit 0 break
fi fi
echo "Playout not ready yet (attempt $i/6), waiting 5s..." echo "Playout not ready yet (attempt $i/6), waiting 5s..."
sleep 5 sleep 5
done done
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout failed to become healthy after 6 attempts" echo "Playout failed to become healthy after 6 attempts"
docker logs linearvoid-playout --tail 50 docker logs linearvoid-playout --tail 50
exit 1 exit 1
curl -f http://192.168.88.40:3000/health || exit 1 fi
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
echo "All services healthy"
- name: Cleanup old images - name: Cleanup old images
env: env:
+21 -43
View File
@@ -19,14 +19,12 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
- name: Lint Frontend - name: Lint Frontend
run: cd frontend && npm run lint || echo "Linting not configured yet" run: cd frontend && npm run lint
- name: Build Frontend - name: Build Frontend
run: cd frontend && npm run build run: cd frontend && npm run build
@@ -34,17 +32,18 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.22" go-version: "1.25"
cache: true env:
GOTOOLCHAIN: local
- name: Validate Playlist Normalization - name: Download Go modules
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete" run: cd backend/chat && GOTOOLCHAIN=local go mod download
- name: Build Docker Images - name: Validate Go
run: docker compose build --no-cache run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
- name: Run Tests - name: Run Tests
run: echo "Tests will be added in future CI steps (per TODO.md)" run: echo "Tests will be added in future CI steps"
docker: docker:
needs: test needs: test
@@ -86,6 +85,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta-playout.outputs.tags }} tags: ${{ steps.meta-playout.outputs.tags }}
labels: ${{ steps.meta-playout.outputs.labels }} labels: ${{ steps.meta-playout.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
- name: Extract metadata for Chat - name: Extract metadata for Chat
id: meta-chat id: meta-chat
@@ -106,6 +107,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta-chat.outputs.tags }} tags: ${{ steps.meta-chat.outputs.tags }}
labels: ${{ steps.meta-chat.outputs.labels }} labels: ${{ steps.meta-chat.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
- name: Extract metadata for Nginx - name: Extract metadata for Nginx
id: meta-nginx id: meta-nginx
@@ -120,37 +123,12 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Nginx image - name: Build and push Nginx image
run: | uses: docker/build-push-action@v5
TAGS="${{ steps.meta-nginx.outputs.tags }}"
LABELS="${{ steps.meta-nginx.outputs.labels }}"
TAG_ARGS=""
for tag in $TAGS; do
TAG_ARGS="$TAG_ARGS --tag $tag"
done
LABEL_ARGS=""
while IFS= read -r label; do
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
done <<< "$LABELS"
docker buildx build \
--push \
--file infrastructure/nginx/Dockerfile \
$TAG_ARGS \
$LABEL_ARGS \
.
release:
needs: docker
runs-on: [ubuntu-latest]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout code
uses: actions/checkout@v4
with: with:
fetch-depth: 0 context: .
file: infrastructure/nginx/Dockerfile
- name: Create Release push: true
uses: softprops/action-gh-release@v1 tags: ${{ steps.meta-nginx.outputs.tags }}
with: labels: ${{ steps.meta-nginx.outputs.labels }}
generate_release_notes: true cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
env: cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
+5 -3
View File
@@ -30,13 +30,15 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.22" go-version: "1.25"
cache: true cache: true
env:
GOTOOLCHAIN: local
- name: Update Go Dependencies - name: Update Go Dependencies
run: | run: |
cd backend/playout && go get -u ./... && go mod tidy cd backend/playout && GOTOOLCHAIN=local go get -u ./... && go mod tidy
cd ../chat && go get -u ./... && go mod tidy cd ../chat && GOTOOLCHAIN=local go get -u ./... && go mod tidy
- name: Commit and push changes - name: Commit and push changes
run: | run: |
+8 -2
View File
@@ -3,11 +3,11 @@ name: Release
on: on:
push: push:
tags: tags:
- "v*" - "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version to release (e.g., v1.0.0)" description: "Version to release (e.g., 1.0.0)"
required: true required: true
type: string type: string
@@ -71,6 +71,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta-playout.outputs.tags }} tags: ${{ steps.meta-playout.outputs.tags }}
labels: ${{ steps.meta-playout.outputs.labels }} labels: ${{ steps.meta-playout.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
- name: Extract metadata for Chat - name: Extract metadata for Chat
id: meta-chat id: meta-chat
@@ -89,6 +91,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta-chat.outputs.tags }} tags: ${{ steps.meta-chat.outputs.tags }}
labels: ${{ steps.meta-chat.outputs.labels }} labels: ${{ steps.meta-chat.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
- name: Extract metadata for Nginx - name: Extract metadata for Nginx
id: meta-nginx id: meta-nginx
@@ -108,6 +112,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta-nginx.outputs.tags }} tags: ${{ steps.meta-nginx.outputs.tags }}
labels: ${{ steps.meta-nginx.outputs.labels }} labels: ${{ steps.meta-nginx.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
- name: Create Release on Gitea - name: Create Release on Gitea
run: | run: |
+9 -2
View File
@@ -2,11 +2,18 @@
Agents instructions for the linearvoid project. Agents instructions for the linearvoid project.
## Output
- use /caveman skill, latest version from <https://github.com/JuliusBrussee/caveman> is <https://github.com/JuliusBrussee/caveman/tree/main/skills/caveman>
## General Guidelines ## General Guidelines
- Use the architecture documentation (arc42 template and ADRs) as the source of truth for design decisions. - Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
- Follow the project folder structure defined in ADR-002 and reflected in the root README. - Follow the project folder structure in the root README.
- Update documentation first before implementing changes to ensure traceability of decisions. - Update documentation first before implementing changes to ensure traceability of decisions.
## 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 `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.
- 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.
+1
View File
@@ -81,6 +81,7 @@ Thumbs.db
# Agent / Tool files # Agent / Tool files
.github/instructions/TODO.md .github/instructions/TODO.md
.github/instructions/DONE.md .github/instructions/DONE.md
.github/instructions/BACKLOG.md
# (keep AGENTS.md, MEMORY.md, and other instruction files) # (keep AGENTS.md, MEMORY.md, and other instruction files)
# Temporary architecture artifacts # Temporary architecture artifacts
+307
View File
@@ -0,0 +1,307 @@
# API Reference
This document describes the currently implemented API surfaces in Linear Void.
## Base URLs
- Public app and admin: <https://linearvoid.tv>
- WebSocket endpoint: <wss://linearvoid.tv/ws>
- Local fallback: <http://localhost> and <ws://localhost/ws>
## Authentication Model
Admin REST endpoints use a cookie session.
- Cookie name: linearvoid_admin_session
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
## Chat WebSocket API
### Connect
Endpoint:
- GET /ws?username={name}&room={room}
Client sends JSON messages over the socket.
### Message Envelope (client -> server)
```json
{
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public"
}
```
Notes:
- type is usually public for room chat and private for DM room routing.
- Private room names are formatted like dm:userA-userB (sorted usernames).
- Moderation/system events are sent as type=system messages.
### Message Envelope (server -> client)
```json
{
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public",
"timestamp": "2026-08-04T12:34:56Z"
}
```
## Admin Session API
### Login
- POST /api/admin/login
- Auth: none
Request:
```json
{
"username": "admin",
"password": "your-password"
}
```
Success response:
```json
{
"authenticated": true,
"username": "admin"
}
```
### Logout
- POST /api/admin/logout
- Auth: optional (clears cookie either way)
Response:
```json
{
"authenticated": false
}
```
### Session Check
- GET /api/admin/session
- Auth: cookie required
Success response:
```json
{
"authenticated": true,
"username": "admin"
}
```
Failure:
- 401 unauthorized
## Admin Data API
All endpoints below require admin authentication.
### Overview
- GET /api/admin/overview
Returns aggregate counters and runtime/system snapshot fields.
### Playlist
- GET /api/admin/playlist
- PUT /api/admin/playlist
PUT request:
```json
{
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
}
```
### Schedule
- GET /api/admin/schedule
Returns parsed playlist entries plus matched media metadata/runtime.
### Moderation
- GET /api/admin/moderation/logs?limit=50
- POST /api/admin/moderation/actions
Action request:
```json
{
"action": "mute",
"target": "username-or-id",
"moderator": "admin",
"reason": "spam",
"room": "main"
}
```
### Analytics
- GET /api/admin/analytics
Returns totals (messages, moderation actions, rooms) and top room summary.
### System
- GET /api/admin/system
Returns service health snapshot for chat/playout/redis/database and config metadata.
### Active Users
- GET /api/admin/users
Returns currently discovered presence users (room + username) when Redis is available.
## Media Library API
### Media Collection
- GET /api/admin/media
- POST /api/admin/media (multipart upload, legacy path)
Multipart form field name: file
### Media Item
- GET /api/admin/media/{id}
- PUT /api/admin/media/{id}
- DELETE /api/admin/media/{id}
PUT request:
```json
{
"status": "ready",
"title": "My Clip",
"tags": "music,live"
}
```
## Resumable Upload API
Maximum file size: 2GB
Default chunk size: 8MB
### Initialize or Resume Session
- POST /api/admin/media/uploads
Request:
```json
{
"uploadId": "optional-existing-id",
"fileName": "clip.mp4",
"fileSize": 123456789,
"mimeType": "video/mp4",
"chunkSize": 8388608,
"totalChunks": 15
}
```
Response:
```json
{
"uploadId": "hex-token",
"fileName": "clip.mp4",
"fileSize": 123456789,
"chunkSize": 8388608,
"totalChunks": 15,
"uploadedChunks": 3,
"progress": 20
}
```
### Check Upload Status
- GET /api/admin/media/uploads/{uploadId}
Returns same status envelope as initialize.
### Upload Chunk
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
- Content-Type: application/octet-stream
Response returns updated upload status envelope.
### Complete Upload
- POST /api/admin/media/uploads/{uploadId}/complete
On success returns admin media listing payload (files + generatedAt).
## Metrics and Health
### Chat metrics
- GET /metrics
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
### Health
- GET /health on chat service
- GET /health on playout service
- GET /health on nginx reverse proxy
## HTTP Status Conventions
| Code | Meaning | When |
| ---- | --------------------- | ----------------------------------------------------------- |
| 200 | OK | Successful GET, PUT, or action |
| 201 | Created | Successful POST (upload init, override created) |
| 204 | No Content | Successful DELETE, CORS preflight |
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
| 401 | Unauthorized | Missing or expired admin session |
| 403 | Forbidden | Valid session but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 405 | Method Not Allowed | Wrong HTTP method for endpoint |
| 409 | Conflict | Schedule override overlap, upload ID mismatch |
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
| 429 | Too Many Requests | Rate limit exceeded (chat messages, WS connections, login) |
| 500 | Internal Server Error | Unexpected server failure |
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
## Rate Limiting
All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
| Resource | Limit | Window | Response |
| --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
| Chat messages | `CHAT_RATE_LIMIT_MAX` (default 10) | `CHAT_RATE_LIMIT_WINDOW_S` (default 10s) | 429 + `{"type":"system","text":"Rate limit exceeded"}` |
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
Rate limit headers on applicable responses:
- `X-RateLimit-Limit`: maximum requests per window
- `Retry-After`: seconds until window resets
- 413: payload too large
- 500: internal server error
+106 -18
View File
@@ -2,33 +2,121 @@
<!-- markdownlint-disable MD024 --> <!-- markdownlint-disable MD024 -->
## Unreleased ## [1.0.9] - 2026-08-06
### Added ### Added
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006). - **Prometheus alerts mount**: `alerts.yml` mounted into prometheus container in both dev and prod compose files.
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section). - **Grafana provisioning**: Prometheus datasource + dashboard provider YAML configs in `monitoring/grafana/`.
- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
- Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts.
- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions.
- Added a dedicated plain TypeScript admin module for tab navigation, media CRUD actions, schedule/system analytics refresh, and moderation workflows without Alpine dependencies.
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
### Changed ### Changed
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback. - **Prod prometheus**: Switched from empty named volume to bind mounts for config files (consistent with dev).
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload. - **CD pipeline**: Trigger on `push` (parallel with CI), removed `environment: production` (was causing silent abort).
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior. - **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior. - **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
- Code-split `hls.js` via lazy loading in the viewer bootstrap to shrink the main frontend bundle and isolate stream playback logic into a dedicated chunk. - **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
### Fixed ### Fixed
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup. - **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage.
- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting. ### Removed
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
- `.github/MEMORY.md`, `.github/agents/`, `.github/instructions/` — tracking files migrated out of repo.
## [1.0.8] - 2026-08-06
### Changed
- **API.md**: Added full HTTP status code matrix (table with 12 codes) and rate limit headers documentation.
- **DEPLOYMENT.md**: Added troubleshooting table (8 common symptoms with check/fix).
- **MONITORING.md**: Full rewrite with architecture diagram, 7 scrape targets, 8 alert rules, key metrics, troubleshooting.
- **SECURITY.md**: Full rewrite with threat model diagram, TLS/HSTS config, network isolation, secrets management, incident response.
- **Architecture docs**: Filled Level 2 building blocks (Mermaid diagram, 5 subsystems with code locations) and Level 3 module breakdowns (chat: 9 modules, playout: 6 modules, frontend: 10 modules).
## [1.0.7] - 2026-08-06
### Added
- **Emergency slate fallback**: `scripts/generate-slate.sh` creates "We'll Be Right Back" slate video + pre-rendered HLS. Playout detects FFmpeg failures (>5 restarts/60s) and auto-switches to slate manifest. Health endpoint reports `degraded` mode. Auto-recovers when FFmpeg restarts successfully.
- **Prometheus metrics**: `linearvoid_playout_restarts_total`, `linearvoid_playout_mode` (0=live, 1=slate), `linearvoid_chat_dms_total` exposed on playout + chat `/metrics`.
- **Video normalization pipeline**: `scripts/normalize.sh` with env-configurable CRF/preset/audio-bitrate wrapping reference FFmpeg command.
- **Strict ffprobe verification gate**: `ValidateVideo()` in playout parses ffprobe JSON, checks codec (h264/aac), framerate (30/1), resolution (1280x720 or 1920x1080), pix_fmt (yuv420p), sample_rate (48000), keyframe interval (≤2s). `ValidateVideoStrict()` returns `{valid, errors[]}`.
- **Upload normalization enforcement**: `validateMediaNormalization()` runs on every admin upload (single + chunked). Non-conforming files get `needs_normalization` status. Visible in media library + playlist validation warnings.
- **Chat history on room join**: `pushChatHistory()` sends last 50 messages via WebSocket on connect.
- **DM offline delivery**: Private messages persisted to DB, appear in history when recipient reconnects.
- **Online presence indicators**: Green dot per user in chat sidebar, updated via join/leave system messages.
- **Schedule metadata overlay**: "Now Playing" + "Up Next" below viewer player, polling `GET /api/schedule` every 30s. Public `/api/schedule` endpoint added.
- **Playlist drag-to-reorder**: Sortable list with drag-and-drop, per-entry remove buttons, validation warnings (missing files, needs_normalization).
- **Schedule overrides**: `ScheduleOverride` model + `GET/POST/DELETE /api/admin/schedule/overrides` with conflict detection (409 on overlap). Admin UI with form + list.
- **User management actions**: `POST /api/admin/users` with mute/unmute/ban/unban actions. Per-user action buttons in admin users table.
- **Inline moderation**: Mute/Ban buttons on each chat log entry row in admin.
- **CSV export**: `GET /api/admin/chat-log/export` returns CSV download.
- **Analytics time range selector**: 1h, 6h, 24h, 7d dropdown on admin analytics page.
- **Runtime config editor**: `GET/PUT /api/admin/config` for rate limit max + window. Admin system page with config form + restart buttons.
- **Production target documentation**: `DEPLOYMENT.md` updated with two-environment model (test lab 192.168.88.0/24 vs Hetzner production TBD), CX22 specs, firewall requirements, DNS cutover plan.
- **Network diagram**: Mermaid diagram in `SETUP.md` distinguishing test lab from planned production.
- **E2E test script**: `scripts/test-playout-e2e.sh` for runtime validation in test lab.
- **Redis pub/sub stub**: Architecture ready for multi-instance chat (pending implementation).
### Changed
- `docker-compose.yml` + `.prod.yml`: added slate volume mount (`./data/slate:/app/slate:ro`), Redis `--maxmemory 256mb --maxmemory-policy allkeys-lru`.
- `backend/playout/Dockerfile`: added `/app/slate` directory.
- `handleConnection`: added 30s presence heartbeat + WebSocket ping, banned/muted user checks, env-configurable rate limits (`CHAT_RATE_LIMIT_MAX`, `CHAT_RATE_LIMIT_WINDOW_S`).
- `handleModerationCommand`: added moderator role check, mute duration parsing, Redis key enforcement.
- `handlePrivateDM`: targeted delivery via `clientMeta` map instead of broadcast.
- `initRedis`: added `redisReconnectLoop` with exponential backoff (2s→60s).
- `handleAdminMedia` + chunked upload: normalization validation runs on every upload, sets `needs_normalization` status.
- nginx: OPTIONS preflight → 204 on all 4 HLS locations. `/health` returns JSON. `/nginx-status` with stub_status (internal-network restricted).
- CD workflow: health checks now IP-agnostic (`docker exec` instead of hardcoded IP curls).
- `docker-compose.yml` + `.prod.yml`, `.gitea/workflows/cd.yml`, `DEPLOYMENT.md`, `SETUP.md`, `docs/architecture/README.md`, `README.md`: all annotated with test-lab vs production distinction, `192.168.88.0/24` flagged as internal test network.
- `docs/architecture/README.md`: CI/CD deployment view updated to note test lab target, production pending.
### Fixed
- CD health check no longer hardcodes `192.168.88.40` in curl targets — uses `docker exec` for portability.
- Playlist page no longer single-textarea — now shows sortable list with validation warnings.
- Schedule page shows overrides alongside playlist timeline.
## [1.0.6] - 2026-08-05
### Added
- `GET /api/admin/chat-log` endpoint with `?room=`, `?limit=`, `?before=`, `?username=`, `?text=` filter/pagination support.
- `GET /api/admin/chat-log/:id` endpoint returning a single chat log entry as JSON.
- `DELETE /api/admin/chat-log/:id` endpoint returning 204 No Content on success.
- `DELETE /api/admin/chat-log/bulk` endpoint accepting `{"ids": [...]}` for bulk deletion (max 500).
- Chat Log admin tab with table, individual delete with confirmation dialog, pagination (prev/next), search/filter by room/username/text, select-all checkbox, and bulk delete button.
- Added `"lint": "tsc --noEmit"` script to `frontend/package.json`.
- `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
- Resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
- Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
- Dedicated operational documentation: `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, `SECURITY.md`.
### Changed
- Split `frontend/admin/index.html` into a template shell plus 9 per-tab page HTML files loaded dynamically via `loadPageContent` / `switchTab` / `bindPageEvents`.
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
- Applied new color palette (`#32292f` dark, `#b4c4ae` light, `#587792` brand, `#2ebfa5` focus, `#04650d` ok) across all 22 CSS custom properties in dark and light themes.
- Replaced hardcoded admin sidebar and tab-trigger colors with CSS custom properties.
- Optimized CI pipeline: removed duplicate `docker compose build --no-cache` from test job, removed broken playlist validation step, fixed Go module cache path, added Docker Buildx registry cache to docker/release jobs.
- Bumped chat healthcheck `start_period` from 10s to 30s in `docker-compose.prod.yml`.
- CD workflow now verifies nginx (port 8080) + chat (3000) + playout (8081); dumps container logs for all failing services.
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
### Fixed
- Go module cache in CI now correctly references only `backend/chat/go.sum` (playout go.sum did not exist); added `go mod download` step to seed cache.
- Removed broken playlist validation step (`go run src/playlist.go` without go.mod) from CI test job.
- Converted nginx CI build from custom shell `docker buildx` to `docker/build-push-action@v5` for cache consistency.
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads. - Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks without 413 errors.
- Removed unused `data-admin-*` Vite env var attributes from admin HTML template.
- Removed Tailwind and Alpine runtime/build dependencies; eliminated Tailwind directives/warnings during production build.
## [1.0.3] - 2026-08-03 ## [1.0.3] - 2026-08-03
@@ -74,7 +162,7 @@ Full backend and frontend implementation complete.
- **Frontend** (`frontend/`): Vite + TypeScript + Tailwind + Alpine.js, HLS player with hls.js, chat sidebar with room switching and private messaging, "Now Playing" overlay, and modern UI (ADR-002, ADR-003). - **Frontend** (`frontend/`): Vite + TypeScript + Tailwind + Alpine.js, HLS player with hls.js, chat sidebar with room switching and private messaging, "Now Playing" overlay, and modern UI (ADR-002, ADR-003).
- Complete `docker-compose.yml` for local development with all services, volumes, and healthchecks. - Complete `docker-compose.yml` for local development with all services, volumes, and healthchecks.
- All ADRs (001-007) created and linked in documentation. - All ADRs (001-007) created and linked in documentation.
- `TODO.md`, `DONE.md`, and `CHANGELOG.md` updated with full traceability. - `CHANGELOG.md` updated with full traceability.
- Git repository initialized with comprehensive initial commit. - Git repository initialized with comprehensive initial commit.
The project is a fully functional, self-hosted 24/7 linear video channel with synchronized playback, custom chat, R2 delivery, moderation, and admin-ready structure. It matches the architecture defined in the arc42 template and all ADRs. The project is a fully functional, self-hosted 24/7 linear video channel with synchronized playback, custom chat, R2 delivery, moderation, and admin-ready structure. It matches the architecture defined in the arc42 template and all ADRs.
+120 -5
View File
@@ -7,9 +7,8 @@ Thank you for considering contributing to Linear Void!
1. **Read the Architecture First**: All design decisions are documented in `docs/architecture/README.md` (arc42 template) and the ADRs in `docs/architecture/adr/` (ADR-001 to ADR-007). 1. **Read the Architecture First**: All design decisions are documented in `docs/architecture/README.md` (arc42 template) and the ADRs in `docs/architecture/adr/` (ADR-001 to ADR-007).
2. **Follow the Structure**: Use the folder layout defined in ADR-002 (`frontend/`, `backend/playout/`, `backend/chat/`, `infrastructure/`, etc.). 2. **Follow the Structure**: Use the folder layout defined in ADR-002 (`frontend/`, `backend/playout/`, `backend/chat/`, `infrastructure/`, etc.).
3. **Update Documentation Before Code**: Create or update an ADR before implementing significant changes. 3. **Update Documentation Before Code**: Create or update an ADR before implementing significant changes.
4. **Update TODO.md**: Plan tasks in `.github/instructions/TODO.md`, mark as completed, and move finished sections to `DONE.md`. 4. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001").
5. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001"). 5. **Test with Docker**: Run `docker compose up --build` to test changes.
6. **Test with Docker**: Run `docker compose up --build` to test changes.
## Repository ## Repository
@@ -20,8 +19,8 @@ Thank you for considering contributing to Linear Void!
## Code Style ## Code Style
- Go: Use standard formatting (`go fmt`). - Go: Use standard formatting (`go fmt`).
- TypeScript: ESLint and Prettier. - TypeScript: `tsc --noEmit` for type-checking (see `frontend/package.json`).
- Tailwind: Follow the utility-first approach. - CSS: Design-token system via CSS custom properties in `frontend/src/style.css`. No utility framework.
## Pull Requests ## Pull Requests
@@ -30,4 +29,120 @@ Thank you for considering contributing to Linear Void!
- Ensure all tests pass and documentation is updated. - Ensure all tests pass and documentation is updated.
- The CI workflow will validate normalization, linting, and Docker builds. - The CI workflow will validate normalization, linting, and Docker builds.
## Versioning and Releases
### Version Scheme
Linear Void follows **Semantic Versioning** ([SemVer](https://semver.org)): `MAJOR.MINOR.PATCH`.
- **MAJOR** — breaking changes to the viewer, API, or deployment model.
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
- **PATCH** — bug fixes, optimizations, documentation polish.
Git tags use no prefix: `1.0.2`, `1.1.0`, `2.0.0`.
### Where Versions Are Declared
| File | Token | Example |
| ----------------------- | ------------- | ------------------------- |
| `frontend/package.json` | `"version"` | `"1.0.2"` |
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
| `README.md` | Version badge | `version-1.0.2-blue` |
### Tag Triggers
| Tag Pattern | Workflow | What Happens |
| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `*.*.*` (e.g. `1.0.2`) | `release.yml` | Builds all 3 images, pushes to registry with semver tags (`1.0.2`, `1.0`, `latest`), creates Gitea Release via API |
| Push to `main` | `ci.yml` → `cd.yml` | CI builds + pushes images with `main`/`latest` tags, CD deploys to production |
### Full Release Procedure
#### Step 1: Bump version numbers
Update these files to the new version (replace `X.Y.Z`):
- [frontend/package.json](frontend/package.json)
- [CHANGELOG.md](CHANGELOG.md) — add new section at top
- [README.md](README.md) — version badge
```bash
# frontend/package.json
"version": "X.Y.Z"
# CHANGELOG.md — add new section at top
## [X.Y.Z] - YYYY-MM-DD
# README.md — version badge
version-X.Y.Z-blue
```
#### Step 2: Write changelog
Add entries under `## [X.Y.Z]` in `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com) format (`Added`, `Changed`, `Fixed`, `Removed`).
#### Step 3: Commit and tag
```bash
# Stage all version bumps + changelog
git add frontend/package.json CHANGELOG.md README.md
# Commit
git commit -m "chore: bump version to X.Y.Z"
# Create annotated tag
git tag -a X.Y.Z -m "Release X.Y.Z"
# Push commit and tag
git push origin main
git push origin X.Y.Z
```
#### Step 4: Verify CI/CD
- CI workflow (`ci.yml`) runs on push to `main` → builds & pushes `main`/`latest` images.
- Release workflow (`release.yml`) triggers on `X.Y.Z` tag → builds images with semver tags (`X.Y.Z`, `X.Y`, `latest`), creates Gitea Release.
- CD workflow (`cd.yml`) triggers after CI completes → deploys to production.
Monitor at: [git.allucanget.biz/zwitschi/linearvoid/actions](https://git.allucanget.biz/zwitschi/linearvoid/actions)
#### Step 5: Verify release
Check the Gitea Releases page for the new tag:
[git.allucanget.biz/zwitschi/linearvoid/releases](https://git.allucanget.biz/zwitschi/linearvoid/releases)
### Manual Release (workflow_dispatch)
If a tag push fails or a re-release is needed:
1. Go to [Actions → Release workflow](https://git.allucanget.biz/zwitschi/linearvoid/actions?workflow=release.yml)
2. Click "Run workflow"
3. Enter version (e.g. `1.0.2`)
4. The workflow builds images, pushes semver tags, and creates the Gitea Release
### Docker Image Tags
Every release produces these tags in the Gitea Container Registry:
| Image | Tags |
| --------- | ------------------------ |
| `playout` | `X.Y.Z`, `X.Y`, `latest` |
| `chat` | `X.Y.Z`, `X.Y`, `latest` |
| `nginx` | `X.Y.Z`, `X.Y`, `latest` |
Example for `1.0.2`:
```text
git.allucanget.biz/zwitschi/linearvoid/chat:1.0.2
git.allucanget.biz/zwitschi/linearvoid/chat:1.0
git.allucanget.biz/zwitschi/linearvoid/chat:latest
git.allucanget.biz/zwitschi/linearvoid/playout:1.0.2
git.allucanget.biz/zwitschi/linearvoid/playout:1.0
git.allucanget.biz/zwitschi/linearvoid/playout:latest
```
### Moving Completed Tasks
After release, update `CHANGELOG.md` if needed.
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing! See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
+284
View File
@@ -0,0 +1,284 @@
# Deployment Guide
## Table of Contents
- [Deployment Guide](#deployment-guide)
- [Table of Contents](#table-of-contents)
- [Environments](#environments)
- [Production Target (Hetzner)](#production-target-hetzner)
- [Planned Infrastructure](#planned-infrastructure)
- [Cutover Plan](#cutover-plan)
- [Firewall / Security Group](#firewall--security-group)
- [Pipeline Readiness](#pipeline-readiness)
- [Target Topology](#target-topology)
- [Prerequisites](#prerequisites)
- [Environment File](#environment-file)
- [Pull-and-Run Deployment](#pull-and-run-deployment)
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
- [Health Verification](#health-verification)
- [Zero/Low-Downtime Update Pattern](#zerolow-downtime-update-pattern)
- [Rollback](#rollback)
- [Operational Tasks](#operational-tasks)
- [View logs](#view-logs)
- [Restart one service](#restart-one-service)
- [Stop stack](#stop-stack)
- [Keep volumes (recommended) vs remove volumes (destructive)](#keep-volumes-recommended-vs-remove-volumes-destructive)
- [Nginx and Proxy Considerations](#nginx-and-proxy-considerations)
- [Security Baseline](#security-baseline)
- [Common Deployment Pitfalls](#common-deployment-pitfalls)
- [Troubleshooting](#troubleshooting)
## Environments
Linear Void runs in two separate environments:
| Environment | Host | Docker Target | Purpose |
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-target-hetzner) below.
## Production Target (Hetzner)
**Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created.
### Planned Infrastructure
| Resource | Spec |
| -------------- | ------------------------------------------------ |
| Provider | Hetzner Cloud |
| Region | Ashburn, VA (us-east) |
| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
| OS | Ubuntu 24.04 LTS or Debian 12 |
| Bandwidth | 20 TB included traffic/month |
| Estimated cost | ~€7–10/month base |
### Cutover Plan
1. Provision Hetzner VPS (see [Production Deployment](#production-target-hetzner) section)
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
4. Trigger deploy → validate health checks
5. Update Cloudflare A record `linearvoid.tv` → Hetzner IP
6. Enable HTTPS, WAF, cache rules on production zone
7. Decommission test lab as public target
### Firewall / Security Group
| Port | Source | Purpose |
| ---- | --------------------- | ------------------------------ |
| 22 | Admin IPs | SSH management |
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
### Pipeline Readiness
Three Gitea Actions workflows drive the full pipeline:
| Workflow | Trigger | Purpose |
| ------------- | ---------------- | ---------------------------------------------------------------------------------------- |
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
```bash
# Test lab (current)
DOCKER_HOST=tcp://192.168.88.40:2375
# Production (when provisioned)
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
```
No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deployment section for full checklist.
## Target Topology
- Host: Linux server with Docker Engine
- Reverse proxy: nginx container (linearvoid-nginx)
- Core services: playout, chat, redis, postgres
- Observability: prometheus, grafana
- CDN/edge: Cloudflare
## Prerequisites
- Docker Engine + Docker Compose plugin
- Network access to git.allucanget.biz
- A valid .env file with production values
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
## Environment File
Use .env with at least:
- CI_REGISTRY_URL
- CI_REGISTRY_NAMESPACE
- CHAT_PORT
- NGINX_PORT
- DATABASE_URL (if overridden)
- REDIS_URL (if overridden)
- ADMIN_USERNAME
- ADMIN_PASSWORD
- VITE_PUBLIC_APP_URL
- VITE_PUBLIC_WS_URL
- VITE_PUBLIC_HLS_URL
- Cloudflare/R2 variables when media sync is enabled
See .env.example and SETUP.md for full variable reference.
## 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).
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.
From repository root:
```bash
docker login git.allucanget.biz
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
```
## First-Time or Recovery Provisioning
1. Ensure host paths exist:
```bash
mkdir -p data/chat data/playlist data/media data/hls
```
1. Confirm volumes and networks:
```bash
docker volume ls
docker network ls
```
1. Start stack:
```bash
docker compose -f docker-compose.prod.yml up -d
```
## Health Verification
After startup, check service status:
```bash
docker compose -f docker-compose.prod.yml ps
```
Endpoint checks:
- App: <http://{host}:8080/>
- Nginx health: <http://{host}:8080/health>
- Chat health: <http://{host}:3000/health>
- Playout health: <http://{host}:8081/health>
- Prometheus: <http://{host}:9090>
- Grafana: <http://{host}:3001>
Domain checks:
- <https://linearvoid.tv>
- <https://linearvoid.tv/admin>
- <https://linearvoid.tv/metrics>
## Zero/Low-Downtime Update Pattern
1. Pull newest images.
1. Recreate changed services only:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
```
1. Re-run health checks.
## Rollback
If new images are unhealthy:
1. Pin previous tags in docker-compose.prod.yml.
2. Pull pinned tags.
3. Restart impacted services.
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout
```
## Operational Tasks
### View logs
```bash
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
```
### Restart one service
```bash
docker compose -f docker-compose.prod.yml restart chat
```
### Stop stack
```bash
docker compose -f docker-compose.prod.yml down
```
### Keep volumes (recommended) vs remove volumes (destructive)
```bash
# keep data
docker compose -f docker-compose.prod.yml down
# destructive (remove data)
docker compose -f docker-compose.prod.yml down -v
```
## Nginx and Proxy Considerations
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
## Security Baseline
- Use strong ADMIN_PASSWORD and rotate regularly.
- Keep Docker host access restricted.
- Prefer HTTPS/WSS through Cloudflare.
- Restrict exposed ports if only proxy access is needed.
## Common Deployment Pitfalls
- Missing frontend build artifacts in local-only compose workflows.
- Wrong registry credentials or missing login.
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
## Troubleshooting
| Symptom | Check | Fix |
| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
- Proxy-layer body size limits returning 413 on upload.
- Health checks failing before service warmup is complete.
+91
View File
@@ -0,0 +1,91 @@
# Monitoring Guide
Prometheus + Grafana monitoring for Linear Void.
## Architecture
```text
┌──────────────┐ ┌──────────────┐ ┌───────────┐
│ Exporters │───> │ Prometheus │───>│ Grafana │
│ (7 targets) │ │ :9090 │ │ :3001 │
└──────────────┘ └───────┬──────┘ └───────────┘
│
alerts.yml (8 rules)
```
## Scrape Targets
| Job | Target | Port | Metrics |
| ---------- | ----------------- | ---- | ------------------------------------------------------------- |
| chat | chat | 3000 | WebSocket clients, messages, DMs, moderation, Redis, sessions |
| playout | playout | 8081 | Restarts, mode (live/slate) |
| nginx | nginx-exporter | 9113 | Requests, connections |
| redis | redis-exporter | 9121 | Memory, clients, commands |
| postgres | postgres-exporter | 9187 | Connections, queries, locks |
| node | node-exporter | 9100 | CPU, memory, disk, network |
| prometheus | prometheus | 9090 | Self-monitoring |
Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
## Alerting Rules
8 rules in `monitoring/prometheus/alerts.yml`:
| Alert | Severity | Expression |
| -------------------- | -------- | -------------------------------------- |
| ChatDown | critical | `up{job="chat"} == 0` for 2m |
| PlayoutDown | critical | `up{job="playout"} == 0` for 2m |
| RedisDown | warning | `linearvoid_chat_redis_up == 0` for 2m |
| PlayoutSlateActive | warning | `linearvoid_playout_mode == 1` for 1m |
| HighChatLatency | warning | message rate <0.01/s for 5m |
| PrometheusWALFailing | warning | WAL write failures >0 for 5m |
| HighCPU | warning | CPU >80% for 10m |
| LowDiskSpace | warning | Disk <10% free for 5m |
## Access
- Prometheus: `http://<host>:9090` (targets, graph, alerts)
- Grafana: `http://<host>:3001` (default: admin/admin, change via `GRAFANA_PASSWORD`)
## Dashboards
- **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
## Key Metrics
- `linearvoid_chat_connected_clients` — active WebSocket connections
- `linearvoid_chat_messages_total` — total stored messages
- `linearvoid_chat_dms_total` — private messages sent
- `linearvoid_chat_redis_up` — Redis reachable (0/1)
- `linearvoid_playout_restarts_total` — FFmpeg restart counter
- `linearvoid_playout_mode` — 0=live, 1=slate, 2=degraded
- `linearvoid_chat_admin_sessions_active` — active admin logins
## Troubleshooting
| Symptom | Check |
| ----------------------- | -------------------------------------------------------------------- |
| No chat metrics | `curl http://chat:3000/metrics` from Prometheus container |
| No exporter metrics | Verify exporter containers running: `docker ps \| grep exporter` |
| Grafana no data | Datasource URL must be `http://prometheus:9090` |
| Prometheus targets down | Check container network: all must be on `linearvoid-net` |
| Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml |
### Grafana shows no data
- Ensure datasource points to <http://prometheus:9090>.
- Confirm Prometheus is healthy.
- Check dashboard query labels match active jobs.
### High cardinality or query slowness
- Reduce unneeded labels.
- Limit expensive dashboard panels.
- Tune retention and query windows.
## Operational Recommendations
- Keep alert thresholds tied to actual user impact.
- Review dashboards after each release that changes endpoints or metrics names.
- Periodically test alert routing and incident response flow.
+11 -4
View File
@@ -7,14 +7,14 @@
[![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions) [![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz) [![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz)
[![Version](https://img.shields.io/badge/version-1.0.3-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases) [![Version](https://img.shields.io/badge/version-1.0.9-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases)
[![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md) [![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md)
## Architecture ## Architecture
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses: The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat. - Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress). - Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
- Lightweight static frontend and WebSocket chat backend. - Lightweight static frontend and WebSocket chat backend.
- Gitea self-hosted CI/CD (Actions) and Container Registry. - Gitea self-hosted CI/CD (Actions) and Container Registry.
@@ -112,6 +112,8 @@ This structure directly reflects the building blocks, runtime flows, deployment
## Getting Started ## Getting Started
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`.
```bash ```bash
git clone https://git.allucanget.biz/zwitschi/linearvoid.git git clone https://git.allucanget.biz/zwitschi/linearvoid.git
cd linearvoid cd linearvoid
@@ -133,6 +135,7 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
## Development ## Development
- Use Docker Compose for local development (playout + chat + frontend). - Use Docker Compose for local development (playout + chat + frontend).
- Test lab host: `192.168.88.40` (internal network, receives CD deploys). Production Hetzner VPS TBD.
- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery. - Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery.
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements). - Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
- Monitor cache hit ratio and R2 operations closely — they determine real cost. - Monitor cache hit ratio and R2 operations closely — they determine real cost.
@@ -204,6 +207,10 @@ 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](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
- [API.md](API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
- [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).
@@ -216,11 +223,11 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview: Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first. 1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
2. Plan tasks in `.github/instructions/TODO.md`. 2. Code against the defined module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring).
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid). 3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
4. CI validates builds and tests automatically. 4. CI validates builds and tests automatically.
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (v1.0.3, v1.0.4, etc.) and automatically built/pushed to the container registry. Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry. Version numbers are declared in `frontend/package.json`, `CHANGELOG.md`, and the version badge in this README. For patches, we use even numbers for stable versions (1.0.2, 1.0.4) and odd numbers for pre-release/unstable versions (1.0.3, 1.0.5).
[MIT License](LICENSE) [MIT License](LICENSE)
+93
View File
@@ -0,0 +1,93 @@
# Security Guide
Layered security model for Linear Void.
## Threat Model
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │───▶│Cloudflare│───▶│ nginx │───▶│ Backend │
│ (public) │ │ WAF+CDN │ │ proxy │ │ chat │
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
│ │
┌──────┴──────┐ ┌─────┴──────┐
│ /health │ │ /api/admin │
│ /metrics │ │ /ws (chat) │
│ /hls/* │ │ /api/chat/* │
│ (public) │ │ (auth req) │
└─────────────┘ └────────────┘
```
## Authentication & Sessions
- Login: `POST /api/admin/login` → cookie `linearvoid_admin_session`
- Cookie flags: `HttpOnly`, `SameSite=Strict`, `Secure` (over HTTPS)
- Server-side expiry via `ADMIN_SESSION_TTL_HOURS`
- Rate limit: 5 attempts/IP/5min via Redis
## Transport Security (TLS/HSTS)
- Cloudflare TLS mode: **Full (strict)** — valid origin cert required
- nginx listens on 80 internally; Cloudflare handles TLS termination
- HSTS header set by Cloudflare: `max-age=31536000; includeSubDomains; preload`
- WSS upgrade path: `/ws` through nginx → chat:3000
## Network Isolation
- All backend services on internal Docker network (`linearvoid-net`)
- Only nginx port exposed to host (443/80)
- Database, Redis, exporters inaccessible from outside
- `docker-compose.prod.yml` keeps ports internal via `expose:` not `ports:`
## Reverse Proxy Controls
- `auth_request /api/admin/auth` gate on all `/admin` and `/api/admin` paths
- Explicit `client_max_body_size 64m` for resumable upload chunks
- WebSocket upgrade headers: `Upgrade`, `Connection` preserved
- CORS headers on HLS: `Access-Control-Allow-Origin *`
- XSS protection: CSP headers served by Cloudflare
## Rate Limiting
| Resource | Limit | Window |
| ----------------- | ----- | ------ |
| WebSocket connect | 5/IP | 60s |
| Chat messages | 10/IP | 10s |
| Admin login | 5/IP | 5min |
Implemented via Redis `INCR` + `EXPIRE`.
## Secrets Management
- `.env` — local dev only, **gitignored**
- Gitea Actions secrets (`CI_*` prefix) for CI/CD
- Never commit: passwords, tokens, API keys, R2 credentials
- Production `.env` provisioned manually on host, never checked in
- Rotate admin password quarterly
## Data Protection
- PostgreSQL: chat history, moderation logs, media metadata
- Redis: append-only persistence for presence, rate limits, sessions
- HLS segments: ephemeral, regenerated by playout
- Backups: `scripts/backup-db.sh` (daily), `scripts/backup-weekly.sh`, `scripts/verify-backups.sh`
## Hardening Checklist
- [ ] HTTPS/WSS enforced end-to-end (Cloudflare Full strict)
- [ ] Strong admin credentials configured
- [ ] Secrets only in env/secrets stores (never committed)
- [ ] Proxy auth and websocket headers validated
- [ ] Upload limits and MIME validation active
- [ ] Rate limiting active (WS, messages, login)
- [ ] Cloudflare WAF/rate-limit rules active
- [ ] Backup/restore drills completed
- [ ] Security alerts routed (Prometheus alertmanager)
## Incident Response
1. Detect — alerts (ChatDown, RedisDown, HighChatLatency) or user reports
2. Triage — edge (Cloudflare), proxy (nginx), app (chat/playout), data (Redis/Postgres)
3. Contain — rate limit, credential rotate, service restart, slate mode activation
4. Recover — verify health endpoints, check Prometheus targets, validate HLS output
5. Record — timeline + corrective actions in `docs/incident-runbook.md`
+36
View File
@@ -21,6 +21,42 @@ This guide covers prerequisites, environment setup, running the application loca
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`) - `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001). - **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
## Network Environments
Linear Void operates in two distinct environments. Know which one you're targeting.
```mermaid
flowchart LR
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
end
subgraph Prod["Production (Hetzner — NOT provisioned)"]
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
end
subgraph Gitea["Gitea CI/CD"]
CI["CI: build + push images"]
CD["CD: deploy via DOCKER_HOST"]
end
subgraph CF["Cloudflare"]
DNS["linearvoid.tv"]
R2["R2 Bucket"]
end
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
CD -.->|"DOCKER_HOST=tcp://&lt;hetzner-ip&gt;:2375<br/>(when provisioned)"| PROD_HOST
TEST_HOST -->|"test traffic"| DNS
PROD_HOST -.->|"production traffic"| DNS
TEST_HOST --> R2
PROD_HOST -.-> R2
```
**Test Lab (active):** Internal network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation.
**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See Production Deployment section in [DEPLOYMENT.md](./DEPLOYMENT.md) for cutover plan.
## Development Prerequisites ## Development Prerequisites
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`. - **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.23-alpine AS builder FROM golang:1.25-alpine AS builder
RUN apk add --no-cache gcc musl-dev RUN apk add --no-cache gcc musl-dev
+1 -1
View File
@@ -1,6 +1,6 @@
module chat module chat
go 1.23.0 go 1.25.0
require ( require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
+820 -37
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -18,8 +18,8 @@ RUN apk add --no-cache \
bash bash
# Set working directory for playout # Set working directory for playout
WORKDIR /app WORKDIR /app
# Create directories for playlist, media, and HLS output # Create directories for playlist, media, HLS output, and emergency slate
RUN mkdir -p /app/hls /app/media /app/playlists RUN mkdir -p /app/hls /app/media /app/playlists /app/slate
# Copy the playout start script and the built playlist manager binary # Copy the playout start script and the built playlist manager binary
COPY start-playout.sh /app/ COPY start-playout.sh /app/
# Copy demo data for out-of-the-box playout # Copy demo data for out-of-the-box playout
+280 -13
View File
@@ -3,11 +3,13 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +29,16 @@ type VideoFile struct {
var ( var (
r2Client *s3.Client r2Client *s3.Client
bucket = os.Getenv("R2_BUCKET_NAME") bucket = os.Getenv("R2_BUCKET_NAME")
playoutMode = "starting" // "live", "slate", "degraded"
restartCount = 0
restartWindow = time.Now()
slateDir = "/app/slate"
hlsDir = "/app/hls"
)
const (
maxRestartsPerMinute = 5
slateManifestName = "playlist.m3u8"
) )
// initR2Client initializes the R2 (S3-compatible) client // initR2Client initializes the R2 (S3-compatible) client
@@ -79,23 +91,181 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001) // ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
func ValidateVideo(videoPath string) (VideoFile, error) { func ValidateVideo(videoPath string) (VideoFile, error) {
v := VideoFile{Path: videoPath} v := VideoFile{Path: videoPath}
var errors []string
// Run ffprobe
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath) cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
_, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
v.Validation = "ffprobe failed: " + err.Error() v.Validation = "ffprobe failed: " + err.Error()
return v, err return v, fmt.Errorf("ffprobe: %w", err)
} }
// TODO: Parse JSON for strict checks (codec_name=h264, r_frame_rate=30/1, key_frame interval) // Parse JSON
// For MVP we assume valid if ffprobe succeeds var probe struct {
v.Valid = true Streams []struct {
v.Validation = "Validation passed (H.264/AVC, AAC, 30fps constant, 2s keyframes - full JSON parse TODO per ADR-001)" CodecName string `json:"codec_name"`
v.Duration = 60.0 // placeholder from ffprobe CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
Duration string `json:"duration"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &probe); err != nil {
v.Validation = "ffprobe JSON parse failed: " + err.Error()
return v, fmt.Errorf("ffprobe json: %w", err)
}
if len(probe.Streams) == 0 {
errors = append(errors, "no streams detected")
}
var hasVideo, hasAudio bool
for _, s := range probe.Streams {
switch s.CodecType {
case "video":
hasVideo = true
if s.CodecName != "h264" {
errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
}
if s.RFrameRate != "30/1" {
errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
}
if s.Width != 1280 && s.Width != 1920 {
errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
}
if s.Height != 720 && s.Height != 1080 {
errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
}
if s.PixFmt != "yuv420p" {
errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
}
case "audio":
hasAudio = true
if s.CodecName != "aac" {
errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
}
if s.SampleRate != "48000" {
errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
}
}
}
if !hasVideo {
errors = append(errors, "no video stream found")
}
if !hasAudio {
errors = append(errors, "no audio stream found")
}
// Parse duration from format or stream
durationSec := 0.0
if probe.Format.Duration != "" {
if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
durationSec = d
}
}
if len(errors) > 0 {
v.Valid = false
v.Validation = strings.Join(errors, "; ")
return v, fmt.Errorf("validation failed: %s", v.Validation)
}
// Validate keyframe interval <=2s (60 frames at 30fps)
keyframeErr := validateKeyframeInterval(videoPath)
if keyframeErr != nil {
errors = append(errors, keyframeErr.Error())
}
if len(errors) > 0 {
v.Valid = false
v.Validation = strings.Join(errors, "; ")
return v, fmt.Errorf("validation failed: %s", v.Validation)
}
v.Valid = true
v.Duration = durationSec
v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
return v, nil return v, nil
} }
// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
func validateKeyframeInterval(videoPath string) error {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-select_streams", "v:0",
"-show_frames",
"-show_entries", "frame=key_frame,pkt_pts_time",
"-of", "csv=p=0",
videoPath)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("keyframe probe failed: %w", err)
}
type kfEntry struct{ pts float64 }
var keyframes []kfEntry
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 2 || parts[0] != "1" {
continue
}
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err != nil {
continue
}
keyframes = append(keyframes, kfEntry{pts})
}
if len(keyframes) < 2 {
return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
}
maxGap := 0.0
for i := 1; i < len(keyframes); i++ {
gap := keyframes[i].pts - keyframes[i-1].pts
if gap > maxGap {
maxGap = gap
}
}
if maxGap > 2.1 { // small tolerance for floating point
return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
}
return nil
}
// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
func ValidateVideoStrict(videoPath string) map[string]any {
v, err := ValidateVideo(videoPath)
result := map[string]any{
"path": videoPath,
"valid": v.Valid,
"errors": []string{},
}
if err != nil {
result["errors"] = strings.Split(v.Validation, "; ")
}
if v.Valid {
result["duration_seconds"] = v.Duration
}
return result
}
// ValidatePlaylist runs normalization validation on all entries // ValidatePlaylist runs normalization validation on all entries
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) { func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
videos, err := LoadPlaylist(playlistPath) videos, err := LoadPlaylist(playlistPath)
@@ -139,8 +309,65 @@ func SyncToR2(localPath, r2Key string) error {
// HealthHandler provides a simple health check endpoint // HealthHandler provides a simple health check endpoint
func HealthHandler(w http.ResponseWriter, r *http.Request) { func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
status := "healthy"
if playoutMode == "slate" {
status = "degraded"
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) fmt.Fprintf(w, `{"status":"%s","service":"playout","mode":"%s","restarts":%d,"timestamp":"%s"}`,
status, playoutMode, restartCount, time.Now().UTC().Format(time.RFC3339))
}
// activateSlate copies pre-rendered slate HLS into the live output directory.
// Writes a static manifest referencing slate segments. Returns error if slate not found.
func activateSlate() error {
slateHLS := filepath.Join(slateDir, "hls")
if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
}
// Clear existing live segments
if err := resetHLSOutput(hlsDir); err != nil {
return fmt.Errorf("failed to clear HLS output for slate: %w", err)
}
// Copy slate segments into live HLS dir
slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
if err != nil {
return err
}
for _, src := range slateFiles {
dst := filepath.Join(hlsDir, filepath.Base(src))
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
}
playoutMode = "slate"
fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
time.Now().Format(time.RFC3339), restartCount)
return nil
}
// deactivateSlate clears slate state and prepares for live playout resumption.
func deactivateSlate() {
playoutMode = "live"
fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
time.Now().Format(time.RFC3339))
}
// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
func shouldEscalateToSlate() bool {
if time.Since(restartWindow) > 60*time.Second {
restartCount = 0
restartWindow = time.Now()
return false
}
return restartCount >= maxRestartsPerMinute
} }
func resetHLSOutput(hlsDir string) error { func resetHLSOutput(hlsDir string) error {
@@ -207,6 +434,21 @@ func main() {
// Start HTTP health check server // Start HTTP health check server
http.HandleFunc("/health", HealthHandler) http.HandleFunc("/health", HealthHandler)
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintln(w, "# HELP linearvoid_playout_restarts_total Total FFmpeg restarts.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_restarts_total counter")
fmt.Fprintf(w, "linearvoid_playout_restarts_total %d\n", restartCount)
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
modeVal := 0
if playoutMode == "slate" {
modeVal = 1
} else if playoutMode == "degraded" {
modeVal = 2
}
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
})
go func() { go func() {
fmt.Println("Health check server listening on :8081/health") fmt.Println("Health check server listening on :8081/health")
if err := http.ListenAndServe(":8081", nil); err != nil { if err := http.ListenAndServe(":8081", nil); err != nil {
@@ -214,9 +456,14 @@ func main() {
} }
}() }()
// Run FFmpeg in a loop for continuous playback with R2 sync hook // Run FFmpeg in a loop for continuous playback with slate fallback
for { for {
if err := resetHLSOutput("/app/hls"); err != nil { if playoutMode == "slate" {
// During slate: attempt recovery by restarting FFmpeg
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
}
if err := resetHLSOutput(hlsDir); err != nil {
fmt.Println("Failed to reset HLS output:", err) fmt.Println("Failed to reset HLS output:", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
@@ -241,12 +488,32 @@ func main() {
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s", "-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
"/app/hls/playlist.m3u8") "/app/hls/playlist.m3u8")
if err := cmd.Run(); err != nil { runErr := cmd.Run()
fmt.Println("FFmpeg exited with error. Restarting in 5s (per ADR-001 resilience)...")
// Track restart and check for slate escalation
restartCount++
fmt.Printf("[%s] FFmpeg exited (restart #%d in window): %v\n",
time.Now().Format(time.RFC3339), restartCount, runErr)
if shouldEscalateToSlate() {
fmt.Printf("[%s] CRITICAL: %d restarts in 60s — escalating to slate fallback\n",
time.Now().Format(time.RFC3339), restartCount)
if err := activateSlate(); err != nil {
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
time.Now().Format(time.RFC3339), err)
}
// Stay in slate mode, retry recovery after delay
time.Sleep(30 * time.Second)
continue
}
// If we were in slate but FFmpeg ran successfully, we recovered
if playoutMode == "slate" {
deactivateSlate()
} }
// After FFmpeg run, sync latest segments and manifest to R2 // After FFmpeg run, sync latest segments and manifest to R2
if err := syncLatestToR2("/app/hls"); err != nil { if err := syncLatestToR2(hlsDir); err != nil {
fmt.Println("R2 sync warning:", err) fmt.Println("R2 sync warning:", err)
} }
+60 -3
View File
@@ -1,3 +1,9 @@
# docker-compose.prod.yml — TEST LAB DEPLOYMENT
# Target: test lab host 192.168.88.40 (NOT Hetzner production).
# Production Hetzner VPS not yet provisioned — see DEPLOYMENT.md Production Deployment section.
# Uses pre-built images from Gitea Container Registry (no local build).
# Override DOCKER_HOST in CD workflow to switch target.
services: services:
playout: playout:
image: git.allucanget.biz/zwitschi/linearvoid/playout:latest image: git.allucanget.biz/zwitschi/linearvoid/playout:latest
@@ -9,6 +15,7 @@ services:
- ./data/playlist:/app/playlist:rw - ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:ro - ./data/media:/app/media:ro
- ./data/hls:/app/hls:rw - ./data/hls:/app/hls:rw
- ./data/slate:/app/slate:ro
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
@@ -42,7 +49,7 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 30s
nginx: nginx:
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
@@ -68,7 +75,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: linearvoid-redis container_name: linearvoid-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes: volumes:
- redis-data:/data - redis-data:/data
networks: networks:
@@ -97,6 +104,56 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
container_name: linearvoid-nginx-exporter
restart: unless-stopped
command:
- "-nginx.scrape-uri=http://nginx/nginx-status"
networks:
- linearvoid-net
depends_on:
- nginx
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: linearvoid-redis-exporter
restart: unless-stopped
command:
- "-redis.addr=redis://redis:6379"
networks:
- linearvoid-net
depends_on:
- redis
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: linearvoid-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://linearvoid:${POSTGRES_PASSWORD:-linearvoid}@db:5432/linearvoid?sslmode=disable"
networks:
- linearvoid-net
depends_on:
- db
node-exporter:
image: prom/node-exporter:latest
container_name: linearvoid-node-exporter
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/rootfs"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
networks:
- linearvoid-net
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:latest
container_name: linearvoid-prometheus container_name: linearvoid-prometheus
@@ -141,6 +198,6 @@ networks:
volumes: volumes:
redis-data: redis-data:
postgres-data: postgres-data:
prometheus-data:
prometheus-config: prometheus-config:
prometheus-data:
grafana-data: grafana-data:
+58 -1
View File
@@ -1,3 +1,8 @@
# docker-compose.yml — LOCAL DEVELOPMENT
# Target: local workstation or test lab (192.168.88.0/24).
# NOT for production. Use docker-compose.prod.yml for deployment.
# Services build from source; ports exposed for direct dev access.
services: services:
playout: playout:
image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
@@ -12,6 +17,7 @@ services:
- ./data/playlist:/app/playlist:ro - ./data/playlist:/app/playlist:ro
- ./data/media:/app/media:ro - ./data/media:/app/media:ro
- ./data/hls:/app/hls:rw - ./data/hls:/app/hls:rw
- ./data/slate:/app/slate:ro
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
@@ -72,7 +78,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: linearvoid-redis container_name: linearvoid-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes: volumes:
- redis-data:/data - redis-data:/data
networks: networks:
@@ -91,12 +97,63 @@ services:
networks: networks:
- linearvoid-net - linearvoid-net
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
container_name: linearvoid-nginx-exporter
restart: unless-stopped
command:
- "-nginx.scrape-uri=http://nginx/nginx-status"
networks:
- linearvoid-net
depends_on:
- nginx
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: linearvoid-redis-exporter
restart: unless-stopped
command:
- "-redis.addr=redis://redis:6379"
networks:
- linearvoid-net
depends_on:
- redis
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: linearvoid-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://linearvoid:${POSTGRES_PASSWORD:-linearvoid}@db:5432/linearvoid?sslmode=disable"
networks:
- linearvoid-net
depends_on:
- db
node-exporter:
image: prom/node-exporter:latest
container_name: linearvoid-node-exporter
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/rootfs"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
networks:
- linearvoid-net
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:latest
container_name: linearvoid-prometheus container_name: linearvoid-prometheus
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
ports: ports:
- "9090:9090" - "9090:9090"
+360 -192
View File
@@ -4,18 +4,93 @@
## Table of Contents ## Table of Contents
- [Introduction and Goals](#introduction-and-goals) - [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
- [Architecture Constraints](#architecture-constraints) - [Table of Contents](#table-of-contents)
- [Context and Scope](#context-and-scope) - [Companion Documentation](#companion-documentation)
- [Solution Strategy](#solution-strategy) - [Introduction and Goals](#introduction-and-goals)
- [Building Block View](#building-block-view) - [Requirements Overview](#requirements-overview)
- [Runtime View](#runtime-view) - [Quality Goals](#quality-goals)
- [Deployment View](#deployment-view) - [Stakeholders](#stakeholders)
- [Cross-cutting Concepts](#cross-cutting-concepts) - [Architecture Constraints](#architecture-constraints)
- [Architecture Decisions](#architecture-decisions) - [Context and Scope](#context-and-scope)
- [Quality Requirements](#quality-requirements) - [Business Context](#business-context)
- [Risks and Technical Debts](#risks-and-technical-debts) - [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
- [Glossary](#glossary) - [Technical Context](#technical-context)
- [Solution Strategy](#solution-strategy)
- [Building Block View](#building-block-view)
- [Whitebox Overall System](#whitebox-overall-system)
- [Level 2](#level-2)
- [Playout Service (`backend/playout/src/playlist.go`)](#playout-service-backendplayoutsrcplaylistgo)
- [Chat Service (`backend/chat/src/main.go`)](#chat-service-backendchatsrcmaingo)
- [Frontend SPA (`frontend/`)](#frontend-spa-frontend)
- [Reverse Proxy (`infrastructure/nginx/nginx.conf`)](#reverse-proxy-infrastructurenginxnginxconf)
- [Level 3](#level-3)
- [Chat Service Modules](#chat-service-modules)
- [Playout Service Modules](#playout-service-modules)
- [Frontend Modules](#frontend-modules)
- [Runtime View](#runtime-view)
- [Playout \& Delivery Scenario](#playout--delivery-scenario)
- [Deployment View](#deployment-view)
- [CI/CD Pipeline Architecture](#cicd-pipeline-architecture)
- [Infrastructure Level 1](#infrastructure-level-1)
- [Infrastructure Level 2](#infrastructure-level-2)
- [Cross-cutting Concepts](#cross-cutting-concepts)
- [Caching \& Cost Optimization](#caching--cost-optimization)
- [Video Normalization \& Playout](#video-normalization--playout)
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
- [Reference Normalization Command](#reference-normalization-command)
- [Verification Gate (ffprobe)](#verification-gate-ffprobe)
- [Monitoring \& Resilience](#monitoring--resilience)
- [Prometheus Baseline](#prometheus-baseline)
- [Grafana Dashboards](#grafana-dashboards)
- [Alerting Rules (Prometheus Rule Groups)](#alerting-rules-prometheus-rule-groups)
- [API Documentation Reference](#api-documentation-reference)
- [Chat WebSocket API](#chat-websocket-api)
- [Playout and Delivery REST API](#playout-and-delivery-rest-api)
- [Admin REST API](#admin-rest-api)
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
- [DNS and Zone Routing](#dns-and-zone-routing)
- [Cache Rules Baseline](#cache-rules-baseline)
- [Edge Security and Controls](#edge-security-and-controls)
- [Worker and Header Policy](#worker-and-header-policy)
- [Operations and Validation](#operations-and-validation)
- [Security \& Moderation](#security--moderation)
- [Transport and Edge Protection](#transport-and-edge-protection)
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
- [Admin Authentication and Session Security](#admin-authentication-and-session-security)
- [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
- [Moderation and Privacy](#moderation-and-privacy)
- [Chat](#chat)
- [Architecture Decisions](#architecture-decisions)
- [Quality Requirements](#quality-requirements)
- [Performance Benchmarks and Capacity Planning](#performance-benchmarks-and-capacity-planning)
- [Baseline SLO Targets (v1)](#baseline-slo-targets-v1)
- [Workload Assumptions](#workload-assumptions)
- [Initial Capacity Model](#initial-capacity-model)
- [Benchmark Plan](#benchmark-plan)
- [Capacity Triggers and Actions](#capacity-triggers-and-actions)
- [Scaling Strategy](#scaling-strategy)
- [Phase 1: Vertical + Cache-First (Current Baseline)](#phase-1-vertical--cache-first-current-baseline)
- [Phase 2: Service Separation](#phase-2-service-separation)
- [Phase 3: Multi-Origin Resilience](#phase-3-multi-origin-resilience)
- [Phase 4: Regional and Throughput Expansion](#phase-4-regional-and-throughput-expansion)
- [Scaling Triggers](#scaling-triggers)
- [Disaster Recovery and Backup Procedures](#disaster-recovery-and-backup-procedures)
- [Recovery Targets](#recovery-targets)
- [Backup Scope](#backup-scope)
- [Backup Cadence](#backup-cadence)
- [Recovery Runbook (Minimum)](#recovery-runbook-minimum)
- [Failure Modes and Strategy](#failure-modes-and-strategy)
- [Risks and Technical Debts](#risks-and-technical-debts)
- [Glossary](#glossary)
## Companion Documentation
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
## Introduction and Goals ## Introduction and Goals
@@ -107,13 +182,6 @@ flowchart TD
Chat --> VPS Chat --> VPS
VPS -->|Proxy / Static| Pages VPS -->|Proxy / Static| Pages
CDN -->|Origin Pull on Miss| R2 CDN -->|Origin Pull on Miss| R2
classDef cloud fill:#f0f0ff,stroke:#4263eb
classDef vps fill:#f0fff0,stroke:#2e7d32
classDef user fill:#fff0f0,stroke:#c62828
class Cloudflare,CDN,Pages,R2 cloud
class VPS,Playout,Chat vps
class Viewers,Admins user
``` ```
**External Interfaces**: **External Interfaces**:
@@ -196,9 +264,116 @@ graph TD
Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs. Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
### Level 2 & 3 ### Level 2
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming. Detailed component diagram with code locations and persistence.
```mermaid
graph TB
subgraph "Cloudflare Edge"
CF[CDN + WAF<br/>Cache Rules]
R2[R2 Bucket<br/>media + HLS]
PAGES[Pages<br/>SPA hosting]
end
subgraph "Docker Host (Hetzner CX22)"
NGINX[nginx<br/>:80/:443<br/>proxy + auth]
CHAT[chat service<br/>:3000<br/>Go + gorilla/websocket]
PLAYOUT[playout service<br/>:8081<br/>Go + FFmpeg]
REDIS[Redis 7<br/>:6379<br/>presence/rate/pubsub]
DB[PostgreSQL 15<br/>:5432<br/>chat history/moderation]
end
subgraph "External"
VIEWER[Viewer Browser<br/>hls.js + WebSocket]
ADMIN[Admin Browser<br/>SPA + uploads]
end
VIEWER -->|HTTPS| CF
VIEWER -->|WSS| NGINX
ADMIN -->|HTTPS| CF
CF --> NGINX
NGINX --> CHAT
NGINX --> PLAYOUT
NGINX -->|/hls/*| R2
CHAT --> REDIS
CHAT --> DB
PLAYOUT --> R2
PLAYOUT -->|reads| R2
```
#### Playout Service (`backend/playout/src/playlist.go`)
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
#### Chat Service (`backend/chat/src/main.go`)
- **Role**: WebSocket chat with rooms, DMs, moderation, admin API
- **Persistence**: PostgreSQL 15 via GORM (messages, moderation logs, media metadata). SQLite for dev fallback
- **Redis**: Presence tracking, rate limiting, pub/sub for multi-instance fan-out
- **Auth**: Session-cookie admin auth with Redis-backed expiry
- **Metrics**: Connected clients, stored messages, DMs, moderation actions on `/metrics`
#### Frontend SPA (`frontend/`)
- **Viewer**: `src/main.ts` — hls.js player, WebSocket chat, presence indicators, schedule overlay
- **Admin**: `src/admin.ts` — 9 dynamic tabs (dashboard, upload, playlist, schedule, moderation, chat-log, users, analytics, system)
- **Build**: Vite → static output in `frontend/dist/`
#### Reverse Proxy (`infrastructure/nginx/nginx.conf`)
- auth_request gate on `/admin` and `/api/admin`
- WebSocket upgrade for `/ws`
- HLS CORS headers + OPTIONS preflight
- `/health` JSON endpoint, `/nginx-status` stub_status
### Level 3
Module-level breakdown per service.
#### Chat Service Modules
| Module | File/Lines | Responsibility |
| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages |
| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler |
| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out |
| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting |
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
#### Playout Service Modules
| Module | File/Lines | Responsibility |
| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------ |
| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths |
| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash |
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) |
#### Frontend Modules
| Module | File | Responsibility |
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery |
| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers |
| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots |
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback |
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` |
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons |
## Runtime View ## Runtime View
@@ -219,7 +394,7 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
### CI/CD Pipeline Architecture ### CI/CD Pipeline Architecture
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them to the production host. The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them. Currently targeting the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -230,7 +405,7 @@ flowchart LR
subgraph "Gitea Container Registry" subgraph "Gitea Container Registry"
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest] REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
end end
subgraph "Production Host (192.168.88.40)" subgraph "Test Lab Host (192.168.88.40) — Production Hetzner TBD"
DOCKER[Docker Engine<br/>via TCP :2375] DOCKER[Docker Engine<br/>via TCP :2375]
PLAYOUT[linearvoid-playout] PLAYOUT[linearvoid-playout]
CHAT[linearvoid-chat] CHAT[linearvoid-chat]
@@ -251,13 +426,6 @@ flowchart LR
DOCKER --> DB DOCKER --> DB
DOCKER --> PROM DOCKER --> PROM
DOCKER --> GRAF DOCKER --> GRAF
classDef ci fill:#e1f5fe,stroke:#0288d1
classDef reg fill:#fff3e0,stroke:#f57c00
classDef prod fill:#e8f5e9,stroke:#388e3c
class CI,CD ci
class REGISTRY reg
class DOCKER,PLAYOUT,CHAT,NGINX,REDIS,DB,PROM,GRAF prod
``` ```
**Pipeline Sequence:** **Pipeline Sequence:**
@@ -273,7 +441,7 @@ flowchart LR
- Pulls latest images from registry - Pulls latest images from registry
- Generates `.env` from secrets - Generates `.env` from secrets
- Seeds Prometheus config into named volume - Seeds Prometheus config into named volume
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375`) - Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
- Runs health checks with retry logic for playout initialization - Runs health checks with retry logic for playout initialization
### Infrastructure Level 1 ### Infrastructure Level 1
@@ -292,7 +460,7 @@ flowchart LR
### Infrastructure Level 2 ### Infrastructure Level 2
**Hetzner VPS**: Runs playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2. **Hetzner VPS (planned):** Will run playout (FFmpeg/MediaMTX), chat backend, Nginx. Monitoring via dashboards + UptimeRobot. Backups to R2. Currently test lab (`192.168.88.40`) serves this role. Production Hetzner instance not yet provisioned — see Production Deployment section in [`DEPLOYMENT.md`](../../DEPLOYMENT.md).
**Cloudflare Layer**: **Cloudflare Layer**:
@@ -311,7 +479,39 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
### Video Normalization & Playout ### Video Normalization & Playout
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency. All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
#### Required Source Profile (Linear Void v1)
| Field | Required Value | Notes |
| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
| Container | MP4 | Single-video-asset input format |
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
| Audio codec | AAC-LC | Stereo preferred |
| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
| Pixel format | `yuv420p` | Browser compatibility baseline |
| Audio sample rate | 48 kHz | Stable timing for concat playout |
| Audio bitrate | 128k-192k | Predictable quality envelope |
#### Reference Normalization Command
```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
```
#### Verification Gate (ffprobe)
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
- Reject non-conforming files from admin ingest with actionable error feedback.
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
### Monitoring & Resilience ### Monitoring & Resilience
@@ -366,6 +566,98 @@ Operational resilience baseline:
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres). - Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies. - Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
### API Documentation Reference
Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
#### Chat WebSocket API
- Endpoint: `/ws`
- Transport: `wss://` in production, `ws://` locally
- Query parameters:
- `username`
- `room`
- Message envelope (typical):
- `room`
- `username`
- `text`
- `type` (`public`, `private`, `system`)
- Responsibilities:
- room fan-out
- private DM channel routing
- moderation command handling
- rate limiting and presence integration
#### Playout and Delivery REST API
- Health endpoints:
- playout health (`/health` on playout service)
- edge/origin health (`/health` via nginx)
- Stream delivery:
- HLS manifest and segment path namespace under `/hls/`
- Responsibilities:
- expose liveness for orchestration and monitoring
- provide stable HLS path contract for viewer and CDN
#### Admin REST API
- Auth/session:
- `/api/admin/login`
- `/api/admin/logout`
- `/api/admin/session`
- Operational/admin domains:
- overview, analytics, users, moderation, schedule, playlist, media
- Media upload domain:
- `/api/admin/media/uploads` (init/resume)
- `/api/admin/media/uploads/{uploadId}/chunk`
- `/api/admin/media/uploads/{uploadId}/complete`
- Responsibilities:
- protected control plane for operator workflows
- media metadata + upload orchestration
- moderation/audit and system status management
### Cloudflare Configuration Reference
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
#### DNS and Zone Routing
- `linearvoid.tv` -> application origin (nginx)
- `www.linearvoid.tv` -> redirect to apex
- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
#### Cache Rules Baseline
- HLS segments (`.ts`, `.m4s`):
- Cache Everything
- long TTL (immutable)
- respect immutable semantics
- Manifests (`.m3u8`):
- short TTL (1-10s)
- revalidation-friendly policy for timeline freshness
#### Edge Security and Controls
- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
- Enable WAF baseline protections for common web exploits.
- Apply rate limits for abusive request patterns (especially auth/admin paths).
- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
#### Worker and Header Policy
- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
- Keep CORS behavior explicit for HLS resources to support browser playback.
#### Operations and Validation
- Validate cache behavior after deployment:
- expected cache status progression (MISS -> HIT)
- manifest freshness under active playout
- Track edge metrics and adjust:
- cache hit ratio
- request volume and error rates
- ToS/cost posture for sustained traffic profiles
### Security & Moderation ### Security & Moderation
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend). Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
@@ -490,6 +782,41 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
- Trigger: origin CPU/memory pressure on playout host. - Trigger: origin CPU/memory pressure on playout host.
- Action: optimize FFmpeg profile, isolate services, add secondary origin. - Action: optimize FFmpeg profile, isolate services, add secondary origin.
### Scaling Strategy
Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
#### Phase 1: Vertical + Cache-First (Current Baseline)
- Single primary origin in US-East with strong Cloudflare edge caching.
- Optimize segment immutability and manifest TTL before adding infrastructure.
- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
#### Phase 2: Service Separation
- Split playout, chat, and observability workloads across dedicated nodes/VMs.
- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
#### Phase 3: Multi-Origin Resilience
- Add warm standby or active-secondary origin.
- Use health-aware DNS/origin failover strategy.
- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
#### Phase 4: Regional and Throughput Expansion
- Add additional origin region only when audience distribution and latency justify it.
- Keep chat affinity strategy explicit (regional routing, state replication considerations).
- Continuously evaluate cache miss economics vs additional origin complexity.
#### Scaling Triggers
- Rising cache miss ratio with sustained traffic growth.
- Chat p95/p99 latency drift or websocket error growth.
- Persistent origin resource saturation despite optimization.
- Increased blast-radius concerns from single-host dependency.
### Disaster Recovery and Backup Procedures ### Disaster Recovery and Backup Procedures
#### Recovery Targets #### Recovery Targets
@@ -572,162 +899,3 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. | | Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted. **References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
### Whitebox Overall System
**_\<Overview Diagram\>_**
Motivation
: _\<text explanation\>_
Contained Building Blocks
: _\<Description of contained building block (black boxes)\>_
Important Interfaces
: _\<Description of important interfaces\>_
#### \<Name black box 1\>
_\<Purpose/Responsibility\>_
_\<Interface(s)\>_
_\<(Optional) Quality/Performance Characteristics\>_
_\<(Optional) Directory/File Location\>_
_\<(Optional) Fulfilled Requirements\>_
_\<(optional) Open Issues/Problems/Risks\>_
#### \<Name black box 2\>
_\<black box template\>_
#### \<Name black box n\>
_\<black box template\>_
#### \<Name interface 1\>
...​
#### \<Name interface m\>
### Level 2
#### White Box _\<building block 1\>_
_\<white box template\>_
#### White Box _\<building block 2\>_
_\<white box template\>_
...​
#### White Box _\<building block m\>_
_\<white box template\>_
### Level 3
#### White Box \<\_building block x.1\_\>
_\<white box template\>_
#### White Box \<\_building block x.2\_\>
_\<white box template\>_
#### White Box \<\_building block y.1\_\>
_\<white box template\>_
## Runtime View
### \<Runtime Scenario 1\>
- _\<insert runtime diagram or textual description of the scenario\>_
- _\<insert description of the notable aspects of the interactions
between the building block instances depicted in this diagram.\>_
### \<Runtime Scenario 2\>
### ...​
### \<Runtime Scenario n\>
## Deployment View
### Infrastructure Level 1
**_\<Overview Diagram\>_**
Motivation
: _\<explanation in text form\>_
Quality and/or Performance Features
: _\<explanation in text form\>_
Mapping of Building Blocks to Infrastructure
: _\<description of the mapping\>_
### Infrastructure Level 2
#### _\<Infrastructure Element 1\>_
_\<diagram + explanation\>_
#### _\<Infrastructure Element 2\>_
_\<diagram + explanation\>_
...​
#### _\<Infrastructure Element n\>_
_\<diagram + explanation\>_
## Cross-cutting Concepts
### _\<Concept 1\>_
_\<explanation\>_
### _\<Concept 2\>_
_\<explanation\>_
...​
### _\<Concept n\>_
_\<explanation\>_
## Architecture Decisions
## Quality Requirements
### Quality Requirements Overview
### Quality Scenarios
## Risks and Technical Debts
## Glossary
+----------------------+-----------------------------------------------+
| Term | Definition |
+======================+===============================================+
| _\<Term-1\>_ | _\<definition-1\>_ |
+----------------------+-----------------------------------------------+
| _\<Term-2\>_ | _\<definition-2\>_ |
+----------------------+-----------------------------------------------+
@@ -60,8 +60,6 @@ Create a dedicated **admin web interface** in a new `frontend/admin/` directory
- Integration with existing playout service and R2 keeps the architecture consistent. - Integration with existing playout service and R2 keeps the architecture consistent.
- Admin actions will be fully auditable, paving the way for ADR-005 (moderation and user management). - Admin actions will be fully auditable, paving the way for ADR-005 (moderation and user management).
This decision was planned in `TODO.md` before implementation, per AGENTS.md.
## Consequences ## Consequences
**Positive**: **Positive**:
@@ -59,8 +59,6 @@ Extend the admin interface started in ADR-004 with additional protected sections
- Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection. - Leveraging Prometheus/Grafana for analytics avoids duplicating metrics collection.
- Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system). - Immutable audit logging supports future compliance and moderation review (key requirement for a public chat system).
This ADR was planned in `TODO.md` before any implementation, per AGENTS.md.
## Consequences ## Consequences
**Positive**: **Positive**:
@@ -92,8 +90,6 @@ This ADR was planned in `TODO.md` before any implementation, per AGENTS.md.
7. Update authentication middleware and tests. 7. Update authentication middleware and tests.
8. Document API contracts and update `docker-compose.yml` to expose the admin interface securely. 8. Document API contracts and update `docker-compose.yml` to expose the admin interface securely.
**Next Steps**: After both admin ADRs are complete, move to implementation of the Playout Service (highest priority in the core modules section of TODO.md).
## References ## References
- ADR-001: Chat features, moderation requirements, monitoring needs. - ADR-001: Chat features, moderation requirements, monitoring needs.
@@ -95,8 +95,6 @@ We must explicitly document:
6. Add monitoring (Prometheus exporter) and admin audit logging for configuration changes. 6. Add monitoring (Prometheus exporter) and admin audit logging for configuration changes.
7. Write tests for rate limiting, presence, and admin configuration flows. 7. Write tests for rate limiting, presence, and admin configuration flows.
**Next Steps**: After ADR-006 and ADR-007 are reviewed, mark the "Create ADRs for Data Storage, Caching, and Admin Capabilities" section as completed in TODO.md and move it to DONE.md. Then proceed with implementation of the Playout Service.
## References ## References
- ADR-001: Chat features requiring presence, rate limiting, and pub/sub. - ADR-001: Chat features requiring presence, rate limiting, and pub/sub.
Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

+164
View File
@@ -0,0 +1,164 @@
# Incident Runbook
Triage order for Linear Void incidents. Work top-to-bottom — each tier depends on the one above.
## Triage Order
1. Edge Reachability (Cloudflare)
2. Origin Health (VPS/Docker)
3. Playout Progression (HLS)
4. Chat Connectivity (WebSocket)
5. Data-Plane (Redis/DB)
## 1. Edge Reachability (Cloudflare)
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
```bash
# Check DNS
dig linearvoid.tv
dig www.linearvoid.tv
# Check Cloudflare status
curl -I https://linearvoid.tv/health
```
**Common causes:**
- DNS misconfiguration (A record pointing to wrong IP)
- Cloudflare proxy disabled (grey cloud → origin exposed directly)
- SSL certificate expired (check Cloudflare dashboard → SSL/TLS)
- Cloudflare outage → check [https://www.cloudflarestatus.com](https://www.cloudflarestatus.com)
**Mitigation:**
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
## 2. Origin Health (VPS/Docker)
**Symptoms:** Cloudflare returns 502/504, containers down.
```bash
# Remote Docker (test lab)
docker -H tcp://192.168.88.40:2375 ps
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
# Direct health check
curl http://192.168.88.40/health
```
**Common causes:**
- Host out of disk space: `df -h`
- Docker daemon stopped: `systemctl status docker`
- Container restart loop: `docker ps -a --filter "status=exited"`
- Resource exhaustion (OOM): `dmesg | grep -i oom`
**Mitigation:**
- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
- Free disk: `docker system prune -f`
- Check logs per service: `docker logs linearvoid-<service> --tail 100`
## 3. Playout Progression (HLS)
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
```bash
# Check playout health
curl http://192.168.88.40:8081/health
# Response includes "mode": "live" or "slate"
# Check HLS output
curl -I http://192.168.88.40/hls/playlist.m3u8
ls -la data/hls/
# Check playout logs
docker logs linearvoid-playout --tail 100
```
**Common causes:**
- FFmpeg crash → slate fallback active (health shows `degraded`)
- Playlist file empty/missing: `cat data/playlist/playlist.txt`
- Media file missing from playlist: `ls data/media/`
- Disk full → HLS segments can't write
**Mitigation:**
- Verify playlist has valid entries
- Check media files exist at paths in playlist
- Restart playout: `docker compose restart playout`
- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
## 4. Chat Connectivity (WebSocket)
**Symptoms:** Chat not loading, messages not sending, presence broken.
```bash
# Check chat health
curl http://192.168.88.40:3000/health
# Test WebSocket
wscat -c ws://192.168.88.40/ws?username=test&room=main
# Check chat logs
docker logs linearvoid-chat --tail 100
```
**Common causes:**
- Redis down → presence/rate-limiting degraded (chat still works)
- Database down → messages not persisting
- Port not exposed: `docker port linearvoid-chat`
**Mitigation:**
- Restart chat: `docker compose restart chat`
- Check Redis: `docker exec linearvoid-redis redis-cli ping`
- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
## 5. Data-Plane (Redis/DB)
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
```bash
# Redis
docker exec linearvoid-redis redis-cli ping
docker exec linearvoid-redis redis-cli info memory
# PostgreSQL
docker exec linearvoid-db pg_isready -U linearvoid
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
```
**Common causes:**
- Redis OOM (check `maxmemory` setting)
- PostgreSQL connection limit reached
- Disk full on volume mount
**Mitigation:**
- Redis: `docker compose restart redis` (AOF persist enabled)
- PostgreSQL: `docker compose restart db`
- Restore from backup: see `scripts/verify-backups.sh` for procedure
## Escalation
| Severity | When | Action |
| -------- | ------------------------------------ | -------------------------------------------------- |
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service |
| Info | Single service blip | Monitor, no action unless recurring |
## Post-Incident
1. Document timeline in incident channel
2. Identify root cause
3. Add prevention to docs if actionable
4. Update this runbook if new failure mode discovered
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup.
+3 -308
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="../src/style.css" /> <link rel="stylesheet" href="../src/style.css" />
</head> </head>
<body class="admin-body" data-admin-default-moderator="%VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR%" data-admin-default-room="%VITE_PUBLIC_ADMIN_DEFAULT_ROOM%" data-admin-moderation-log-limit="%VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT%"> <body class="admin-body">
<div class="admin-shell"> <div class="admin-shell">
<aside class="admin-sidebar" aria-label="Primary"> <aside class="admin-sidebar" aria-label="Primary">
<div class="brand-block"> <div class="brand-block">
@@ -24,6 +24,7 @@
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button> <button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button> <button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button> <button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button> <button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button> <button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button> <button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
@@ -31,313 +32,7 @@
</aside> </aside>
<main class="admin-main" id="admin-main" tabindex="-1"> <main class="admin-main" id="admin-main" tabindex="-1">
<section id="tab-dashboard" class="tab-panel is-active" role="tabpanel" aria-labelledby="tab-trigger-dashboard"> <div id="tab-content-container" aria-live="polite">Loading...</div>
<h1>Dashboard</h1>
<div class="stat-grid">
<article class="stat-card">
<h2>Concurrent Viewers</h2>
<p id="stat-viewers" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Stored Messages</h2>
<p id="stat-stored-messages" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Moderation Actions</h2>
<p id="stat-moderation-actions" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Media Runtime</h2>
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
<p id="stat-media-count" class="stat-caption">0 files</p>
</article>
</div>
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
</section>
<section id="tab-upload" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-upload" hidden>
<div class="panel-header-row">
<div>
<h1>Upload Video</h1>
<p>Upload media files into the local media library mounted for playout.</p>
</div>
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
</div>
<div id="upload-dropzone" class="upload-dropzone">
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
<progress id="media-upload-progress" value="0" max="100"></progress>
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
</div>
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Media Library</h2>
<p id="media-generated-at" class="small-muted">loading...</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>File</th>
<th>Title</th>
<th>Runtime</th>
<th>Status</th>
<th>Size</th>
<th>Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="media-table-body"></tbody>
</table>
</div>
</div>
</section>
<section id="tab-schedule" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-schedule" hidden>
<div class="panel-header-row">
<div>
<h1>Schedule</h1>
<p>Read-only view of the currently configured playout order.</p>
</div>
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
</div>
<div class="panel-card">
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Label</th>
<th>Runtime</th>
<th>Status</th>
<th>Path</th>
</tr>
</thead>
<tbody id="schedule-table-body"></tbody>
</table>
</div>
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
</div>
</section>
<section id="tab-playlist" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-playlist" hidden>
<div class="panel-header-row">
<div>
<h1>Playlist</h1>
<p>Edit the active concat playlist used by the playout service.</p>
</div>
<div class="button-row">
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
</div>
</div>
<div class="panel-card">
<label for="playlist-content" class="sr-only">Playlist content</label>
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
</div>
</section>
<section id="tab-moderation" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-moderation" hidden>
<div class="panel-header-row">
<div>
<h1>Moderation</h1>
<p>Create moderation actions and review the recent immutable audit log.</p>
</div>
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
</div>
<div class="moderation-grid">
<section class="panel-card">
<h2>New Action</h2>
<form id="moderation-form" class="form-stack">
<label>Action
<select id="mod-action" class="text-input">
<option value="mute">mute</option>
<option value="ban">ban</option>
<option value="delete">delete</option>
<option value="timeout">timeout</option>
</select>
</label>
<label>Target
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
</label>
<label>Moderator
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
</label>
<label>Room
<input id="mod-room" class="text-input" type="text" placeholder="main" />
</label>
<label>Reason
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
</label>
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
</form>
</section>
<section class="panel-card">
<div class="panel-header-row">
<h2>Recent Audit Log</h2>
<p class="small-muted">Latest entries</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Moderator</th>
<th>Reason</th>
</tr>
</thead>
<tbody id="moderation-table-body"></tbody>
</table>
</div>
</section>
</div>
</section>
<section id="tab-analytics" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-analytics" hidden>
<div class="panel-header-row">
<div>
<h1>Analytics</h1>
<p>Basic activity counters from chat history and moderation audit logs.</p>
</div>
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
</div>
<div class="stat-grid">
<article class="stat-card">
<h2>Total Messages</h2>
<p id="analytics-total-messages" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Last 24 Hours</h2>
<p id="analytics-messages-24h" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Distinct Rooms</h2>
<p id="analytics-distinct-rooms" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Moderation Actions</h2>
<p id="analytics-moderation-actions" class="stat-value">0</p>
</article>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Top Rooms</h2>
<p id="analytics-generated-at" class="small-muted">loading...</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Room</th>
<th>Messages</th>
</tr>
</thead>
<tbody id="analytics-table-body"></tbody>
</table>
</div>
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
</div>
</section>
<section id="tab-system" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-system" hidden>
<h1>System Configuration</h1>
<div class="panel-card split-grid">
<section>
<h2>Redis Stats</h2>
<dl class="kv-list">
<div>
<dt>Memory Used</dt>
<dd id="system-redis-memory">loading...</dd>
</div>
<div>
<dt>Connected Clients</dt>
<dd id="system-redis-clients">0</dd>
</div>
<div>
<dt>Redis Enabled</dt>
<dd id="system-redis-enabled">no</dd>
</div>
</dl>
</section>
<section>
<h2>Storage</h2>
<dl class="kv-list">
<div>
<dt>Database Driver</dt>
<dd id="system-db-driver">loading...</dd>
</div>
<div>
<dt>Stored Messages</dt>
<dd id="system-stored-messages">0</dd>
</div>
<div>
<dt>Last Refresh</dt>
<dd id="system-generated-at">loading...</dd>
</div>
<div>
<dt>Playlist Path</dt>
<dd id="system-playlist-path">loading...</dd>
</div>
<div>
<dt>Playlist Present</dt>
<dd id="system-playlist-present">no</dd>
</div>
</dl>
</section>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Service Health</h2>
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
</div>
<div id="system-services" class="service-list"></div>
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
</div>
</section>
<section id="tab-users" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-users" hidden>
<div class="panel-header-row">
<div>
<h1>Users</h1>
<p>Current room presence derived from Redis presence keys.</p>
</div>
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
</div>
<div class="panel-card">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Room</th>
<th>Online</th>
</tr>
</thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
</div>
</section>
</main> </main>
</div> </div>
+11 -21
View File
@@ -10,7 +10,6 @@
<body class="lv-body"> <body class="lv-body">
<main class="viewer-shell" aria-label="Live player and chat"> <main class="viewer-shell" aria-label="Live player and chat">
<section class="viewer-main" aria-label="Video stream panel">
<header class="viewer-header"> <header class="viewer-header">
<div class="viewer-brand-group"> <div class="viewer-brand-group">
<div class="brand-badge" aria-hidden="true">LV</div> <div class="brand-badge" aria-hidden="true">LV</div>
@@ -19,33 +18,19 @@
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p> <p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div> </div>
</div> </div>
<div class="now-playing-pill" aria-live="polite"> <button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
<span class="live-dot" aria-hidden="true"></span>
<span id="now-playing">Loading schedule...</span>
</div>
</header> </header>
<section class="viewer-content" aria-label="Live stream and chat layout">
<section class="player-stage" aria-label="Video stage"> <section class="player-stage" aria-label="Video stage">
<video id="player" class="player" controls autoplay muted playsinline></video> <video id="player" class="player" controls autoplay muted playsinline></video>
<div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite"> <div id="now-playing" class="now-playing-overlay" aria-live="polite">
<div class="now-playing-overlay-inner"> <span id="np-title">Loading...</span>
<span class="live-label">LIVE</span> <span id="np-next" class="np-next"></span>
<span id="overlay-title" class="overlay-title"></span>
</div>
</div> </div>
</section> </section>
<footer class="viewer-footer"> <aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
<div>Latency: ~25s • Synced</div>
<nav class="room-links" aria-label="Quick room switch">
<a href="#" onclick="switchRoom('main'); return false;">Main Channel</a>
<a href="#" onclick="switchRoom('chill'); return false;">Chill Room</a>
<a href="#" onclick="switchRoom('talk'); return false;">Talk Room</a>
</nav>
</footer>
</section>
<aside class="chat-sidebar" aria-label="Chat panel">
<header class="chat-header"> <header class="chat-header">
<div class="chat-room-title"> <div class="chat-room-title">
<span class="chat-live-marker" aria-hidden="true">●</span> <span class="chat-live-marker" aria-hidden="true">●</span>
@@ -56,6 +41,10 @@
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section> <section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
<section id="presence-panel" class="presence-panel" aria-label="Online users">
<div id="presence-list" class="presence-list"></div>
</section>
<div class="chat-input-area"> <div class="chat-input-area">
<div class="chat-input-row"> <div class="chat-input-row">
<label for="message-input" class="sr-only">Message input</label> <label for="message-input" class="sr-only">Message input</label>
@@ -67,6 +56,7 @@
</p> </p>
</div> </div>
</aside> </aside>
</section>
</main> </main>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
+3 -2
View File
@@ -1,12 +1,13 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~6.0.2", "typescript": "~6.0.2",
+53
View File
@@ -0,0 +1,53 @@
<div class="panel-header-row">
<div>
<h1>Analytics</h1>
<p>Basic activity counters from chat history and moderation audit logs.</p>
</div>
<div class="button-row">
<select id="analytics-range" class="text-input" style="width:auto">
<option value="1h">Last hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h" selected>Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
</div>
</div>
<div class="stat-grid">
<article class="stat-card">
<h2>Total Messages</h2>
<p id="analytics-total-messages" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Last 24 Hours</h2>
<p id="analytics-messages-24h" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Distinct Rooms</h2>
<p id="analytics-distinct-rooms" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Moderation Actions</h2>
<p id="analytics-moderation-actions" class="stat-value">0</p>
</article>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Top Rooms</h2>
<p id="analytics-generated-at" class="small-muted">loading...</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Room</th>
<th>Messages</th>
</tr>
</thead>
<tbody id="analytics-table-body"></tbody>
</table>
</div>
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
</div>
+46
View File
@@ -0,0 +1,46 @@
<div class="panel-header-row">
<div>
<h1>Chat Log</h1>
<p>Browse and manage stored chat messages.</p>
</div>
<div class="button-row">
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
</div>
</div>
<div class="panel-card">
<div class="chat-log-filters">
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
</div>
</div>
<div class="panel-card">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
<th>ID</th>
<th>Room</th>
<th>Username</th>
<th>Message</th>
<th>Type</th>
<th>Timestamp</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="chat-log-table-body"></tbody>
</table>
</div>
<div class="chat-log-pagination">
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
<span id="chat-log-page-info" class="small-muted">Page 1</span>
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
</div>
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
</div>
+21
View File
@@ -0,0 +1,21 @@
<h1>Dashboard</h1>
<div class="stat-grid">
<article class="stat-card">
<h2>Concurrent Viewers</h2>
<p id="stat-viewers" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Stored Messages</h2>
<p id="stat-stored-messages" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Moderation Actions</h2>
<p id="stat-moderation-actions" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Media Runtime</h2>
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
<p id="stat-media-count" class="stat-caption">0 files</p>
</article>
</div>
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
+61
View File
@@ -0,0 +1,61 @@
<div class="panel-header-row">
<div>
<h1>Moderation</h1>
<p>Create moderation actions and review the recent immutable audit log.</p>
</div>
<div class="button-row">
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
</div>
</div>
<div class="moderation-grid">
<section class="panel-card">
<h2>New Action</h2>
<form id="moderation-form" class="form-stack">
<label>Action
<select id="mod-action" class="text-input">
<option value="mute">mute</option>
<option value="ban">ban</option>
<option value="delete">delete</option>
<option value="timeout">timeout</option>
</select>
</label>
<label>Target
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
</label>
<label>Moderator
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
</label>
<label>Room
<input id="mod-room" class="text-input" type="text" placeholder="main" />
</label>
<label>Reason
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
</label>
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
</form>
</section>
<section class="panel-card">
<div class="panel-header-row">
<h2>Recent Audit Log</h2>
<p class="small-muted">Latest entries</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Moderator</th>
<th>Reason</th>
</tr>
</thead>
<tbody id="moderation-table-body"></tbody>
</table>
</div>
</section>
</div>
+18
View File
@@ -0,0 +1,18 @@
<div class="panel-header-row">
<div>
<h1>Playlist</h1>
<p>Edit the active concat playlist. Drag entries to reorder.</p>
</div>
<div class="button-row">
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
</div>
</div>
<div class="panel-card">
<div id="playlist-warnings" class="status-warnings" hidden></div>
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
<textarea id="playlist-content" class="text-input text-area" spellcheck="false" rows="6"></textarea>
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
</div>
+53
View File
@@ -0,0 +1,53 @@
<div class="panel-header-row">
<div>
<h1>Schedule</h1>
<p>Read-only view of the currently configured playout order.</p>
</div>
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
</div>
<div class="panel-card">
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Label</th>
<th>Runtime</th>
<th>Status</th>
<th>Path</th>
</tr>
</thead>
<tbody id="schedule-table-body"></tbody>
</table>
</div>
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
</div>
<div class="panel-card">
<h2>Schedule Overrides</h2>
<p>Insert scheduled overrides to play specific videos at specific times.</p>
<form id="override-form" class="form-stack" style="max-width:500px">
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
<label>Reason <input id="override-reason" type="text" class="text-input" placeholder="e.g. special event" /></label>
<button type="submit" class="btn btn-primary">Add Override</button>
</form>
<p id="override-status" class="status-line" aria-live="polite"></p>
<div class="table-wrap" style="margin-top:1rem">
<table class="data-table">
<thead>
<tr>
<th>Media ID</th>
<th>Start</th>
<th>End</th>
<th>Reason</th>
<th></th>
</tr>
</thead>
<tbody id="override-table-body"></tbody>
</table>
</div>
</div>
+75
View File
@@ -0,0 +1,75 @@
<h1>System Configuration</h1>
<div class="panel-card split-grid">
<section>
<h2>Redis Stats</h2>
<dl class="kv-list">
<div>
<dt>Memory Used</dt>
<dd id="system-redis-memory">loading...</dd>
</div>
<div>
<dt>Connected Clients</dt>
<dd id="system-redis-clients">0</dd>
</div>
<div>
<dt>Redis Enabled</dt>
<dd id="system-redis-enabled">no</dd>
</div>
</dl>
</section>
<section>
<h2>Storage</h2>
<dl class="kv-list">
<div>
<dt>Database Driver</dt>
<dd id="system-db-driver">loading...</dd>
</div>
<div>
<dt>Stored Messages</dt>
<dd id="system-stored-messages">0</dd>
</div>
<div>
<dt>Last Refresh</dt>
<dd id="system-generated-at">loading...</dd>
</div>
<div>
<dt>Playlist Path</dt>
<dd id="system-playlist-path">loading...</dd>
</div>
<div>
<dt>Playlist Present</dt>
<dd id="system-playlist-present">no</dd>
</div>
</dl>
</section>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Service Health</h2>
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
</div>
<div id="system-services" class="service-list"></div>
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
</div>
<div class="panel-card">
<h2>Runtime Configuration</h2>
<form id="config-form" class="form-stack" style="max-width:400px">
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
<button type="submit" class="btn btn-primary">Save Config</button>
</form>
<p id="config-status" class="status-line" aria-live="polite"></p>
</div>
<div class="panel-card">
<h2>Service Control</h2>
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
<div class="button-row">
<button class="btn btn-secondary restart-btn" data-service="playout">Restart Playout</button>
<button class="btn btn-secondary restart-btn" data-service="chat">Restart Chat</button>
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
</div>
<p id="restart-status" class="status-line" aria-live="polite"></p>
</div>
+41
View File
@@ -0,0 +1,41 @@
<div class="panel-header-row">
<div>
<h1>Upload Video</h1>
<p>Upload media files into the local media library mounted for playout.</p>
</div>
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
</div>
<div id="upload-dropzone" class="upload-dropzone">
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
<progress id="media-upload-progress" value="0" max="100"></progress>
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
</div>
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
</div>
<div class="panel-card">
<div class="panel-header-row">
<h2>Media Library</h2>
<p id="media-generated-at" class="small-muted">loading...</p>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>File</th>
<th>Title</th>
<th>Runtime</th>
<th>Status</th>
<th>Size</th>
<th>Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="media-table-body"></tbody>
</table>
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
<div class="panel-header-row">
<div>
<h1>Users</h1>
<p>Current room presence derived from Redis presence keys.</p>
</div>
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
</div>
<div class="panel-card">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Room</th>
<th>Online</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
</div>
+604 -47
View File
@@ -47,7 +47,9 @@ const uploadProgressWrap = () =>
document.getElementById("media-upload-progress-wrap") as HTMLElement | null; document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
const uploadProgressBar = () => const uploadProgressBar = () =>
document.getElementById("media-upload-progress") as HTMLProgressElement | null; document.getElementById(
"media-upload-progress",
) as HTMLProgressElement | null;
const uploadProgressText = () => const uploadProgressText = () =>
document.getElementById("media-upload-progress-text") as HTMLElement | null; document.getElementById("media-upload-progress-text") as HTMLElement | null;
@@ -135,9 +137,21 @@ const fmtSize = (value?: number) => {
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}; };
function switchTab(tabName: string) { // Page content cache: maps tab name to loaded HTML string
const pageCache: Record<string, string> = {};
async function loadPageContent(tabName: string): Promise<string> {
if (pageCache[tabName]) return pageCache[tabName];
const response = await fetch(`/admin/${tabName}.html`);
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
const html = await response.text();
pageCache[tabName] = html;
return html;
}
async function switchTab(tabName: string) {
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger"); const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
const panels = document.querySelectorAll<HTMLElement>(".tab-panel");
triggers.forEach((trigger) => { triggers.forEach((trigger) => {
const active = trigger.dataset.tab === tabName; const active = trigger.dataset.tab === tabName;
@@ -145,16 +159,27 @@ function switchTab(tabName: string) {
trigger.setAttribute("aria-selected", String(active)); trigger.setAttribute("aria-selected", String(active));
}); });
panels.forEach((panel) => { const container = document.getElementById("tab-content-container");
const active = panel.id === `tab-${tabName}`; if (!container) return;
panel.hidden = !active;
panel.classList.toggle("is-active", active);
});
try {
const html = await loadPageContent(tabName);
container.innerHTML = html;
} catch (error) {
console.error("Failed to load tab content", error);
container.innerHTML = `<p class="status-error">Failed to load ${tabName} page.</p>`;
return;
}
// Re-bind events for the newly loaded page content
bindPageEvents(tabName);
if (tabName === "dashboard") refreshOverview();
if (tabName === "upload") refreshMediaLibrary(); if (tabName === "upload") refreshMediaLibrary();
if (tabName === "playlist") refreshPlaylist(); if (tabName === "playlist") refreshPlaylist();
if (tabName === "schedule") refreshSchedule(); if (tabName === "schedule") refreshSchedule();
if (tabName === "moderation") refreshModerationLogs(); if (tabName === "moderation") refreshModerationLogs();
if (tabName === "chat-log") refreshChatLog();
if (tabName === "users") refreshUsers(); if (tabName === "users") refreshUsers();
if (tabName === "analytics") refreshAnalytics(); if (tabName === "analytics") refreshAnalytics();
if (tabName === "system") refreshSystemSummary(); if (tabName === "system") refreshSystemSummary();
@@ -234,10 +259,13 @@ async function refreshPlaylist() {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json(); const payload = await response.json();
const content: string = payload.content || "";
const textarea = document.getElementById( const textarea = document.getElementById(
"playlist-content", "playlist-content",
) as HTMLTextAreaElement | null; ) as HTMLTextAreaElement | null;
if (textarea) textarea.value = payload.content || ""; if (textarea) textarea.value = content;
renderPlaylistSortable(content);
await showPlaylistWarnings(content);
setStatus("playlist-status", "Playlist loaded."); setStatus("playlist-status", "Playlist loaded.");
} catch (error) { } catch (error) {
console.error("Failed to load playlist", error); console.error("Failed to load playlist", error);
@@ -245,6 +273,95 @@ async function refreshPlaylist() {
} }
} }
function renderPlaylistSortable(content: string) {
const list = document.getElementById("playlist-sortable");
if (!list) return;
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
list.innerHTML = lines
.map((line, i) => {
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
return `<li class="sortable-item" draggable="true" data-index="${i}">
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
<span class="sortable-index">${i + 1}</span>
<span class="sortable-path">${path}</span>
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
</li>`;
})
.join("");
let dragSrc: number | null = null;
list.querySelectorAll<HTMLElement>(".sortable-item").forEach((item) => {
item.addEventListener("dragstart", () => {
dragSrc = Number(item.dataset.index);
item.classList.add("dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("dragging");
dragSrc = null;
});
item.addEventListener("dragover", (e) => e.preventDefault());
item.addEventListener("drop", (e) => {
e.preventDefault();
const dst = Number(item.dataset.index);
if (dragSrc !== null && dragSrc !== dst) reorderPlaylistEntry(dragSrc, dst);
});
item.querySelector("[data-remove]")?.addEventListener("click", () => {
removePlaylistEntry(Number(item.dataset.index));
});
});
}
function getPlaylistLines(): string[] {
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
return ta?.value.split("\n").filter((l) => l.trim() && !l.startsWith("#")) || [];
}
function syncPlaylistTextarea(lines: string[]) {
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
if (ta) ta.value = lines.join("\n") + "\n";
renderPlaylistSortable(lines.join("\n"));
}
function reorderPlaylistEntry(from: number, to: number) {
const lines = getPlaylistLines();
const [moved] = lines.splice(from, 1);
lines.splice(to, 0, moved);
syncPlaylistTextarea(lines);
}
function removePlaylistEntry(index: number) {
const lines = getPlaylistLines();
lines.splice(index, 1);
syncPlaylistTextarea(lines);
}
async function showPlaylistWarnings(content: string) {
const warnings = document.getElementById("playlist-warnings");
if (!warnings) return;
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
const msgs: string[] = [];
if (lines.length === 0) msgs.push("Playlist is empty. Add files from the media library.");
try {
const resp = await fetch("/api/admin/media");
if (resp.ok) {
const media = await resp.json();
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
for (const line of lines) {
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
}
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
}
} catch { /* ignore */ }
if (msgs.length > 0) {
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
warnings.hidden = false;
} else {
warnings.hidden = true;
}
}
async function savePlaylist() { async function savePlaylist() {
if (state.playlistSaving) return; if (state.playlistSaving) return;
state.playlistSaving = true; state.playlistSaving = true;
@@ -308,6 +425,55 @@ async function refreshSchedule() {
} }
} }
async function refreshOverrides() {
try {
const resp = await fetch("/api/admin/schedule/overrides");
if (!resp.ok) return;
const overrides = await resp.json();
const tbody = document.getElementById("override-table-body");
if (!tbody) return;
if (!overrides.length) {
tbody.innerHTML = `<tr><td colspan="5">No overrides.</td></tr>`;
return;
}
tbody.innerHTML = overrides
.map(
(o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) =>
`<tr><td>${o.mediaId}</td><td>${new Date(o.startTime).toLocaleString()}</td><td>${new Date(o.endTime).toLocaleString()}</td><td>${o.reason || "-"}</td><td><button class="btn btn-secondary btn-sm" data-delete-override="${o.id}">✕</button></td></tr>`,
)
.join("");
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
});
} catch { /* non-critical */ }
}
async function submitOverride(e: Event) {
e.preventDefault();
const status = document.getElementById("override-status");
if (status) status.textContent = "Saving...";
const mediaId = (document.getElementById("override-media-id") as HTMLInputElement).value;
const startTime = (document.getElementById("override-start") as HTMLInputElement).value;
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
try {
const resp = await fetch("/api/admin/schedule/overrides", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
});
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
refreshOverrides();
} catch (err) {
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
}
}
async function deleteOverride(id: number) {
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
}
async function refreshSystemSummary() { async function refreshSystemSummary() {
setStatus("system-status", "Loading system summary..."); setStatus("system-status", "Loading system summary...");
try { try {
@@ -446,11 +612,18 @@ async function uploadVideo(file: File) {
const alreadyUploaded = Number(initPayload.uploadedChunks || 0); const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks); const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks); const resumeFrom = Math.min(
Math.max(alreadyUploaded, 0),
effectiveTotalChunks,
);
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks"); setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) { for (
let chunkIndex = resumeFrom;
chunkIndex < effectiveTotalChunks;
chunkIndex += 1
) {
const start = chunkIndex * UPLOAD_CHUNK_BYTES; const start = chunkIndex * UPLOAD_CHUNK_BYTES;
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES); const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
const chunk = file.slice(start, end); const chunk = file.slice(start, end);
@@ -468,7 +641,11 @@ async function uploadVideo(file: File) {
throw new Error(await readErrorMessage(chunkResponse)); throw new Error(await readErrorMessage(chunkResponse));
} }
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks"); setUploadProgress(
chunkIndex + 1,
effectiveTotalChunks,
"Uploading chunks",
);
setStatus( setStatus(
"media-status", "media-status",
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`, `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
@@ -497,7 +674,8 @@ async function uploadVideo(file: File) {
await refreshSchedule(); await refreshSchedule();
} catch (error) { } catch (error) {
console.error("Failed to upload media", error); console.error("Failed to upload media", error);
const details = error instanceof Error ? error.message : "Unknown upload error."; const details =
error instanceof Error ? error.message : "Unknown upload error.";
setStatus( setStatus(
"media-status", "media-status",
`Upload interrupted. Re-select the same file to resume. (${details})`, `Upload interrupted. Re-select the same file to resume. (${details})`,
@@ -637,6 +815,253 @@ async function submitModerationAction(event: Event) {
} }
} }
// --- Chat Log ---
type ChatLogEntry = {
id: number;
room: string;
username: string;
text: string;
timestamp: string;
type: string;
};
const chatLogState = {
entries: [] as ChatLogEntry[],
total: 0,
limit: 50,
before: "",
nextBefore: "",
selected: new Set<number>(),
room: "",
username: "",
text: "",
};
function getChatLogBefore(): string {
if (chatLogState.entries.length === 0) return "";
const oldest = chatLogState.entries[chatLogState.entries.length - 1];
return oldest.timestamp;
}
async function refreshChatLog() {
setStatus("chat-log-status", "Loading chat log...");
try {
const params = new URLSearchParams();
params.set("limit", String(chatLogState.limit));
if (chatLogState.before) params.set("before", chatLogState.before);
if (chatLogState.room) params.set("room", chatLogState.room);
if (chatLogState.username) params.set("username", chatLogState.username);
if (chatLogState.text) params.set("text", chatLogState.text);
const response = await fetch(`/api/admin/chat-log?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
chatLogState.entries = payload.entries || [];
chatLogState.total = payload.total || 0;
chatLogState.selected.clear();
renderChatLogTable();
updateChatLogPagination();
updateBulkDeleteButton();
setStatus(
"chat-log-status",
`Loaded ${chatLogState.entries.length} entries.`,
);
} catch (error) {
console.error("Failed to load chat log", error);
setStatus("chat-log-status", "Failed to load chat log.", true);
}
}
function renderChatLogTable() {
const tbody = document.getElementById("chat-log-table-body");
if (!tbody) return;
tbody.innerHTML = "";
if (chatLogState.entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="8">No chat log entries found.</td></tr>`;
return;
}
chatLogState.entries.forEach((entry) => {
const tr = document.createElement("tr");
const checked = chatLogState.selected.has(entry.id) ? "checked" : "";
tr.innerHTML = `
<td><input type="checkbox" class="chat-log-checkbox" data-id="${entry.id}" ${checked} /></td>
<td>${entry.id}</td>
<td>${entry.room}</td>
<td>${entry.username}</td>
<td>${entry.text}</td>
<td>${entry.type}</td>
<td>${entry.timestamp}</td>
<td class="action-cell">
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-mute" data-user="${entry.username}">Mute</button>
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-ban" data-user="${entry.username}">Ban</button>
<button type="button" class="btn btn-danger btn-sm" data-action="delete-chat" data-id="${entry.id}">Del</button>
</td>
`;
tr.querySelector('[data-action="chat-mute"]')?.addEventListener("click", () => {
userAction("mute", entry.username);
});
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
});
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
"click",
() => deleteChatEntry(entry.id),
);
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
const cb = e.target as HTMLInputElement;
if (cb.checked) {
chatLogState.selected.add(entry.id);
} else {
chatLogState.selected.delete(entry.id);
}
updateBulkDeleteButton();
updateSelectAllCheckbox();
});
tbody.appendChild(tr);
});
updateSelectAllCheckbox();
}
function updateSelectAllCheckbox() {
const selectAll = document.getElementById(
"chat-log-select-all",
) as HTMLInputElement | null;
if (!selectAll) return;
const allSelected =
chatLogState.entries.length > 0 &&
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
selectAll.checked = allSelected;
}
function updateBulkDeleteButton() {
const btn = document.getElementById(
"bulk-delete-chat-log-btn",
) as HTMLButtonElement | null;
if (!btn) return;
btn.disabled = chatLogState.selected.size === 0;
}
function updateChatLogPagination() {
const prevBtn = document.getElementById(
"chat-log-prev-btn",
) as HTMLButtonElement | null;
const nextBtn = document.getElementById(
"chat-log-next-btn",
) as HTMLButtonElement | null;
const info = document.getElementById("chat-log-page-info");
if (prevBtn) prevBtn.disabled = !chatLogState.before;
if (nextBtn)
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
if (info)
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
}
function chatLogNextPage() {
if (chatLogState.entries.length === 0) return;
chatLogState.before = getChatLogBefore();
refreshChatLog();
}
function chatLogPrevPage() {
chatLogState.before = "";
refreshChatLog();
}
function chatLogApplyFilter() {
chatLogState.room =
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
?.value || "";
chatLogState.username =
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
?.value || "";
chatLogState.text =
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
?.value || "";
chatLogState.before = "";
refreshChatLog();
}
function chatLogClearFilter() {
const roomEl = document.getElementById(
"chat-log-filter-room",
) as HTMLInputElement | null;
const userEl = document.getElementById(
"chat-log-filter-username",
) as HTMLInputElement | null;
const textEl = document.getElementById(
"chat-log-filter-text",
) as HTMLInputElement | null;
if (roomEl) roomEl.value = "";
if (userEl) userEl.value = "";
if (textEl) textEl.value = "";
chatLogState.room = "";
chatLogState.username = "";
chatLogState.text = "";
chatLogState.before = "";
refreshChatLog();
}
async function deleteChatEntry(id: number) {
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
return;
setStatus("chat-log-status", `Deleting message #${id}...`);
try {
const response = await fetch(`/api/admin/chat-log/${id}`, {
method: "DELETE",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setStatus("chat-log-status", `Message #${id} deleted.`);
await refreshChatLog();
await refreshOverview();
} catch (error) {
console.error("Failed to delete chat entry", error);
setStatus("chat-log-status", "Failed to delete message.", true);
}
}
async function bulkDeleteChatLog() {
if (chatLogState.selected.size === 0) return;
if (
!window.confirm(
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
)
)
return;
setStatus(
"chat-log-status",
`Deleting ${chatLogState.selected.size} messages...`,
);
try {
const ids = Array.from(chatLogState.selected);
const response = await fetch("/api/admin/chat-log/bulk", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setStatus(
"chat-log-status",
`${payload.deleted || ids.length} messages deleted.`,
);
await refreshChatLog();
await refreshOverview();
} catch (error) {
console.error("Failed to bulk delete chat entries", error);
setStatus("chat-log-status", "Failed to delete messages.", true);
}
}
async function refreshUsers() { async function refreshUsers() {
setStatus("users-status", "Loading active users..."); setStatus("users-status", "Loading active users...");
try { try {
@@ -654,9 +1079,19 @@ async function refreshUsers() {
} else { } else {
users.forEach((user: any) => { users.forEach((user: any) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`; tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>
<td>
<button class="btn btn-secondary btn-sm user-mute" data-user="${user.username}">Mute 10m</button>
<button class="btn btn-secondary btn-sm user-ban" data-user="${user.username}">Ban</button>
<button class="btn btn-secondary btn-sm user-unmute" data-user="${user.username}">Unmute</button>
<button class="btn btn-secondary btn-sm user-unban" data-user="${user.username}">Unban</button>
</td>`;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
} }
setStatus( setStatus(
@@ -671,6 +1106,63 @@ async function refreshUsers() {
} }
} }
async function userAction(action: string, username: string) {
try {
const resp = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, username, duration: 10 }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
setStatus("users-status", `${action} ${username} OK.`);
} catch (err) {
setStatus("users-status", `Failed: ${err}`, true);
}
}
async function loadConfig() {
try {
const resp = await fetch("/api/admin/config");
if (!resp.ok) return;
const cfg = await resp.json();
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
} catch { /* non-critical */ }
}
async function saveConfig(e: Event) {
e.preventDefault();
const status = document.getElementById("config-status");
if (status) status.textContent = "Saving...";
try {
const resp = await fetch("/api/admin/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
} catch (err) {
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
}
}
async function restartService(service: string) {
if (!confirm(`Restart ${service}? This may cause brief interruption.`)) return;
const status = document.getElementById("restart-status");
if (status) status.textContent = `Requesting restart of ${service}...`;
try {
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
const data = await resp.json();
if (status) status.textContent = data.message;
} catch (err) {
if (status) status.textContent = `Failed: ${err}`;
}
}
function bindEvents() { function bindEvents() {
document document
.querySelectorAll<HTMLButtonElement>(".tab-trigger") .querySelectorAll<HTMLButtonElement>(".tab-trigger")
@@ -688,31 +1180,15 @@ function bindEvents() {
window.location.href = "/admin/login/"; window.location.href = "/admin/login/";
} }
}); });
}
// bindPageEvents wires up page-specific DOM listeners after HTML is loaded
function bindPageEvents(tabName: string) {
switch (tabName) {
case "upload":
document document
.getElementById("reload-media-btn") .getElementById("reload-media-btn")
?.addEventListener("click", refreshMediaLibrary); ?.addEventListener("click", refreshMediaLibrary);
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document
.getElementById("reload-analytics-btn")
?.addEventListener("click", refreshAnalytics);
document
.getElementById("reload-users-btn")
?.addEventListener("click", refreshUsers);
document
.getElementById("reload-system-btn")
?.addEventListener("click", refreshSystemSummary);
const uploadInput = document.getElementById( const uploadInput = document.getElementById(
"video-upload", "video-upload",
@@ -736,36 +1212,117 @@ function bindEvents() {
if (!droppedFiles || droppedFiles.length === 0) return; if (!droppedFiles || droppedFiles.length === 0) return;
await uploadVideo(droppedFiles[0]); await uploadVideo(droppedFiles[0]);
}); });
uploadInput?.addEventListener("change", async (event) => { uploadInput?.addEventListener("change", async (event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return; if (!target.files || target.files.length === 0) return;
await uploadVideo(target.files[0]); await uploadVideo(target.files[0]);
target.value = ""; target.value = "";
}); });
break;
case "playlist":
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
break;
case "schedule":
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
document
.getElementById("override-form")
?.addEventListener("submit", submitOverride);
refreshOverrides();
break;
case "moderation":
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document
.getElementById("export-chat-log-btn")
?.addEventListener("click", () => {
window.open("/api/admin/chat-log/export", "_blank");
});
document document
.getElementById("moderation-form") .getElementById("moderation-form")
?.addEventListener("submit", submitModerationAction); ?.addEventListener("submit", submitModerationAction);
(document.getElementById("mod-moderator") as HTMLInputElement).value = (document.getElementById("mod-moderator") as HTMLInputElement).value =
adminConfig.defaultModerator; adminConfig.defaultModerator;
(document.getElementById("mod-room") as HTMLInputElement).value = (document.getElementById("mod-room") as HTMLInputElement).value =
adminConfig.defaultRoom; adminConfig.defaultRoom;
break;
case "chat-log":
document
.getElementById("reload-chat-log-btn")
?.addEventListener("click", refreshChatLog);
document
.getElementById("bulk-delete-chat-log-btn")
?.addEventListener("click", bulkDeleteChatLog);
document
.getElementById("chat-log-filter-btn")
?.addEventListener("click", chatLogApplyFilter);
document
.getElementById("chat-log-clear-filter-btn")
?.addEventListener("click", chatLogClearFilter);
document
.getElementById("chat-log-prev-btn")
?.addEventListener("click", chatLogPrevPage);
document
.getElementById("chat-log-next-btn")
?.addEventListener("click", chatLogNextPage);
document
.getElementById("chat-log-select-all")
?.addEventListener("change", (e) => {
const checked = (e.target as HTMLInputElement).checked;
chatLogState.entries.forEach((entry) => {
if (checked) {
chatLogState.selected.add(entry.id);
} else {
chatLogState.selected.delete(entry.id);
}
});
updateBulkDeleteButton();
renderChatLogTable();
});
break;
case "analytics":
document
.getElementById("reload-analytics-btn")
?.addEventListener("click", refreshAnalytics);
break;
case "users":
document
.getElementById("reload-users-btn")
?.addEventListener("click", refreshUsers);
break;
case "system":
document
.getElementById("reload-system-btn")
?.addEventListener("click", refreshSystemSummary);
document
.getElementById("config-form")
?.addEventListener("submit", saveConfig);
document.querySelectorAll(".restart-btn").forEach((btn) =>
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
);
loadConfig();
break;
}
} }
async function init() { async function init() {
bindEvents(); bindEvents();
await Promise.all([ // Load dashboard as the initial tab
refreshOverview(), await switchTab("dashboard");
refreshAnalytics(),
refreshMediaLibrary(),
refreshPlaylist(),
refreshSchedule(),
refreshModerationLogs(),
refreshSystemSummary(),
refreshUsers(),
]);
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
+63 -14
View File
@@ -1,12 +1,13 @@
import "./style.css"; import "./style.css";
type HlsClass = typeof import("hls.js")["default"]; type HlsClass = (typeof import("hls.js"))["default"];
declare global { declare global {
interface Window { interface Window {
switchRoom: (room: string) => void; switchRoom: (room: string) => void;
sendMessage: () => void; sendMessage: () => void;
togglePrivateDM: () => void; togglePrivateDM: () => void;
toggleChatPanel: () => void;
} }
} }
@@ -14,6 +15,8 @@ let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let currentRoom = "main"; let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000); let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true;
const onlineUsers = new Set<string>();
function resolveWebSocketBaseUrl() { function resolveWebSocketBaseUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
@@ -72,6 +75,19 @@ function connectWebSocket(room: string) {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp); addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
// Track presence from system join/leave messages
if (msg.type === "system" && msg.username === "system") {
const joined = msg.text?.match(/^(\S+) joined /);
const left = msg.text?.match(/^(\S+) left /);
if (joined) {
onlineUsers.add(joined[1]);
renderPresence();
} else if (left) {
onlineUsers.delete(left[1]);
renderPresence();
}
}
}; };
ws.onclose = () => { ws.onclose = () => {
@@ -110,6 +126,37 @@ function addMessage(
messages.scrollTop = messages.scrollHeight; messages.scrollTop = messages.scrollHeight;
} }
function renderPresence() {
const list = document.getElementById("presence-list");
if (!list) return;
const sorted = [...onlineUsers].sort();
list.innerHTML = sorted
.map(
(u) =>
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
)
.join("");
}
async function refreshSchedule() {
try {
const res = await fetch("/api/schedule");
if (!res.ok) return;
const data = await res.json();
const entries = data.entries as Array<{ title: string; status: string }>;
if (entries.length > 0) {
const np = document.getElementById("np-title");
const nn = document.getElementById("np-next");
if (np) np.textContent = `Now: ${entries[0].title}`;
if (nn && entries.length > 1) {
nn.textContent = `Up next: ${entries[1].title}`;
}
}
} catch {
// Schedule unavailable — non-critical
}
}
async function initPlayerAndChat() { async function initPlayerAndChat() {
const video = document.getElementById("player") as HTMLVideoElement; const video = document.getElementById("player") as HTMLVideoElement;
const manifestUrl = resolveManifestUrl(); const manifestUrl = resolveManifestUrl();
@@ -134,25 +181,26 @@ async function initPlayerAndChat() {
connectWebSocket("main"); connectWebSocket("main");
setInterval(() => { // Start schedule polling
const titles = [ refreshSchedule();
"Midnight Jazz Lounge", setInterval(refreshSchedule, 30000);
"Synthwave Nights",
"Classic Cinema",
"Ambient Void",
];
const title = titles[Math.floor(Math.random() * titles.length)];
document.getElementById("now-playing")!.textContent = title;
const overlay = document.getElementById("now-playing-overlay")!;
overlay.classList.remove("hidden");
document.getElementById("overlay-title")!.textContent = title;
}, 15000);
console.log( console.log(
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready", "Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
); );
} }
function toggleChatPanel() {
const shell = document.querySelector(".viewer-shell");
const toggle = document.getElementById("chat-toggle");
if (!shell || !toggle) return;
chatVisible = !chatVisible;
shell.classList.toggle("chat-collapsed", !chatVisible);
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
toggle.setAttribute("aria-expanded", String(chatVisible));
}
function sendMessage() { function sendMessage() {
const input = document.getElementById("message-input") as HTMLInputElement; const input = document.getElementById("message-input") as HTMLInputElement;
const text = input.value.trim(); const text = input.value.trim();
@@ -190,6 +238,7 @@ function togglePrivateDM() {
window.switchRoom = switchRoom; window.switchRoom = switchRoom;
window.sendMessage = sendMessage; window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM; window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel;
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat(); void initPlayerAndChat();
+303 -100
View File
@@ -1,33 +1,52 @@
:root { :root {
color-scheme: dark;
--font-body: --font-body:
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif; "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
--font-display: "Space Grotesk", "Segoe UI", sans-serif; --font-display: "Space Grotesk", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", Consolas, monospace; --font-mono: "IBM Plex Mono", Consolas, monospace;
--bg-0: #f4f6ef; --bg-0: #000000;
--bg-1: #e3e8db; --bg-1: #0a0a0a;
--ink-0: #101b18; --bg-2: #000000;
--ink-1: #2d3a36; --ink-0: #ede8e6;
--ink-soft: #5a6963; --ink-1: #b0a8a4;
--ink-soft: #78706c;
--panel: #fffffd; --panel: #111111;
--panel-2: #f7fbf2; --panel-2: #1a1a1a;
--line: #c6d1c5; --line: #2a2a2a;
--line-soft: #1f1f1f;
--brand: #005f73; --brand: #587792;
--brand-strong: #00464f; --brand-strong: #4a6780;
--brand-contrast: #f9fffd; --brand-contrast: #f4f8fb;
--ok: #2f7a43; --ok: #04650d;
--warn: #b76a00; --warn: #d49624;
--danger: #a7283f; --danger: #c44a5e;
--danger-soft: #1f1216;
--focus: #0a9396; --focus: #2ebfa5;
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12); --shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5);
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2); --shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6);
--radius-1: 0.6rem; --bg-grad-a: #000000;
--radius-2: 1rem; --bg-grad-b: #000000;
--radius-3: 1.6rem; --bg-grad-c: #000000;
--live-dot: #ff6b6b;
--overlay-bg: rgba(0, 0, 0, 0.88);
--input-bg: #0d0d0d;
--input-border: var(--line-soft);
--placeholder: #6a6260;
--badge-start: #2ebfa5;
--badge-end: #1a8c78;
--radius-1: 0;
--radius-2: 0;
--radius-3: 0;
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
@@ -40,6 +59,50 @@
--fast: 160ms ease; --fast: 160ms ease;
} }
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-0: #e8e8e8;
--bg-1: #dcdcdc;
--bg-2: #f0f0f0;
--ink-0: #0d0d0d;
--ink-1: #2a2a2a;
--ink-soft: #555555;
--panel: #ffffff;
--panel-2: #f5f5f5;
--line: #cccccc;
--line-soft: #dddddd;
--brand: #587792;
--brand-strong: #4a6780;
--brand-contrast: #f4f8fb;
--ok: #04650d;
--warn: #a86200;
--danger: #a7283f;
--danger-soft: #fff3f6;
--focus: #2ebfa5;
--shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08);
--shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12);
--bg-grad-a: #e8e8e8;
--bg-grad-b: var(--bg-0);
--bg-grad-c: #f0f0f0;
--live-dot: #cb271a;
--overlay-bg: rgba(0, 0, 0, 0.88);
--input-bg: #ffffff;
--input-border: #cccccc;
--placeholder: #888888;
--badge-start: #2ebfa5;
--badge-end: #1a8c78;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -48,6 +111,7 @@ html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%;
min-height: 100%; min-height: 100%;
} }
@@ -56,9 +120,9 @@ body {
color: var(--ink-0); color: var(--ink-0);
background: radial-gradient( background: radial-gradient(
circle at 20% 10%, circle at 20% 10%,
#dce9d6 0%, var(--bg-grad-a) 0%,
var(--bg-0) 50%, var(--bg-grad-b) 50%,
#eff3ea 100% var(--bg-grad-c) 100%
); );
line-height: 1.45; line-height: 1.45;
} }
@@ -127,6 +191,74 @@ select {
color: var(--danger); color: var(--danger);
} }
.sortable-list {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--line-soft);
border-radius: 4px;
}
.sortable-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--line-soft);
cursor: grab;
transition: background 0.15s;
}
.sortable-item:last-child {
border-bottom: none;
}
.sortable-item:hover {
background: var(--panel-2);
}
.sortable-item.dragging {
opacity: 0.4;
}
.drag-handle {
color: var(--ink-soft);
font-size: 0.8rem;
letter-spacing: 1px;
cursor: grab;
user-select: none;
}
.sortable-index {
font-size: 0.7rem;
color: var(--ink-soft);
min-width: 1.5rem;
text-align: right;
}
.sortable-path {
flex: 1;
font-size: 0.8rem;
color: var(--ink-0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-warnings {
background: rgba(212, 150, 36, 0.1);
border: 1px solid var(--warn);
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-radius: 4px;
}
.warning-msg {
font-size: 0.8rem;
color: var(--warn);
line-height: 1.5;
}
.btn { .btn {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-1); border-radius: var(--radius-1);
@@ -172,13 +304,13 @@ select {
} }
.btn-danger { .btn-danger {
background: #fff3f6; background: var(--danger-soft);
color: var(--danger); color: var(--danger);
border-color: #f3c0ca; border-color: color-mix(in srgb, var(--danger) 45%, transparent);
} }
.btn-danger:hover { .btn-danger:hover {
background: #ffe8ee; background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
} }
.btn-block { .btn-block {
@@ -198,13 +330,13 @@ select {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-1); border-radius: var(--radius-1);
background: #fff; background: var(--input-bg);
color: var(--ink-0); color: var(--ink-0);
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
} }
.text-input::placeholder { .text-input::placeholder {
color: #74817b; color: var(--placeholder);
} }
.text-area { .text-area {
@@ -239,7 +371,7 @@ select {
.data-table td { .data-table td {
text-align: left; text-align: left;
padding: 0.72rem 0.65rem; padding: 0.72rem 0.65rem;
border-bottom: 1px solid #dfe7de; border-bottom: 1px solid var(--line-soft);
vertical-align: top; vertical-align: top;
} }
@@ -279,34 +411,44 @@ select {
.lv-body { .lv-body {
min-height: 100vh; min-height: 100vh;
padding: var(--space-5); width: 100vw;
height: 100vh;
padding: 0;
overflow: hidden;
} }
.viewer-shell { .viewer-shell {
width: min(1250px, 100%); width: 100vw;
margin: 0 auto; height: 100vh;
margin: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); grid-template-rows: auto minmax(0, 1fr);
background: linear-gradient(170deg, #f7fbf2 0%, #ebf2e7 40%, #f8fbf5 100%); background: linear-gradient(
170deg,
color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%,
var(--panel) 45%,
color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100%
);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-3); border-radius: 0;
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
overflow: hidden; overflow: hidden;
} }
.viewer-main { .viewer-content {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
min-height: 76vh; min-height: 0;
height: 100%;
} }
.viewer-header, .viewer-header {
.viewer-footer { padding: var(--space-3) var(--space-4);
padding: var(--space-5);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
border-bottom: 1px solid var(--line);
} }
.viewer-brand-group { .viewer-brand-group {
@@ -318,14 +460,18 @@ select {
.brand-badge { .brand-badge {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: 0.9rem; border-radius: 0;
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 800; font-weight: 800;
font-size: 0.95rem; font-size: 0.95rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #fff; color: var(--brand-contrast);
background: linear-gradient(140deg, #0a9396 0%, #005f73 100%); background: linear-gradient(
140deg,
var(--badge-start) 0%,
var(--badge-end) 100%
);
} }
.brand-text, .brand-text,
@@ -346,30 +492,49 @@ select {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
border-radius: 999px; border-radius: 0;
background: #fff; background: var(--panel);
border: 1px solid #c9d6c8; border: 1px solid var(--line);
color: var(--ink-1); color: var(--ink-1);
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
} }
.live-dot, .live-dot,
.chat-live-marker { .chat-live-marker {
color: #cb271a; color: var(--live-dot);
font-weight: 800; font-weight: 800;
} }
.player-stage { .player-stage {
position: relative; min-height: 0;
padding: 0 var(--space-5) var(--space-5); height: 100%;
padding: var(--space-4);
display: grid;
} }
.player { .player {
width: 100%; width: 100%;
border-radius: 1.1rem; height: 100%;
border: 1px solid #bfd0be; border-radius: 0;
border: 1px solid var(--line-soft);
background: #000; background: #000;
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
object-fit: contain;
}
.now-playing-overlay {
padding: 0.5rem 0.75rem;
background: var(--panel);
border: 1px solid var(--line-soft);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--ink-0);
}
.np-next {
color: var(--ink-soft);
} }
.now-playing-overlay { .now-playing-overlay {
@@ -382,17 +547,17 @@ select {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
background: rgba(0, 16, 19, 0.84); background: var(--overlay-bg);
color: #f0fffb; color: #f0fffb;
padding: 0.44rem 0.72rem; padding: 0.44rem 0.72rem;
border-radius: 999px; border-radius: 0;
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.live-label { .live-label {
background: #d62828; background: var(--live-dot);
color: #fff; color: #fff;
border-radius: 999px; border-radius: 0;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
@@ -402,23 +567,16 @@ select {
font-size: 0.86rem; font-size: 0.86rem;
} }
.room-links { .chat-toggle-btn {
display: flex; white-space: nowrap;
flex-wrap: wrap;
gap: var(--space-3);
}
.room-links a {
text-decoration: none;
font-weight: 650;
} }
.chat-sidebar { .chat-sidebar {
background: #f0f6ee; background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
min-height: 76vh; min-height: 0;
} }
.chat-header, .chat-header,
@@ -430,7 +588,7 @@ select {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid #d4dfd3; border-bottom: 1px solid var(--line-soft);
gap: var(--space-3); gap: var(--space-3);
} }
@@ -449,6 +607,35 @@ select {
gap: var(--space-2); gap: var(--space-2);
} }
.presence-panel {
padding: 0.5rem 1rem;
border-top: 1px solid var(--line-soft);
max-height: 120px;
overflow-y: auto;
}
.presence-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.presence-dot {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--ink-1);
}
.presence-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ok);
flex-shrink: 0;
}
.message-row { .message-row {
display: flex; display: flex;
} }
@@ -467,28 +654,28 @@ select {
.message-bubble { .message-bubble {
max-width: 83%; max-width: 83%;
border-radius: 1rem; border-radius: 0;
padding: 0.58rem 0.72rem; padding: 0.58rem 0.72rem;
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
animation: messageIn var(--fast); animation: messageIn var(--fast);
} }
.message-other { .message-other {
background: #ffffff; background: var(--panel);
border: 1px solid #d3e0d2; border: 1px solid var(--line-soft);
} }
.message-self { .message-self {
background: #005f73; background: var(--brand);
color: #fff; color: var(--brand-contrast);
} }
.message-system { .message-system {
font-size: 0.83rem; font-size: 0.83rem;
color: #1d4441; color: var(--ink-1);
background: #d9efea; background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
border: 1px solid #b5ddd6; border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
border-radius: 999px; border-radius: 0;
padding: 0.24rem 0.68rem; padding: 0.24rem 0.68rem;
animation: messageIn var(--fast); animation: messageIn var(--fast);
} }
@@ -524,16 +711,17 @@ select {
.input { .input {
width: 100%; width: 100%;
border: 1px solid #c2d2c1; border: 1px solid var(--input-border);
border-radius: var(--radius-1); border-radius: var(--radius-1);
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
background: #fff; background: var(--input-bg);
color: var(--ink-0); color: var(--ink-0);
} }
.admin-body { .admin-body {
min-height: 100vh; min-height: 100vh;
padding: var(--space-4); padding: var(--space-4);
background: #000000;
} }
.admin-shell { .admin-shell {
@@ -544,14 +732,14 @@ select {
border-radius: var(--radius-3); border-radius: var(--radius-3);
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #f7fbf3; background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
min-height: calc(100vh - 2 * var(--space-4)); min-height: calc(100vh - 2 * var(--space-4));
} }
.admin-sidebar { .admin-sidebar {
background: #16423d; background: var(--bg-2);
color: #ecfff6; color: var(--ink-0);
display: grid; display: grid;
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
gap: var(--space-4); gap: var(--space-4);
@@ -579,7 +767,7 @@ select {
width: 100%; width: 100%;
text-align: left; text-align: left;
background: transparent; background: transparent;
color: #ddf8ee; color: var(--ink-1);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-1); border-radius: var(--radius-1);
padding: 0.62rem 0.72rem; padding: 0.62rem 0.72rem;
@@ -594,9 +782,9 @@ select {
} }
.tab-trigger.is-active { .tab-trigger.is-active {
background: #0a9396; background: var(--brand);
color: #fff; color: var(--brand-contrast);
border-color: #89d1c8; border-color: color-mix(in srgb, var(--brand) 55%, #fff);
} }
.admin-main { .admin-main {
@@ -604,6 +792,14 @@ select {
overflow: auto; overflow: auto;
} }
.viewer-shell.chat-collapsed .viewer-content {
grid-template-columns: 1fr;
}
.viewer-shell.chat-collapsed .chat-sidebar {
display: none;
}
.tab-panel { .tab-panel {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
@@ -622,9 +818,9 @@ select {
} }
.panel-card { .panel-card {
border: 1px solid #d8e2d8; border: 1px solid var(--line-soft);
border-radius: var(--radius-2); border-radius: var(--radius-2);
background: #fff; background: var(--panel);
padding: var(--space-4); padding: var(--space-4);
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
} }
@@ -636,10 +832,10 @@ select {
} }
.upload-dropzone { .upload-dropzone {
border: 2px dashed #97b8aa; border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
border-radius: var(--radius-2); border-radius: var(--radius-2);
padding: var(--space-5); padding: var(--space-5);
background: #effaf5; background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
display: grid; display: grid;
justify-items: start; justify-items: start;
gap: var(--space-2); gap: var(--space-2);
@@ -647,7 +843,7 @@ select {
.upload-dropzone.is-dragover { .upload-dropzone.is-dragover {
border-color: var(--brand); border-color: var(--brand);
background: #e6f6ef; background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
} }
.upload-progress { .upload-progress {
@@ -668,9 +864,9 @@ select {
} }
.stat-card { .stat-card {
border: 1px solid #dce7db; border: 1px solid var(--line-soft);
border-radius: var(--radius-2); border-radius: var(--radius-2);
background: #fff; background: var(--panel);
padding: var(--space-4); padding: var(--space-4);
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
} }
@@ -704,7 +900,7 @@ select {
} }
.service-row { .service-row {
border: 1px solid #dbe5db; border: 1px solid var(--line-soft);
border-radius: var(--radius-1); border-radius: var(--radius-1);
padding: 0.75rem; padding: 0.75rem;
display: flex; display: flex;
@@ -748,9 +944,9 @@ select {
} }
.auth-card { .auth-card {
border: 1px solid #cad8c9; border: 1px solid var(--line);
border-radius: var(--radius-3); border-radius: var(--radius-3);
background: #fff; background: var(--panel);
box-shadow: var(--shadow-2); box-shadow: var(--shadow-2);
padding: var(--space-6); padding: var(--space-6);
display: grid; display: grid;
@@ -781,11 +977,15 @@ select {
} }
@media (max-width: 1080px) { @media (max-width: 1080px) {
.viewer-shell,
.admin-shell { .admin-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.viewer-content {
grid-template-columns: 1fr;
grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
}
.chat-sidebar { .chat-sidebar {
border-left: 0; border-left: 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -821,16 +1021,15 @@ select {
} }
.viewer-header, .viewer-header,
.viewer-footer,
.player-stage, .player-stage,
.chat-header, .chat-header,
.chat-input-area { .chat-input-area {
padding: var(--space-3); padding: var(--space-3);
} }
.viewer-footer { .viewer-header {
align-items: flex-start;
flex-direction: column; flex-direction: column;
align-items: start;
} }
.admin-nav { .admin-nav {
@@ -848,6 +1047,10 @@ select {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.viewer-shell.chat-collapsed .viewer-content {
grid-template-rows: 1fr;
}
.auth-card { .auth-card {
padding: var(--space-5); padding: var(--space-5);
} }
+38 -3
View File
@@ -24,6 +24,9 @@ http {
listen 80 default_server; listen 80 default_server;
server_name linearvoid.tv localhost 127.0.0.1; server_name linearvoid.tv localhost 127.0.0.1;
# Resumable admin upload chunks are 8MB by default; allow safe headroom.
client_max_body_size 64m;
root /srv/frontend; root /srv/frontend;
index index.html; index index.html;
@@ -157,18 +160,36 @@ http {
} }
location = /hls/playlist.m3u8 { location = /hls/playlist.m3u8 {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
return 204;
}
alias /srv/hls/playlist.m3u8; alias /srv/hls/playlist.m3u8;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
location = /hls/init.mp4 { location = /hls/init.mp4 {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
return 204;
}
alias /srv/hls/init.mp4; alias /srv/hls/init.mp4;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
location ~* ^/hls/(.+\.(ts|m4s))$ { location ~* ^/hls/(.+\.(ts|m4s))$ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
return 204;
}
alias /srv/hls/$1; alias /srv/hls/$1;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
@@ -176,15 +197,29 @@ http {
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
location /hls/ { location /hls/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
return 204;
}
alias /srv/hls/; alias /srv/hls/;
add_header Cache-Control "public, max-age=60"; add_header Cache-Control "public, max-age=60";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
# Health checks # Health checks — aggregated from upstream services
location /health { location /health {
return 200 "healthy"; default_type application/json;
add_header Content-Type text/plain; return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
}
location /nginx-status {
stub_status on;
allow 127.0.0.1;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
} }
} }
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: "default"
orgId: 1
folder: ""
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+74
View File
@@ -0,0 +1,74 @@
groups:
- name: linearvoid
rules:
- alert: ChatDown
expr: up{job="chat"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Chat service is down"
description: "Chat service has been unreachable for 2 minutes."
- alert: PlayoutDown
expr: up{job="playout"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Playout service is down"
description: "Playout service has been unreachable for 2 minutes."
- alert: RedisDown
expr: linearvoid_chat_redis_up == 0
for: 2m
labels:
severity: warning
annotations:
summary: "Redis is unreachable"
description: "Chat service cannot reach Redis. Rate limiting and presence tracking degraded."
- alert: PlayoutSlateActive
expr: linearvoid_playout_mode == 1
for: 1m
labels:
severity: warning
annotations:
summary: "Playout in slate fallback mode"
description: "Playout has switched to emergency slate. Check playout logs."
- alert: HighChatLatency
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
for: 5m
labels:
severity: warning
annotations:
summary: "Unusually low chat message rate"
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
- alert: PrometheusWALFailing
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Prometheus WAL writes failing"
description: "Prometheus TSDB WAL write failures detected."
- alert: HighCPU
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m
labels:
severity: warning
annotations:
summary: "High CPU usage"
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
- alert: LowDiskSpace
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
for: 5m
labels:
severity: warning
annotations:
summary: "Low disk space"
description: "Less than 10% disk space available on {{ $labels.instance }}."
+18
View File
@@ -1,10 +1,28 @@
global: global:
scrape_interval: 15s scrape_interval: 15s
rule_files:
- "/etc/prometheus/alerts.yml"
scrape_configs: scrape_configs:
- job_name: "chat" - job_name: "chat"
static_configs: static_configs:
- targets: ["chat:3000"] - targets: ["chat:3000"]
- job_name: "playout"
static_configs:
- targets: ["playout:8081"]
- job_name: "nginx"
static_configs:
- targets: ["nginx-exporter:9113"]
- job_name: "redis"
static_configs:
- targets: ["redis-exporter:9121"]
- job_name: "postgres"
static_configs:
- targets: ["postgres-exporter:9187"]
- job_name: "node"
static_configs:
- targets: ["node-exporter:9100"]
- job_name: "prometheus" - job_name: "prometheus"
static_configs: static_configs:
- targets: ["prometheus:9090"] - targets: ["prometheus:9090"]
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Daily PostgreSQL backup to R2 (14-day retention)
# Schedule: cron daily. Requires: pg_dump, aws CLI or rclone for R2.
set -e
BACKUP_DIR="${BACKUP_DIR:-/tmp/linearvoid-backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
DB_URL="${DATABASE_URL:-postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable}"
TIMESTAMP=$(date +%Y%m%d)
BACKUP_FILE="$BACKUP_DIR/daily/db_${TIMESTAMP}.dump"
mkdir -p "$(dirname "$BACKUP_FILE")"
echo "[$(date -Iseconds)] Starting daily backup..."
# Extract connection details from DATABASE_URL
DB_HOST=$(echo "$DB_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
DB_PORT=$(echo "$DB_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
DB_USER=$(echo "$DB_URL" | sed -n 's/.*:\/\/\([^:]*\).*/\1/p')
DB_NAME=$(echo "$DB_URL" | sed -n 's/.*\/\([^?]*\).*/\1/p')
DB_PASS=$(echo "$DB_URL" | sed -n 's/.*:\/\/[^:]*:\([^@]*\).*/\1/p')
export PGPASSWORD="${DB_PASS}"
# Dump
pg_dump -h "${DB_HOST:-db}" -p "${DB_PORT:-5432}" -U "${DB_USER:-linearvoid}" \
-Fc -f "$BACKUP_FILE" "${DB_NAME:-linearvoid}"
echo " Backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
# Upload to R2
if [ -n "$R2_ENDPOINT" ] && [ -n "$R2_ACCESS_KEY_ID" ]; then
aws s3 cp "$BACKUP_FILE" "s3://${R2_BUCKET}/daily/db_${TIMESTAMP}.dump" \
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null && \
echo " Uploaded to R2: daily/db_${TIMESTAMP}.dump" || \
echo " WARNING: R2 upload failed"
fi
# Cleanup old backups
find "$BACKUP_DIR/daily" -name "db_*.dump" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
echo " Cleaned up backups older than ${RETENTION_DAYS} days"
echo "[$(date -Iseconds)] Daily backup complete."
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Weekly full PostgreSQL backup to R2 (8-week retention)
# Schedule: cron weekly (runs backup-db.sh with weekly prefix)
set -e
BACKUP_DIR="${BACKUP_DIR:-/tmp/linearvoid-backups}"
RETENTION_WEEKS="${RETENTION_WEEKS:-8}"
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
DB_URL="${DATABASE_URL:-postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable}"
TIMESTAMP=$(date +%Y%m%d)
BACKUP_FILE="$BACKUP_DIR/weekly/db_${TIMESTAMP}.dump"
mkdir -p "$(dirname "$BACKUP_FILE")"
echo "[$(date -Iseconds)] Starting weekly full backup..."
DB_HOST=$(echo "$DB_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
DB_PORT=$(echo "$DB_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
DB_USER=$(echo "$DB_URL" | sed -n 's/.*:\/\/\([^:]*\).*/\1/p')
DB_NAME=$(echo "$DB_URL" | sed -n 's/.*\/\([^?]*\).*/\1/p')
DB_PASS=$(echo "$DB_URL" | sed -n 's/.*:\/\/[^:]*:\([^@]*\).*/\1/p')
export PGPASSWORD="${DB_PASS}"
pg_dump -h "${DB_HOST:-db}" -p "${DB_PORT:-5432}" -U "${DB_USER:-linearvoid}" \
-Fc -f "$BACKUP_FILE" "${DB_NAME:-linearvoid}"
echo " Backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
# Verify integrity
pg_restore --list "$BACKUP_FILE" > /dev/null 2>&1 && \
echo " Integrity check: PASSED" || \
echo " WARNING: Integrity check FAILED"
# Upload
if [ -n "$R2_ENDPOINT" ] && [ -n "$R2_ACCESS_KEY_ID" ]; then
aws s3 cp "$BACKUP_FILE" "s3://${R2_BUCKET}/weekly/db_${TIMESTAMP}.dump" \
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null && \
echo " Uploaded to R2: weekly/db_${TIMESTAMP}.dump" || \
echo " WARNING: R2 upload failed"
fi
# Cleanup
find "$BACKUP_DIR/weekly" -name "db_*.dump" -mtime "+$((RETENTION_WEEKS * 7))" -delete 2>/dev/null || true
echo " Cleaned up backups older than ${RETENTION_WEEKS} weeks"
echo "[$(date -Iseconds)] Weekly backup complete."
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Generate emergency slate video + pre-rendered HLS segments for failover.
# Output: data/slate/ (slate MP4 + HLS segments + manifest)
# Requires: ffmpeg
set -e
SLATE_DIR="${1:-data/slate}"
mkdir -p "$SLATE_DIR"
SLATE_MP4="$SLATE_DIR/slate.mp4"
SLATE_HLS="$SLATE_DIR/hls"
mkdir -p "$SLATE_HLS"
echo "=== Generating Emergency Slate ==="
# 1. Generate 10s slate video: color bars + "We'll Be Right Back" text
echo "[1/3] Generating slate video (10s, H.264/AAC, 30fps, 2s keyframes)..."
ffmpeg -y -f lavfi -i "color=c=#1a1a2e:s=1280x720:d=10:r=30" \
-f lavfi -i "sine=frequency=440:duration=10" \
-filter_complex "[0]drawtext=text='Linear Void':fontcolor=#587792:fontsize=60:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='We'\''ll Be Right Back':fontcolor=#ede8e6:fontsize=36:x=(w-text_w)/2:y=(h-text_h)/2+30[bg];[1]volume=0.15[a]" \
-map "[bg]" -map "[a]" \
-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 128k -ar 48000 -ac 2 \
-movflags +faststart \
-t 10 \
"$SLATE_MP4"
echo " Slate MP4: $(du -h "$SLATE_MP4" | cut -f1)"
# 2. Verify slate meets normalization profile
echo "[2/3] Verifying slate normalization..."
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate \
-of default=noprint_wrappers=1 "$SLATE_MP4" 2>&1 | head -10
# 3. Package as pre-rendered HLS (fMP4, 6s segments)
echo "[3/3] Packaging as HLS..."
ffmpeg -y -i "$SLATE_MP4" \
-c copy \
-f hls \
-hls_time 6 \
-hls_list_size 5 \
-hls_segment_type fmp4 \
-hls_fmp4_init_filename "init.mp4" \
-hls_flags "independent_segments" \
-hls_segment_filename "$SLATE_HLS/slate_segment_%03d.m4s" \
"$SLATE_HLS/slate.m3u8"
echo ""
echo "=== Slate Generated ==="
echo " MP4: $SLATE_MP4"
echo " Manifest: $SLATE_HLS/slate.m3u8"
echo " Segments: $(find "$SLATE_HLS" -name '*.m4s' | wc -l) files"
echo " Init: $SLATE_HLS/init.mp4"
echo ""
echo "To use: mount $SLATE_HLS as /app/slate in playout container"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Normalize video to Linear Void required profile.
# Output: H.264/AAC, 30fps constant, 2s keyframes, yuv420p, 48kHz audio.
# Usage: ./normalize.sh <input> [output]
set -e
INPUT="$1"
if [ -z "$INPUT" ]; then
echo "Usage: $0 <input-file> [output-file]"
echo " Normalizes video to Linear Void profile: H.264/AAC, 30fps, 2s keyframes"
exit 1
fi
if [ ! -f "$INPUT" ]; then
echo "Error: input file not found: $INPUT"
exit 1
fi
OUTPUT="${2:-${INPUT%.*}-normalized.mp4}"
CRF="${NORMALIZE_CRF:-21}"
PRESET="${NORMALIZE_PRESET:-medium}"
AUDIO_BITRATE="${NORMALIZE_AUDIO_BITRATE:-160k}"
echo "=== Linear Void Video Normalization ==="
echo " Input: $INPUT ($(du -h "$INPUT" | cut -f1))"
echo " Output: $OUTPUT"
echo " CRF: $CRF"
echo " Preset: $PRESET"
echo " Audio rate: $AUDIO_BITRATE"
echo ""
# Probe input
echo "--- Input Properties ---"
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
-of default=noprint_wrappers=1 "$INPUT" 2>&1
echo ""
# Normalize
echo "--- Normalizing ---"
ffmpeg -y -i "$INPUT" \
-c:v libx264 -preset "$PRESET" -crf "$CRF" \
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-pix_fmt yuv420p \
-c:a aac -b:a "$AUDIO_BITRATE" -ar 48000 -ac 2 \
-movflags +faststart \
"$OUTPUT"
echo ""
echo "--- Output Properties ---"
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,sample_rate,channels \
-of default=noprint_wrappers=1 "$OUTPUT" 2>&1
echo ""
echo "=== Normalization Complete ==="
echo " Output: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
echo ""
echo "Verify with: ffprobe -v error -show_entries stream=codec_name,r_frame_rate,pix_fmt -of default=noprint_wrappers=1 $OUTPUT"
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# End-to-end playout test: single file → 3-file playlist → verify seamless transition
# Requires: Docker, ffprobe, curl. Run from repo root.
set -e
echo "=== Playout End-to-End Test ==="
# 1. Build playout image
echo "[1/7] Building playout image..."
docker compose build playout
# 2. Create test playlist with 3 demo files (reuse demodata if exists)
TEST_PLAYLIST="data/playlist/playlist.txt"
mkdir -p data/playlist data/hls
echo "[2/7] Creating 3-file test playlist..."
cat > "$TEST_PLAYLIST" << 'EOF'
file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
file '/app/media/demo1.mp4'
EOF
echo "Playlist created with 3 entries:"
cat "$TEST_PLAYLIST"
# 3. Validate playlist parses correctly
echo "[3/7] Validating playlist parse..."
LINE_COUNT=$(grep -c "^file " "$TEST_PLAYLIST" || true)
if [ "$LINE_COUNT" -ne 3 ]; then
echo "FAIL: Expected 3 entries, got $LINE_COUNT"
exit 1
fi
echo "OK: $LINE_COUNT entries parsed"
# 4. Start playout container
echo "[4/7] Starting playout container..."
docker compose up -d playout
sleep 8
# 5. Verify health endpoint
echo "[5/7] Checking playout health..."
HEALTH=$(curl -sf http://localhost:8081/health || echo "FAIL")
if echo "$HEALTH" | grep -q "healthy"; then
echo "OK: Health endpoint: $HEALTH"
else
echo "FAIL: Health check returned: $HEALTH"
docker compose logs playout --tail 30
exit 1
fi
# 6. Verify HLS output
echo "[6/7] Verifying HLS output..."
sleep 10 # Let FFmpeg produce some segments
if [ -f "data/hls/playlist.m3u8" ]; then
echo "OK: playlist.m3u8 exists"
MANIFEST_LINES=$(wc -l < data/hls/playlist.m3u8)
echo " Manifest lines: $MANIFEST_LINES"
else
echo "FAIL: No playlist.m3u8 generated"
docker compose logs playout --tail 30
exit 1
fi
SEGMENT_COUNT=$(find data/hls -name "segment_*.m4s" | wc -l)
if [ "$SEGMENT_COUNT" -ge 1 ]; then
echo "OK: $SEGMENT_COUNT segment(s) generated"
else
echo "FAIL: No segments generated"
exit 1
fi
# 7. Check for seamless transition markers (no FFmpeg errors in logs)
echo "[7/7] Checking for seamless transitions..."
ERRORS=$(docker compose logs playout 2>&1 | grep -ci "error" || true)
if [ "$ERRORS" -eq 0 ]; then
echo "OK: No FFmpeg errors in logs"
else
echo "WARN: $ERRORS error(s) in playout logs (may be R2 connection — check manually)"
fi
echo ""
echo "=== Playout E2E Test PASSED ==="
echo "Verified: playlist parse → HLS generation → health endpoint → segment output"
echo ""
echo "Cleanup: docker compose down playout"
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# Weekly backup integrity verification.
# Downloads latest backup from R2, restores into temp container, runs sanity checks.
# Schedule: cron weekly (after weekly backup).
set -e
R2_BUCKET="${R2_BUCKET:-linearvoid-backups}"
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
echo "[$(date -Iseconds)] Starting backup integrity verification..."
# Download latest weekly backup from R2
if [ -z "$R2_ENDPOINT" ] || [ -z "$R2_ACCESS_KEY_ID" ]; then
echo " SKIP: R2 credentials not configured (R2_ENDPOINT, R2_ACCESS_KEY_ID)"
exit 0
fi
LATEST=$(aws s3 ls "s3://${R2_BUCKET}/weekly/" --endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null | sort | tail -1 | awk '{print $4}')
if [ -z "$LATEST" ]; then
echo " SKIP: No weekly backups found in R2"
exit 0
fi
echo " Downloading: $LATEST"
aws s3 cp "s3://${R2_BUCKET}/weekly/${LATEST}" "$TEMP_DIR/backup.dump" \
--endpoint-url "$R2_ENDPOINT" --no-verify-ssl 2>/dev/null
echo " File size: $(du -h "$TEMP_DIR/backup.dump" | cut -f1)"
# Restore into temp PostgreSQL container
echo " Starting temp PostgreSQL..."
docker run -d --name lv-backup-test-$$ \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=linearvoid \
-p 15432:5432 \
postgres:15-alpine 2>/dev/null
sleep 5
# Wait for PG to be ready
for i in $(seq 1 10); do
if docker exec lv-backup-test-$$ pg_isready -U postgres 2>/dev/null; then
break
fi
sleep 2
done
# Restore
echo " Restoring backup..."
docker cp "$TEMP_DIR/backup.dump" "lv-backup-test-$$:/tmp/backup.dump"
docker exec lv-backup-test-$$ pg_restore -U postgres -d linearvoid /tmp/backup.dump 2>/dev/null && \
echo " Restore: OK" || { echo " Restore: FAILED"; docker rm -f lv-backup-test-$$ 2>/dev/null; exit 1; }
# Sanity checks
MSG_COUNT=$(docker exec lv-backup-test-$$ psql -U postgres -d linearvoid -tAc "SELECT COUNT(*) FROM messages;" 2>/dev/null || echo "0")
MOD_COUNT=$(docker exec lv-backup-test-$$ psql -U postgres -d linearvoid -tAc "SELECT COUNT(*) FROM moderation_logs;" 2>/dev/null || echo "0")
echo " Messages: $MSG_COUNT, Moderation: $MOD_COUNT"
# Cleanup
docker rm -f lv-backup-test-$$ 2>/dev/null
echo "[$(date -Iseconds)] Backup verification complete. OK."