Compare commits
11
Commits
dd5789fcd9
...
1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a34a4bd5 | ||
|
|
ee16441b79 | ||
|
|
74133ec589 | ||
|
|
f650a3a94e | ||
|
|
c10d98f3b4 | ||
|
|
207401b5fb | ||
|
|
d4d7806189 | ||
|
|
497f7743f2 | ||
|
|
2c06265d11 | ||
|
|
930cdfdff9 | ||
|
|
ab3fab3e68 |
No files matched your search
+27
-8
@@ -73,23 +73,42 @@ jobs:
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
# Wait for playout to stabilize (it may restart during init)
|
||||
# Wait for services to stabilize
|
||||
sleep 20
|
||||
curl -f http://192.168.88.40:3000/health || exit 1
|
||||
|
||||
# Check chat health
|
||||
echo "Checking chat health..."
|
||||
if ! curl -f http://192.168.88.40: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 ! curl -f http://192.168.88.40:8080/; 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
|
||||
echo "Checking playout health..."
|
||||
for i in $(seq 1 6); do
|
||||
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||
echo "Playout is healthy"
|
||||
exit 0
|
||||
break
|
||||
fi
|
||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Playout failed to become healthy after 6 attempts"
|
||||
docker logs linearvoid-playout --tail 50
|
||||
exit 1
|
||||
curl -f http://192.168.88.40:3000/health || exit 1
|
||||
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
|
||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||
echo "Playout failed to become healthy after 6 attempts"
|
||||
docker logs linearvoid-playout --tail 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All services healthy"
|
||||
|
||||
- name: Cleanup old images
|
||||
env:
|
||||
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
with:
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
backend/playout/go.sum
|
||||
backend/chat/go.sum
|
||||
|
||||
- name: Validate Playlist Normalization
|
||||
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
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
|
||||
|
||||
- Use the architecture documentation (arc42 template and ADRs) as the source of truth for design decisions.
|
||||
- Follow the project folder structure defined in ADR-002 and reflected in the root README.
|
||||
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
||||
- Follow the project folder structure in the root README.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# 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
|
||||
|
||||
- 200/201: successful request
|
||||
- 400: malformed payload, invalid input, missing required fields
|
||||
- 401: unauthorized admin request
|
||||
- 404: not found
|
||||
- 405: method not allowed
|
||||
- 409: logical conflict (for example, mismatched resumed upload)
|
||||
- 413: payload too large
|
||||
- 500: internal server error
|
||||
+21
-14
@@ -2,31 +2,38 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## Unreleased
|
||||
## [1.0.5] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006).
|
||||
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section).
|
||||
- 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.
|
||||
- `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
|
||||
|
||||
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback.
|
||||
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload.
|
||||
- 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.
|
||||
- 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.
|
||||
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
|
||||
- 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
|
||||
|
||||
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup.
|
||||
- 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 Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
|
||||
- Go module cache in CI now correctly references `backend/playout/go.sum` and `backend/chat/go.sum`.
|
||||
- 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
|
||||
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
- Proxy-layer body size limits returning 413 on upload.
|
||||
- Health checks failing before service warmup is complete.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Monitoring Guide
|
||||
|
||||
This guide covers Prometheus and Grafana setup for Linear Void.
|
||||
|
||||
## Current Monitoring Components
|
||||
|
||||
- Prometheus container: linearvoid-prometheus
|
||||
- Grafana container: linearvoid-grafana
|
||||
- Metrics target: chat service /metrics
|
||||
- Base scrape config: monitoring/prometheus/prometheus.yml
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
Current scrape config:
|
||||
|
||||
- Job chat -> chat:3000
|
||||
- Job prometheus -> prometheus:9090
|
||||
- Global scrape interval: 15s
|
||||
|
||||
File:
|
||||
|
||||
- monitoring/prometheus/prometheus.yml
|
||||
|
||||
## Start Monitoring Stack
|
||||
|
||||
With compose stack up:
|
||||
|
||||
- Prometheus UI: <http://localhost:9090>
|
||||
- Grafana UI: <http://localhost:3001>
|
||||
|
||||
Compose services include prometheus and grafana in both docker-compose.yml and docker-compose.prod.yml.
|
||||
|
||||
## Grafana Provisioning
|
||||
|
||||
Current mount paths:
|
||||
|
||||
- Dashboards directory: monitoring/grafana/dashboards
|
||||
- Datasources directory: monitoring/grafana/datasources
|
||||
|
||||
Notes:
|
||||
|
||||
- The dashboards directory includes a Prometheus overview dashboard file.
|
||||
- Datasource provisioning can be done with a YAML file in monitoring/grafana/datasources, or by configuring a datasource in Grafana UI.
|
||||
|
||||
Example datasource provisioning file (create if needed):
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
```
|
||||
|
||||
## Dashboard References
|
||||
|
||||
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
|
||||
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
|
||||
|
||||
Grafana screenshot:
|
||||
|
||||

|
||||
|
||||
## Key Metrics to Track
|
||||
|
||||
### Chat/Application
|
||||
|
||||
- linearvoid_chat_connected_clients
|
||||
- linearvoid_chat_stored_messages_total
|
||||
- linearvoid_chat_moderation_actions_total
|
||||
- linearvoid_chat_redis_enabled
|
||||
- linearvoid_chat_redis_up
|
||||
- linearvoid_chat_active_admin_sessions
|
||||
|
||||
### Prometheus Runtime
|
||||
|
||||
- up
|
||||
- process_cpu_seconds_total
|
||||
- process_resident_memory_bytes
|
||||
- prometheus_tsdb_head_series
|
||||
- prometheus_tsdb_wal_writes_failed_total
|
||||
- prometheus_tsdb_compactions_failed_total
|
||||
|
||||
## Suggested Alerts
|
||||
|
||||
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
|
||||
|
||||
Recommended baseline alerts:
|
||||
|
||||
- Chat target down for 2m
|
||||
- Prometheus target down for 2m
|
||||
- Redis indicator down for 2m
|
||||
- TSDB WAL/compaction failure rate above zero
|
||||
- Elevated handler/query latency p99
|
||||
|
||||
## Validate Monitoring End-to-End
|
||||
|
||||
1. Verify /metrics through nginx:
|
||||
|
||||
- <https://linearvoid.tv/metrics>
|
||||
|
||||
1. Confirm targets in Prometheus:
|
||||
|
||||
- Status -> Targets page in Prometheus UI
|
||||
|
||||
1. Confirm dashboard data in Grafana:
|
||||
|
||||
- Import or open provisioned dashboards
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No chat metrics in Prometheus
|
||||
|
||||
- Check chat container health and logs.
|
||||
- Confirm /metrics is reachable from Prometheus container.
|
||||
- Verify scrape target is chat:3000 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.
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
## Architecture
|
||||
@@ -204,6 +204,10 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||
- [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.
|
||||
- [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.
|
||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Security Guide
|
||||
|
||||
This document summarizes security controls and best practices for Linear Void.
|
||||
|
||||
## Security Model Overview
|
||||
|
||||
Security is layered across:
|
||||
|
||||
- Cloudflare edge
|
||||
- nginx reverse proxy
|
||||
- chat/admin backend
|
||||
- container and host operations
|
||||
|
||||
## Authentication and Sessions
|
||||
|
||||
Admin access is session-cookie based.
|
||||
|
||||
- Login endpoint: /api/admin/login
|
||||
- Session endpoint: /api/admin/session
|
||||
- Logout endpoint: /api/admin/logout
|
||||
- Cookie: linearvoid_admin_session
|
||||
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
|
||||
- Session expiry enforced server-side
|
||||
|
||||
Best practices:
|
||||
|
||||
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
|
||||
- Rotate admin credentials periodically.
|
||||
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
|
||||
|
||||
## Transport Security
|
||||
|
||||
- Enforce HTTPS for app/admin endpoints.
|
||||
- Use WSS for chat WebSocket.
|
||||
- Keep Cloudflare TLS mode strict and valid at origin.
|
||||
|
||||
## Reverse Proxy Controls
|
||||
|
||||
nginx controls include:
|
||||
|
||||
- auth_request guard for /admin and /api/admin paths
|
||||
- dedicated unauthorized behavior for admin API responses
|
||||
- explicit websocket upgrade headers for /ws
|
||||
- upload body-size limits for resumable media chunk uploads
|
||||
|
||||
Best practices:
|
||||
|
||||
- Keep proxy body-size and timeout settings aligned across all proxy layers.
|
||||
- Avoid exposing backend service ports publicly unless required.
|
||||
|
||||
## Input Validation and Upload Safety
|
||||
|
||||
Server-side protections include:
|
||||
|
||||
- extension allow-list for media uploads
|
||||
- MIME checks
|
||||
- file-size ceiling (2GB)
|
||||
- resumable chunk bounds and chunk-index validation
|
||||
- media path safety checks on delete
|
||||
|
||||
Best practices:
|
||||
|
||||
- Keep upload chunk size bounded.
|
||||
- Reject unsupported content types consistently.
|
||||
- Monitor 4xx/5xx rates on upload routes.
|
||||
|
||||
## Rate Limiting and Abuse Controls
|
||||
|
||||
- Chat rate limiting uses Redis INCR + EXPIRE windows.
|
||||
- Current baseline: more than 10 messages in 10 seconds is throttled.
|
||||
- Moderation actions are logged for auditability.
|
||||
|
||||
Best practices:
|
||||
|
||||
- Tune thresholds by observed abuse patterns.
|
||||
- Keep moderation logs reviewed and retained per policy.
|
||||
|
||||
## Infrastructure and Secrets
|
||||
|
||||
- Store secrets in .env (not in committed source).
|
||||
- Use Gitea Actions secrets for CI/CD credentials and admin credentials.
|
||||
- Restrict Docker host control plane exposure.
|
||||
- Limit who can read deployment environment files.
|
||||
|
||||
## Cloudflare and Edge Security
|
||||
|
||||
Recommended baseline:
|
||||
|
||||
- WAF enabled
|
||||
- Rate limits on sensitive paths
|
||||
- Bot protection where appropriate
|
||||
- Cache rules scoped carefully to avoid caching private/control-plane content
|
||||
|
||||
## Data Protection and Retention
|
||||
|
||||
- Persistent data: postgres, redis append-only, chat state, playlist/media metadata
|
||||
- Backup and restore drills should be periodic
|
||||
- Restore procedures should be tested in isolated environments
|
||||
|
||||
## Logging and Monitoring for Security
|
||||
|
||||
Track and alert on:
|
||||
|
||||
- repeated failed admin login attempts
|
||||
- spikes in 401 and 403 responses
|
||||
- sudden upload errors (413/400/500 patterns)
|
||||
- unusual websocket disconnect or message-rate anomalies
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
- [ ] HTTPS/WSS enforced end-to-end
|
||||
- [ ] Strong admin credentials configured
|
||||
- [ ] Secrets only in env/secrets stores
|
||||
- [ ] Proxy auth and websocket headers validated
|
||||
- [ ] Upload limits and validation active
|
||||
- [ ] Rate limiting active and tested
|
||||
- [ ] Cloudflare WAF/rate-limit rules active
|
||||
- [ ] Backup/restore drills completed
|
||||
- [ ] Security-related monitoring alerts routed
|
||||
|
||||
## Incident Response Baseline
|
||||
|
||||
1. Detect issue (alerts/logs/user reports).
|
||||
2. Triage scope (edge, proxy, app, data plane).
|
||||
3. Contain impact (rate limit, credential rotate, route disable).
|
||||
4. Recover services and verify health.
|
||||
5. Record timeline and corrective actions.
|
||||
+716
-3
@@ -156,6 +156,24 @@ type AdminUsersResponse struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type ChatLogEntry struct {
|
||||
ID uint `json:"id"`
|
||||
Room string `json:"room"`
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ChatLogListResponse struct {
|
||||
Entries []ChatLogEntry `json:"entries"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type ChatLogBulkDeleteRequest struct {
|
||||
IDs []uint `json:"ids"`
|
||||
}
|
||||
|
||||
type ScheduleEntry struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
@@ -195,6 +213,36 @@ type AdminMediaResponse struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type AdminMediaUploadInitRequest struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
MimeType string `json:"mimeType"`
|
||||
ChunkSize int64 `json:"chunkSize"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
}
|
||||
|
||||
type AdminMediaUploadSession struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
MimeType string `json:"mimeType"`
|
||||
ChunkSize int64 `json:"chunkSize"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminMediaUploadStatusResponse struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
ChunkSize int64 `json:"chunkSize"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
UploadedChunks int `json:"uploadedChunks"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
type AdminLoginPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -209,6 +257,12 @@ var databaseDriverName = "sqlite"
|
||||
|
||||
const adminSessionCookieName = "linearvoid_admin_session"
|
||||
|
||||
const (
|
||||
maxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||
defaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||
maxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||
)
|
||||
|
||||
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
|
||||
func initRedis() {
|
||||
redisAddress := os.Getenv("REDIS_URL")
|
||||
@@ -938,6 +992,136 @@ func getMediaIDFromPath(path string) (uint, error) {
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func isAllowedMediaExtension(fileName string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedMediaMime(mime string) bool {
|
||||
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||
if mime == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(mime, "video/") {
|
||||
return true
|
||||
}
|
||||
return mime == "application/octet-stream"
|
||||
}
|
||||
|
||||
func getUploadWorkspaceDir() string {
|
||||
return filepath.Join(getMediaDir(), ".uploads")
|
||||
}
|
||||
|
||||
func getUploadSessionDir(uploadID string) string {
|
||||
return filepath.Join(getUploadWorkspaceDir(), uploadID)
|
||||
}
|
||||
|
||||
func getUploadSessionMetaPath(uploadID string) string {
|
||||
return filepath.Join(getUploadSessionDir(uploadID), "session.json")
|
||||
}
|
||||
|
||||
func getUploadChunkPath(uploadID string, chunkIndex int) string {
|
||||
return filepath.Join(getUploadSessionDir(uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||
}
|
||||
|
||||
func loadUploadSession(uploadID string) (AdminMediaUploadSession, error) {
|
||||
var session AdminMediaUploadSession
|
||||
data, err := os.ReadFile(getUploadSessionMetaPath(uploadID))
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return session, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func saveUploadSession(session AdminMediaUploadSession) error {
|
||||
if err := os.MkdirAll(getUploadSessionDir(session.UploadID), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(getUploadSessionMetaPath(session.UploadID), data, 0o644)
|
||||
}
|
||||
|
||||
func countUploadedChunks(uploadID string, totalChunks int) int {
|
||||
uploaded := 0
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
if _, err := os.Stat(getUploadChunkPath(uploadID, i)); err == nil {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
func validateUploadID(uploadID string) bool {
|
||||
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range uploadID {
|
||||
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildUploadStatus(session AdminMediaUploadSession) AdminMediaUploadStatusResponse {
|
||||
uploadedChunks := countUploadedChunks(session.UploadID, session.TotalChunks)
|
||||
progress := 0.0
|
||||
if session.TotalChunks > 0 {
|
||||
progress = (float64(uploadedChunks) / float64(session.TotalChunks)) * 100
|
||||
}
|
||||
|
||||
return AdminMediaUploadStatusResponse{
|
||||
UploadID: session.UploadID,
|
||||
FileName: session.FileName,
|
||||
FileSize: session.FileSize,
|
||||
ChunkSize: session.ChunkSize,
|
||||
TotalChunks: session.TotalChunks,
|
||||
UploadedChunks: uploadedChunks,
|
||||
Progress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueMediaTargetPath(mediaDir, fileName string) string {
|
||||
baseName := filepath.Base(fileName)
|
||||
ext := filepath.Ext(baseName)
|
||||
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||
candidate := filepath.Join(mediaDir, baseName)
|
||||
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||
}
|
||||
}
|
||||
|
||||
func detectMediaMime(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := f.Read(head)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return http.DetectContentType(head[:n]), nil
|
||||
}
|
||||
|
||||
func mediaEntryFromRecord(record Media) MediaFileEntry {
|
||||
return MediaFileEntry{
|
||||
ID: record.ID,
|
||||
@@ -1020,13 +1204,27 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileName := filepath.Base(strings.TrimSpace(header.Filename))
|
||||
if fileName == "" {
|
||||
http.Error(w, "missing file name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isAllowedMediaExtension(fileName) {
|
||||
http.Error(w, "unsupported file type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if header.Size > maxAdminUploadBytes {
|
||||
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
mediaDir := getMediaDir()
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(mediaDir, filepath.Base(header.Filename))
|
||||
targetPath := uniqueMediaTargetPath(mediaDir, fileName)
|
||||
targetFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
|
||||
@@ -1051,6 +1249,23 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if fileInfo.Size() > maxAdminUploadBytes {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
detectedMime, err := detectMediaMime(targetPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds, err := probeMediaDurationSeconds(targetPath)
|
||||
if err != nil {
|
||||
@@ -1059,10 +1274,10 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := filepath.Base(header.Filename)
|
||||
filename := filepath.Base(targetPath)
|
||||
mediaRecord := Media{
|
||||
Filename: filename,
|
||||
OriginalName: header.Filename,
|
||||
OriginalName: fileName,
|
||||
Path: targetPath,
|
||||
SizeBytes: fileInfo.Size(),
|
||||
DurationSeconds: durationSeconds,
|
||||
@@ -1089,6 +1304,336 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
|
||||
const basePath = "/api/admin/media/uploads"
|
||||
|
||||
if r.URL.Path == basePath {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var payload AdminMediaUploadInitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := filepath.Base(strings.TrimSpace(payload.FileName))
|
||||
if fileName == "" {
|
||||
http.Error(w, "file name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !isAllowedMediaExtension(fileName) {
|
||||
http.Error(w, "unsupported file type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.FileSize <= 0 {
|
||||
http.Error(w, "file size must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.FileSize > maxAdminUploadBytes {
|
||||
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if !isAllowedMediaMime(payload.MimeType) {
|
||||
http.Error(w, "unsupported mime type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
chunkSize := payload.ChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = defaultAdminChunkSize
|
||||
}
|
||||
if chunkSize > maxAdminChunkSize {
|
||||
http.Error(w, "chunk size exceeds server limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
expectedChunks := int((payload.FileSize + chunkSize - 1) / chunkSize)
|
||||
if payload.TotalChunks <= 0 {
|
||||
payload.TotalChunks = expectedChunks
|
||||
}
|
||||
if payload.TotalChunks != expectedChunks {
|
||||
http.Error(w, "total chunk count mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
uploadID := strings.TrimSpace(payload.UploadID)
|
||||
if uploadID != "" && !validateUploadID(uploadID) {
|
||||
http.Error(w, "invalid upload id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
session := AdminMediaUploadSession{}
|
||||
|
||||
if uploadID != "" {
|
||||
loaded, err := loadUploadSession(uploadID)
|
||||
if err == nil {
|
||||
if loaded.FileName != fileName || loaded.FileSize != payload.FileSize {
|
||||
http.Error(w, "upload id does not match selected file", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
session = loaded
|
||||
} else if !os.IsNotExist(err) {
|
||||
http.Error(w, fmt.Sprintf("failed to resume upload session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if session.UploadID == "" {
|
||||
randomID, err := generateSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create upload session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session = AdminMediaUploadSession{
|
||||
UploadID: randomID,
|
||||
FileName: fileName,
|
||||
FileSize: payload.FileSize,
|
||||
MimeType: payload.MimeType,
|
||||
ChunkSize: chunkSize,
|
||||
TotalChunks: payload.TotalChunks,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
} else {
|
||||
session.UpdatedAt = now
|
||||
}
|
||||
|
||||
if err := saveUploadSession(session); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to persist upload session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(buildUploadStatus(session))
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(r.URL.Path, basePath+"/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
remainder := strings.TrimPrefix(r.URL.Path, basePath+"/")
|
||||
parts := strings.Split(strings.Trim(remainder, "/"), "/")
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
http.Error(w, "missing upload id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
uploadID := strings.TrimSpace(parts[0])
|
||||
if !validateUploadID(uploadID) {
|
||||
http.Error(w, "invalid upload id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := loadUploadSession(uploadID)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "upload session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("failed to read upload session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(buildUploadStatus(session))
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
subresource := parts[1]
|
||||
switch subresource {
|
||||
case "chunk":
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
chunkIndexRaw := strings.TrimSpace(r.URL.Query().Get("index"))
|
||||
chunkIndex, err := strconv.Atoi(chunkIndexRaw)
|
||||
if err != nil || chunkIndex < 0 || chunkIndex >= session.TotalChunks {
|
||||
http.Error(w, "invalid chunk index", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
chunkPath := getUploadChunkPath(uploadID, chunkIndex)
|
||||
tmpPath := chunkPath + ".tmp"
|
||||
|
||||
chunkFile, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to open chunk target: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
maxRead := session.ChunkSize + 1
|
||||
written, copyErr := io.Copy(chunkFile, io.LimitReader(r.Body, maxRead))
|
||||
closeErr := chunkFile.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
http.Error(w, "failed to persist upload chunk", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
expectedMax := session.ChunkSize
|
||||
if chunkIndex == session.TotalChunks-1 {
|
||||
remaining := session.FileSize - (int64(chunkIndex) * session.ChunkSize)
|
||||
if remaining > 0 {
|
||||
expectedMax = remaining
|
||||
}
|
||||
}
|
||||
|
||||
if written <= 0 || written > expectedMax {
|
||||
_ = os.Remove(tmpPath)
|
||||
http.Error(w, "invalid chunk size", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, chunkPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
http.Error(w, fmt.Sprintf("failed to finalize chunk: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := saveUploadSession(session); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to update upload session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(buildUploadStatus(session))
|
||||
case "complete":
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if countUploadedChunks(uploadID, session.TotalChunks) != session.TotalChunks {
|
||||
http.Error(w, "upload is incomplete", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mediaDir := getMediaDir()
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := uniqueMediaTargetPath(mediaDir, session.FileName)
|
||||
targetFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var totalWritten int64
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
chunkPath := getUploadChunkPath(uploadID, i)
|
||||
chunkReader, err := os.Open(chunkPath)
|
||||
if err != nil {
|
||||
targetFile.Close()
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("missing chunk %d", i), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
written, err := io.Copy(targetFile, chunkReader)
|
||||
_ = chunkReader.Close()
|
||||
if err != nil {
|
||||
targetFile.Close()
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to assemble media file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
totalWritten += written
|
||||
}
|
||||
|
||||
if err := targetFile.Close(); err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if totalWritten != session.FileSize {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, "assembled file size mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
detectedMime, err := detectMediaMime(targetPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds, err := probeMediaDurationSeconds(targetPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := filepath.Base(targetPath)
|
||||
mediaRecord := Media{
|
||||
Filename: filename,
|
||||
OriginalName: session.FileName,
|
||||
Path: targetPath,
|
||||
SizeBytes: fileInfo.Size(),
|
||||
DurationSeconds: durationSeconds,
|
||||
Status: "ready",
|
||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||
Tags: "",
|
||||
}
|
||||
if err := db.Create(&mediaRecord).Error; err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(getUploadSessionDir(uploadID))
|
||||
|
||||
files, err := listMediaFiles()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) {
|
||||
mediaID, err := getMediaIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
@@ -1407,6 +1952,162 @@ func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminChatLog lists chat log entries with optional filtering and pagination
|
||||
func handleAdminChatLog(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
room := strings.TrimSpace(query.Get("room"))
|
||||
|
||||
limit := 50
|
||||
if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
|
||||
parsedLimit, err := strconv.Atoi(rawLimit)
|
||||
if err != nil || parsedLimit <= 0 {
|
||||
http.Error(w, "invalid limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if parsedLimit > 200 {
|
||||
parsedLimit = 200
|
||||
}
|
||||
limit = parsedLimit
|
||||
}
|
||||
|
||||
dbQuery := db.Model(&Message{})
|
||||
|
||||
if room != "" {
|
||||
dbQuery = dbQuery.Where("room = ?", room)
|
||||
}
|
||||
|
||||
if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" {
|
||||
beforeTime, err := time.Parse(time.RFC3339, beforeRaw)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dbQuery = dbQuery.Where("timestamp < ?", beforeTime)
|
||||
}
|
||||
|
||||
if username := strings.TrimSpace(query.Get("username")); username != "" {
|
||||
dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
|
||||
}
|
||||
|
||||
if text := strings.TrimSpace(query.Get("text")); text != "" {
|
||||
dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := dbQuery.Count(&total).Error; err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var messages []Message
|
||||
if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]ChatLogEntry, len(messages))
|
||||
for i, msg := range messages {
|
||||
entries[i] = ChatLogEntry{
|
||||
ID: msg.ID,
|
||||
Room: msg.Room,
|
||||
Username: msg.Username,
|
||||
Text: msg.Text,
|
||||
Timestamp: msg.Timestamp,
|
||||
Type: msg.Type,
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total})
|
||||
}
|
||||
|
||||
// handleAdminChatLogByID returns or deletes a single chat log entry
|
||||
func handleAdminChatLogByID(w http.ResponseWriter, r *http.Request) {
|
||||
idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/")
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart == "" || strings.Contains(idPart, "/") {
|
||||
http.Error(w, "invalid chat log id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
http.Error(w, "invalid chat log id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
var msg Message
|
||||
if err := db.First(&msg, id).Error; err != nil {
|
||||
http.Error(w, "chat log entry not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ChatLogEntry{
|
||||
ID: msg.ID,
|
||||
Room: msg.Room,
|
||||
Username: msg.Username,
|
||||
Text: msg.Text,
|
||||
Timestamp: msg.Timestamp,
|
||||
Type: msg.Type,
|
||||
})
|
||||
case http.MethodDelete:
|
||||
result := db.Delete(&Message{}, id)
|
||||
if result.Error != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
http.Error(w, "chat log entry not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminChatLogBulk deletes multiple chat log entries by ID
|
||||
func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var payload ChatLogBulkDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.IDs) == 0 {
|
||||
http.Error(w, "ids array is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.IDs) > 500 {
|
||||
http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := db.Delete(&Message{}, payload.IDs)
|
||||
if result.Error != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected})
|
||||
}
|
||||
|
||||
func main() {
|
||||
initRedis()
|
||||
initDB()
|
||||
@@ -1424,7 +2125,12 @@ func main() {
|
||||
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
|
||||
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
|
||||
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
|
||||
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
||||
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
||||
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
||||
http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk))
|
||||
http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID))
|
||||
http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog))
|
||||
http.HandleFunc("/metrics", handleMetrics)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1432,6 +2138,13 @@ func main() {
|
||||
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||
})
|
||||
|
||||
// Catch-all: return 404 JSON for all unhandled paths
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":"not found"}`)
|
||||
})
|
||||
|
||||
port := os.Getenv("CHAT_PORT")
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
|
||||
@@ -18,14 +18,15 @@ RUN apk add --no-cache \
|
||||
bash
|
||||
# Set working directory for playout
|
||||
WORKDIR /app
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/hls /app/media /app/playlists
|
||||
# Copy the playout start script and the built playlist manager binary
|
||||
COPY start-playout.sh /app/
|
||||
# Copy demo data for out-of-the-box playout
|
||||
COPY demodata/ /app/
|
||||
# Copy the built playlist manager binary from the builder stage
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/hls
|
||||
# Set execute permissions for the start script and playlist manager binary
|
||||
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
||||
# Expose HLS directory (will be served by nginx)
|
||||
VOLUME ["/app/hls"]
|
||||
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
start_period: 30s
|
||||
|
||||
nginx:
|
||||
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
||||
|
||||
+438
-189
@@ -4,18 +4,86 @@
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Introduction and Goals](#introduction-and-goals)
|
||||
- [Architecture Constraints](#architecture-constraints)
|
||||
- [Context and Scope](#context-and-scope)
|
||||
- [Solution Strategy](#solution-strategy)
|
||||
- [Building Block View](#building-block-view)
|
||||
- [Runtime View](#runtime-view)
|
||||
- [Deployment View](#deployment-view)
|
||||
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
||||
- [Architecture Decisions](#architecture-decisions)
|
||||
- [Quality Requirements](#quality-requirements)
|
||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||
- [Glossary](#glossary)
|
||||
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Companion Documentation](#companion-documentation)
|
||||
- [Introduction and Goals](#introduction-and-goals)
|
||||
- [Requirements Overview](#requirements-overview)
|
||||
- [Quality Goals](#quality-goals)
|
||||
- [Stakeholders](#stakeholders)
|
||||
- [Architecture Constraints](#architecture-constraints)
|
||||
- [Context and Scope](#context-and-scope)
|
||||
- [Business Context](#business-context)
|
||||
- [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
|
||||
- [Technical Context](#technical-context)
|
||||
- [Solution Strategy](#solution-strategy)
|
||||
- [Building Block View](#building-block-view)
|
||||
- [Whitebox Overall System](#whitebox-overall-system)
|
||||
- [Level 2](#level-2)
|
||||
- [Level 3](#level-3)
|
||||
- [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
|
||||
|
||||
@@ -107,13 +175,6 @@ flowchart TD
|
||||
Chat --> VPS
|
||||
VPS -->|Proxy / Static| Pages
|
||||
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**:
|
||||
@@ -196,7 +257,11 @@ graph TD
|
||||
|
||||
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.
|
||||
|
||||
### Level 3
|
||||
|
||||
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.
|
||||
|
||||
@@ -251,13 +316,6 @@ flowchart LR
|
||||
DOCKER --> DB
|
||||
DOCKER --> PROM
|
||||
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:**
|
||||
@@ -311,15 +369,224 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
|
||||
|
||||
### 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
|
||||
|
||||
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook.
|
||||
Linear Void uses a two-layer monitoring model:
|
||||
|
||||
1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
|
||||
2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
|
||||
|
||||
#### Prometheus Baseline
|
||||
|
||||
- Config file: `monitoring/prometheus/prometheus.yml`
|
||||
- Default scrape interval: `15s`
|
||||
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
|
||||
|
||||
Recommended next-step scrape targets (if/when exporters are introduced):
|
||||
|
||||
- `nginx` (via nginx-exporter)
|
||||
- `redis` (via redis-exporter)
|
||||
- `postgres` (via postgres-exporter)
|
||||
- `node` (via node-exporter)
|
||||
|
||||
#### Grafana Dashboards
|
||||
|
||||
- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
|
||||
- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
|
||||
|
||||
Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
|
||||
|
||||
#### Alerting Rules (Prometheus Rule Groups)
|
||||
|
||||
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
|
||||
|
||||
Minimum recommended alert set:
|
||||
|
||||
- **Availability**
|
||||
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
|
||||
- **Chat service health**
|
||||
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
|
||||
- **Playout continuity**
|
||||
No HLS segment progression for `30-60s` (from playout-specific metric when available).
|
||||
- **Prometheus capacity and ingestion health**
|
||||
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
|
||||
- **Latency and user-impact**
|
||||
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
|
||||
|
||||
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
|
||||
|
||||
Operational resilience baseline:
|
||||
|
||||
- Emergency slate fallback for playout failure.
|
||||
- Auto-restart policies for playout/chat/nginx containers.
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default.
|
||||
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
||||
|
||||
#### Transport and Edge Protection
|
||||
|
||||
- Public traffic uses HTTPS/WSS at the edge.
|
||||
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
||||
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||
|
||||
#### Reverse Proxy Enforcement (nginx)
|
||||
|
||||
- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
|
||||
- Unauthorized admin UI requests are redirected to `/admin/login/`.
|
||||
- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
|
||||
- WebSocket upgrade headers are explicitly forwarded for `/ws`.
|
||||
|
||||
#### Admin Authentication and Session Security
|
||||
|
||||
- Admin authentication is cookie-session based (`linearvoid_admin_session`).
|
||||
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
||||
- Session expiration is enforced server-side.
|
||||
|
||||
#### Rate Limiting and Abuse Controls
|
||||
|
||||
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
||||
- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
|
||||
- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
|
||||
|
||||
#### CORS and Cross-Origin Behavior
|
||||
|
||||
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
|
||||
- Admin/authenticated routes are expected to run same-origin behind nginx auth gates.
|
||||
|
||||
#### Moderation and Privacy
|
||||
|
||||
- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
|
||||
- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
|
||||
- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
|
||||
|
||||
### Chat
|
||||
|
||||
@@ -354,6 +621,147 @@ These ADRs together form the canonical decision log.
|
||||
|
||||
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
||||
|
||||
### Performance Benchmarks and Capacity Planning
|
||||
|
||||
#### Baseline SLO Targets (v1)
|
||||
|
||||
- Playback startup: target < 4s after first manifest request on warm cache.
|
||||
- Steady-state playback: no segment starvation during normal operation.
|
||||
- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
|
||||
- Chat delivery latency: p95 < 500ms inside same region under normal load.
|
||||
- Admin page responsiveness: p95 API response < 1s for common read operations.
|
||||
|
||||
#### Workload Assumptions
|
||||
|
||||
- Primary audience in US; origin in US-East.
|
||||
- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
|
||||
- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
|
||||
|
||||
#### Initial Capacity Model
|
||||
|
||||
- Video bitrate envelopes (from ADR-001):
|
||||
- 720p30: ~2.6-4.2 Mbps total with audio.
|
||||
- 1080p30: ~5.1-8.2 Mbps total with audio.
|
||||
- Storage planning for 48h rolling library:
|
||||
- 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
|
||||
- 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
|
||||
- Cost behavior:
|
||||
- Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
|
||||
|
||||
#### Benchmark Plan
|
||||
|
||||
- Playback path:
|
||||
- Measure time-to-first-frame and rebuffer events from multiple US regions.
|
||||
- Track manifest freshness and segment availability at edge.
|
||||
- Chat path:
|
||||
- Load-test concurrent websocket connections and message fan-out.
|
||||
- Measure p50/p95 send-to-receive latency and Redis fallback behavior.
|
||||
- Admin/API path:
|
||||
- Benchmark playlist/system endpoints for p95 latency and error rate.
|
||||
- Platform path:
|
||||
- Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
|
||||
|
||||
#### Capacity Triggers and Actions
|
||||
|
||||
- Trigger: sustained viewer growth with rising cache-miss ratio.
|
||||
- Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
|
||||
- Trigger: chat p95 latency drift or websocket error growth.
|
||||
- Action: scale chat service, tune Redis, separate noisy moderation workloads.
|
||||
- Trigger: Prometheus WAL/compaction failures or query saturation.
|
||||
- Action: tune retention/cardinality, increase resources, split dashboards/queries.
|
||||
- Trigger: origin CPU/memory pressure on playout host.
|
||||
- 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
|
||||
|
||||
#### Recovery Targets
|
||||
|
||||
- RPO target: <= 24h for chat/moderation/admin metadata.
|
||||
- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
|
||||
|
||||
#### Backup Scope
|
||||
|
||||
- Persistent service volumes:
|
||||
- Postgres data (`postgres-data` volume).
|
||||
- Redis append-only persistence (`redis-data` volume) where enabled.
|
||||
- Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
|
||||
- Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
|
||||
- Host-mounted state directories:
|
||||
- `data/chat`
|
||||
- `data/playlist`
|
||||
- `data/media` (authoritative source may be R2/object storage)
|
||||
- `data/hls` (optional; can be regenerated by playout)
|
||||
- Configuration and infra source of truth:
|
||||
- Compose files, nginx config, monitoring configs, and environment template.
|
||||
|
||||
#### Backup Cadence
|
||||
|
||||
- Daily logical Postgres backups (plus weekly full snapshot).
|
||||
- Daily sync of chat/playlist state directories.
|
||||
- Weekly integrity check by restoring backups into an isolated environment.
|
||||
- Retention policy baseline:
|
||||
- Daily backups: 14 days
|
||||
- Weekly backups: 8 weeks
|
||||
- Monthly backups: 6 months
|
||||
|
||||
#### Recovery Runbook (Minimum)
|
||||
|
||||
1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
|
||||
2. Restore Postgres and Redis persisted state.
|
||||
3. Restore `data/playlist` and chat persistence directories.
|
||||
4. Rehydrate media from authoritative object storage if local media is missing.
|
||||
5. Start stack and validate in order:
|
||||
- `playout` health endpoint
|
||||
- `chat` health endpoint
|
||||
- nginx `/health`
|
||||
- HLS playlist availability
|
||||
- websocket chat connectivity
|
||||
6. Verify admin authentication and moderation audit visibility.
|
||||
7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
|
||||
|
||||
#### Failure Modes and Strategy
|
||||
|
||||
- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
|
||||
- Storage corruption: restore last known-good snapshot and replay latest logical backups.
|
||||
- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
|
||||
|
||||
Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
|
||||
|
||||
## Risks and Technical Debts
|
||||
|
||||
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
||||
@@ -381,162 +789,3 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
|
||||
| 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.
|
||||
|
||||
### 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\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,45 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+3
-303
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</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">
|
||||
<aside class="admin-sidebar" aria-label="Primary">
|
||||
<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-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-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-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>
|
||||
@@ -31,308 +32,7 @@
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
<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 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 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>
|
||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
+32
-50
@@ -10,63 +10,45 @@
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<section class="viewer-main" aria-label="Video stream panel">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
<div class="now-playing-pill" aria-live="polite">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span id="now-playing">Loading schedule...</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing-overlay" class="now-playing-overlay hidden" aria-live="polite">
|
||||
<div class="now-playing-overlay-inner">
|
||||
<span class="live-label">LIVE</span>
|
||||
<span id="overlay-title" class="overlay-title"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="viewer-footer">
|
||||
<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>
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">
|
||||
Synced with video • Moderation active
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<aside class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">
|
||||
Synced with video • Moderation active
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
+562
-68
@@ -33,12 +33,86 @@ const adminConfig = {
|
||||
: 50,
|
||||
};
|
||||
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||
|
||||
const state = {
|
||||
mediaUploading: false,
|
||||
moderationSubmitting: false,
|
||||
playlistSaving: false,
|
||||
};
|
||||
|
||||
const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById(
|
||||
"media-upload-progress",
|
||||
) as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
|
||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||
const wrap = uploadProgressWrap();
|
||||
const progress = uploadProgressBar();
|
||||
const text = uploadProgressText();
|
||||
if (!wrap || !progress || !text) return;
|
||||
|
||||
wrap.hidden = false;
|
||||
progress.max = total;
|
||||
progress.value = value;
|
||||
|
||||
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
|
||||
text.textContent = `${label} (${pct}%)`;
|
||||
};
|
||||
|
||||
const hideUploadProgress = () => {
|
||||
const wrap = uploadProgressWrap();
|
||||
if (wrap) wrap.hidden = true;
|
||||
};
|
||||
|
||||
const getFileFingerprint = (file: File) =>
|
||||
`${file.name}:${file.size}:${file.lastModified}`;
|
||||
|
||||
const getResumeStorageKey = (fingerprint: string) =>
|
||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
|
||||
const getStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
return window.localStorage.getItem(getResumeStorageKey(fingerprint)) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const setStoredUploadId = (fingerprint: string, uploadId: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(getResumeStorageKey(fingerprint), uploadId);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
window.localStorage.removeItem(getResumeStorageKey(fingerprint));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const readErrorMessage = async (response: Response) => {
|
||||
const fallback = `HTTP ${response.status}`;
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
return text || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (id: string, text: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
@@ -63,9 +137,21 @@ const fmtSize = (value?: number) => {
|
||||
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 panels = document.querySelectorAll<HTMLElement>(".tab-panel");
|
||||
|
||||
triggers.forEach((trigger) => {
|
||||
const active = trigger.dataset.tab === tabName;
|
||||
@@ -73,16 +159,27 @@ function switchTab(tabName: string) {
|
||||
trigger.setAttribute("aria-selected", String(active));
|
||||
});
|
||||
|
||||
panels.forEach((panel) => {
|
||||
const active = panel.id === `tab-${tabName}`;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("is-active", active);
|
||||
});
|
||||
const container = document.getElementById("tab-content-container");
|
||||
if (!container) return;
|
||||
|
||||
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 === "playlist") refreshPlaylist();
|
||||
if (tabName === "schedule") refreshSchedule();
|
||||
if (tabName === "moderation") refreshModerationLogs();
|
||||
if (tabName === "chat-log") refreshChatLog();
|
||||
if (tabName === "users") refreshUsers();
|
||||
if (tabName === "analytics") refreshAnalytics();
|
||||
if (tabName === "system") refreshSystemSummary();
|
||||
@@ -330,30 +427,122 @@ async function refreshMediaLibrary() {
|
||||
|
||||
async function uploadVideo(file: File) {
|
||||
if (state.mediaUploading) return;
|
||||
|
||||
if (file.size <= 0) {
|
||||
setStatus("media-status", "Cannot upload an empty file.", true);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
setStatus("media-status", "File exceeds 2GB upload limit.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
state.mediaUploading = true;
|
||||
setStatus("media-status", `Uploading ${file.name}...`);
|
||||
setStatus("media-status", `Preparing upload for ${file.name}...`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_BYTES);
|
||||
const fingerprint = getFileFingerprint(file);
|
||||
|
||||
const response = await fetch("/api/admin/media", {
|
||||
const initResponse = await fetch("/api/admin/media/uploads", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
uploadId: getStoredUploadId(fingerprint),
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
chunkSize: UPLOAD_CHUNK_BYTES,
|
||||
totalChunks,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(await readErrorMessage(initResponse));
|
||||
}
|
||||
|
||||
const initPayload = await initResponse.json();
|
||||
const uploadId = String(initPayload.uploadId || "");
|
||||
if (!uploadId) {
|
||||
throw new Error("Upload session could not be initialized.");
|
||||
}
|
||||
|
||||
setStoredUploadId(fingerprint, uploadId);
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(
|
||||
Math.max(alreadyUploaded, 0),
|
||||
effectiveTotalChunks,
|
||||
);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (
|
||||
let chunkIndex = resumeFrom;
|
||||
chunkIndex < effectiveTotalChunks;
|
||||
chunkIndex += 1
|
||||
) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const chunkResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/chunk?index=${chunkIndex}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: chunk,
|
||||
},
|
||||
);
|
||||
|
||||
if (!chunkResponse.ok) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(
|
||||
chunkIndex + 1,
|
||||
effectiveTotalChunks,
|
||||
"Uploading chunks",
|
||||
);
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(effectiveTotalChunks, effectiveTotalChunks, "Finalizing");
|
||||
|
||||
const payload = await completeResponse.json();
|
||||
renderMediaRows(payload.files || []);
|
||||
setText("media-generated-at", payload.generatedAt || "unavailable");
|
||||
setStatus("media-status", `${file.name} uploaded.`);
|
||||
clearStoredUploadId(fingerprint);
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
setStatus("media-status", "Failed to upload media file.", true);
|
||||
const details =
|
||||
error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
state.mediaUploading = false;
|
||||
setTimeout(() => hideUploadProgress(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +674,245 @@ 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-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
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() {
|
||||
setStatus("users-status", "Loading active users...");
|
||||
try {
|
||||
@@ -536,67 +964,133 @@ function bindEvents() {
|
||||
window.location.href = "/admin/login/";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.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);
|
||||
// bindPageEvents wires up page-specific DOM listeners after HTML is loaded
|
||||
function bindPageEvents(tabName: string) {
|
||||
switch (tabName) {
|
||||
case "upload":
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
await uploadVideo(target.files[0]);
|
||||
target.value = "";
|
||||
});
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragleave", () => {
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await uploadVideo(droppedFiles[0]);
|
||||
});
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
await uploadVideo(target.files[0]);
|
||||
target.value = "";
|
||||
});
|
||||
break;
|
||||
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
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);
|
||||
break;
|
||||
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
await Promise.all([
|
||||
refreshOverview(),
|
||||
refreshAnalytics(),
|
||||
refreshMediaLibrary(),
|
||||
refreshPlaylist(),
|
||||
refreshSchedule(),
|
||||
refreshModerationLogs(),
|
||||
refreshSystemSummary(),
|
||||
refreshUsers(),
|
||||
]);
|
||||
// Load dashboard as the initial tab
|
||||
await switchTab("dashboard");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
+15
-15
@@ -1,12 +1,13 @@
|
||||
import "./style.css";
|
||||
|
||||
type HlsClass = typeof import("hls.js")["default"];
|
||||
type HlsClass = (typeof import("hls.js"))["default"];
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
switchRoom: (room: string) => void;
|
||||
sendMessage: () => void;
|
||||
togglePrivateDM: () => void;
|
||||
toggleChatPanel: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ let hls: InstanceType<HlsClass> | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
@@ -134,25 +136,22 @@ async function initPlayerAndChat() {
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
setInterval(() => {
|
||||
const titles = [
|
||||
"Midnight Jazz Lounge",
|
||||
"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(
|
||||
"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() {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
const text = input.value.trim();
|
||||
@@ -190,6 +189,7 @@ function togglePrivateDM() {
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initPlayerAndChat();
|
||||
|
||||
+203
-85
@@ -1,29 +1,48 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||
|
||||
--bg-0: #f4f6ef;
|
||||
--bg-1: #e3e8db;
|
||||
--ink-0: #101b18;
|
||||
--ink-1: #2d3a36;
|
||||
--ink-soft: #5a6963;
|
||||
--bg-0: #0d1417;
|
||||
--bg-1: #172227;
|
||||
--bg-2: #0a1012;
|
||||
--ink-0: #edf6f3;
|
||||
--ink-1: #cadbd6;
|
||||
--ink-soft: #9cb2ab;
|
||||
|
||||
--panel: #fffffd;
|
||||
--panel-2: #f7fbf2;
|
||||
--line: #c6d1c5;
|
||||
--panel: #17252b;
|
||||
--panel-2: #1f3138;
|
||||
--line: #30464d;
|
||||
--line-soft: #3d5962;
|
||||
|
||||
--brand: #005f73;
|
||||
--brand-strong: #00464f;
|
||||
--brand-contrast: #f9fffd;
|
||||
--ok: #2f7a43;
|
||||
--warn: #b76a00;
|
||||
--danger: #a7283f;
|
||||
--brand: #2ec4b6;
|
||||
--brand-strong: #20a79a;
|
||||
--brand-contrast: #061412;
|
||||
--ok: #5add94;
|
||||
--warn: #f2b560;
|
||||
--danger: #ff7d8f;
|
||||
--danger-soft: #3a1f27;
|
||||
|
||||
--focus: #0a9396;
|
||||
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
||||
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
||||
--focus: #78f2e0;
|
||||
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.34);
|
||||
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--bg-grad-a: #132129;
|
||||
--bg-grad-b: var(--bg-0);
|
||||
--bg-grad-c: var(--bg-2);
|
||||
|
||||
--live-dot: #ff6b6b;
|
||||
--overlay-bg: rgba(4, 14, 18, 0.84);
|
||||
|
||||
--input-bg: #131f24;
|
||||
--input-border: var(--line-soft);
|
||||
--placeholder: #90a8a1;
|
||||
|
||||
--badge-start: #35c8bb;
|
||||
--badge-end: #1f8d85;
|
||||
|
||||
--radius-1: 0.6rem;
|
||||
--radius-2: 1rem;
|
||||
@@ -40,6 +59,50 @@
|
||||
--fast: 160ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--bg-0: #f4f6ef;
|
||||
--bg-1: #e3e8db;
|
||||
--bg-2: #eff3ea;
|
||||
--ink-0: #101b18;
|
||||
--ink-1: #2d3a36;
|
||||
--ink-soft: #5a6963;
|
||||
|
||||
--panel: #fffffd;
|
||||
--panel-2: #f7fbf2;
|
||||
--line: #c6d1c5;
|
||||
--line-soft: #d8e2d8;
|
||||
|
||||
--brand: #005f73;
|
||||
--brand-strong: #00464f;
|
||||
--brand-contrast: #f9fffd;
|
||||
--ok: #2f7a43;
|
||||
--warn: #b76a00;
|
||||
--danger: #a7283f;
|
||||
--danger-soft: #fff3f6;
|
||||
|
||||
--focus: #0a9396;
|
||||
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
||||
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
||||
|
||||
--bg-grad-a: #dce9d6;
|
||||
--bg-grad-b: var(--bg-0);
|
||||
--bg-grad-c: #eff3ea;
|
||||
|
||||
--live-dot: #cb271a;
|
||||
--overlay-bg: rgba(0, 16, 19, 0.84);
|
||||
|
||||
--input-bg: #fff;
|
||||
--input-border: #c2d2c1;
|
||||
--placeholder: #74817b;
|
||||
|
||||
--badge-start: #0a9396;
|
||||
--badge-end: #005f73;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -48,6 +111,7 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -56,9 +120,9 @@ body {
|
||||
color: var(--ink-0);
|
||||
background: radial-gradient(
|
||||
circle at 20% 10%,
|
||||
#dce9d6 0%,
|
||||
var(--bg-0) 50%,
|
||||
#eff3ea 100%
|
||||
var(--bg-grad-a) 0%,
|
||||
var(--bg-grad-b) 50%,
|
||||
var(--bg-grad-c) 100%
|
||||
);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -172,13 +236,13 @@ select {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff3f6;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: #f3c0ca;
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ffe8ee;
|
||||
background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger));
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
@@ -198,13 +262,13 @@ select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-1);
|
||||
background: #fff;
|
||||
background: var(--input-bg);
|
||||
color: var(--ink-0);
|
||||
padding: 0.65rem 0.8rem;
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: #74817b;
|
||||
color: var(--placeholder);
|
||||
}
|
||||
|
||||
.text-area {
|
||||
@@ -239,7 +303,7 @@ select {
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 0.72rem 0.65rem;
|
||||
border-bottom: 1px solid #dfe7de;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -279,34 +343,44 @@ select {
|
||||
|
||||
.lv-body {
|
||||
min-height: 100vh;
|
||||
padding: var(--space-5);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
width: min(1250px, 100%);
|
||||
margin: 0 auto;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
|
||||
background: linear-gradient(170deg, #f7fbf2 0%, #ebf2e7 40%, #f8fbf5 100%);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
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-radius: var(--radius-3);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-main {
|
||||
.viewer-content {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 76vh;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.viewer-header,
|
||||
.viewer-footer {
|
||||
.viewer-header {
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.viewer-brand-group {
|
||||
@@ -324,8 +398,12 @@ select {
|
||||
font-weight: 800;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
background: linear-gradient(140deg, #0a9396 0%, #005f73 100%);
|
||||
color: var(--brand-contrast);
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
var(--badge-start) 0%,
|
||||
var(--badge-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.brand-text,
|
||||
@@ -347,29 +425,33 @@ select {
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid #c9d6c8;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-1);
|
||||
padding: 0.35rem 0.8rem;
|
||||
}
|
||||
|
||||
.live-dot,
|
||||
.chat-live-marker {
|
||||
color: #cb271a;
|
||||
color: var(--live-dot);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.player-stage {
|
||||
position: relative;
|
||||
padding: 0 var(--space-5) var(--space-5);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1.1rem;
|
||||
border: 1px solid #bfd0be;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: #000;
|
||||
box-shadow: var(--shadow-1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.now-playing-overlay {
|
||||
@@ -382,7 +464,7 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: rgba(0, 16, 19, 0.84);
|
||||
background: var(--overlay-bg);
|
||||
color: #f0fffb;
|
||||
padding: 0.44rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
@@ -402,23 +484,16 @@ select {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.room-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.room-links a {
|
||||
text-decoration: none;
|
||||
font-weight: 650;
|
||||
.chat-toggle-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
background: #f0f6ee;
|
||||
background: color-mix(in srgb, var(--panel) 82%, var(--bg-1));
|
||||
border-left: 1px solid var(--line);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 76vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-header,
|
||||
@@ -430,7 +505,7 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #d4dfd3;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -474,20 +549,20 @@ select {
|
||||
}
|
||||
|
||||
.message-other {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d3e0d2;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.message-self {
|
||||
background: #005f73;
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
color: var(--brand-contrast);
|
||||
}
|
||||
|
||||
.message-system {
|
||||
font-size: 0.83rem;
|
||||
color: #1d4441;
|
||||
background: #d9efea;
|
||||
border: 1px solid #b5ddd6;
|
||||
color: var(--ink-1);
|
||||
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||
border-radius: 999px;
|
||||
padding: 0.24rem 0.68rem;
|
||||
animation: messageIn var(--fast);
|
||||
@@ -524,16 +599,28 @@ select {
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid #c2d2c1;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-1);
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: #fff;
|
||||
background: var(--input-bg);
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
min-height: 100vh;
|
||||
padding: var(--space-4);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 82% 12%,
|
||||
rgba(20, 48, 54, 0.5) 0%,
|
||||
transparent 44%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 16% 85%,
|
||||
rgba(8, 28, 34, 0.55) 0%,
|
||||
transparent 40%
|
||||
),
|
||||
var(--bg-0);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
@@ -544,13 +631,13 @@ select {
|
||||
border-radius: var(--radius-3);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: #f7fbf3;
|
||||
background: color-mix(in srgb, var(--panel) 88%, var(--bg-1));
|
||||
box-shadow: var(--shadow-2);
|
||||
min-height: calc(100vh - 2 * var(--space-4));
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
background: #16423d;
|
||||
background: #102a2f;
|
||||
color: #ecfff6;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
@@ -594,9 +681,9 @@ select {
|
||||
}
|
||||
|
||||
.tab-trigger.is-active {
|
||||
background: #0a9396;
|
||||
color: #fff;
|
||||
border-color: #89d1c8;
|
||||
background: var(--brand);
|
||||
color: var(--brand-contrast);
|
||||
border-color: color-mix(in srgb, var(--brand) 55%, #fff);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
@@ -604,6 +691,14 @@ select {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.viewer-shell.chat-collapsed .viewer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.viewer-shell.chat-collapsed .chat-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -622,9 +717,9 @@ select {
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
border: 1px solid #d8e2d8;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-2);
|
||||
background: #fff;
|
||||
background: var(--panel);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
@@ -636,15 +731,31 @@ select {
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
border: 2px dashed #97b8aa;
|
||||
border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line));
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--space-5);
|
||||
background: #effaf5;
|
||||
background: color-mix(in srgb, var(--brand) 12%, var(--panel-2));
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.upload-dropzone.is-dragover {
|
||||
border-color: var(--brand);
|
||||
background: color-mix(in srgb, var(--brand) 20%, var(--panel-2));
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
width: min(540px, 100%);
|
||||
}
|
||||
|
||||
.upload-progress progress {
|
||||
width: 100%;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -652,9 +763,9 @@ select {
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid #dce7db;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-2);
|
||||
background: #fff;
|
||||
background: var(--panel);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
@@ -688,7 +799,7 @@ select {
|
||||
}
|
||||
|
||||
.service-row {
|
||||
border: 1px solid #dbe5db;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-1);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
@@ -732,9 +843,9 @@ select {
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
border: 1px solid #cad8c9;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-3);
|
||||
background: #fff;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-2);
|
||||
padding: var(--space-6);
|
||||
display: grid;
|
||||
@@ -765,11 +876,15 @@ select {
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.viewer-shell,
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -805,16 +920,15 @@ select {
|
||||
}
|
||||
|
||||
.viewer-header,
|
||||
.viewer-footer,
|
||||
.player-stage,
|
||||
.chat-header,
|
||||
.chat-input-area {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.viewer-footer {
|
||||
.viewer-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
@@ -832,6 +946,10 @@ select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.viewer-shell.chat-collapsed .viewer-content {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ http {
|
||||
listen 80 default_server;
|
||||
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;
|
||||
index index.html;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user