35 Commits
Author SHA1 Message Date
zwitschi 8f1e227d50 fix: Dockerfile parameter
CI / test (push) Successful in 1m35s
CI / docker (push) Successful in 3m12s
CI / release (push) Skipped
2026-08-04 13:44:00 +02:00
zwitschi 0891606321 fix: context
CI / test (push) Successful in 1m35s
CI / docker (push) Successful in 2m46s
CI / release (push) Skipped
2026-08-04 13:17:13 +02:00
zwitschi c6f28ff7af fix: rearrange steps again
CI / test (push) Successful in 1m35s
CI / docker (push) Failing after 30s
CI / release (push) Skipped
2026-08-04 13:00:23 +02:00
zwitschi 072a91961f fix: rearrange steps again
CI / test (push) Successful in 6m3s
CI / docker (push) Failing after 28s
CI / release (push) Skipped
2026-08-04 12:51:34 +02:00
zwitschi 7184240dd4 fix: move copy step up
CI / test (push) Successful in 1m34s
CI / docker (push) Failing after 15s
CI / release (push) Skipped
2026-08-04 12:46:47 +02:00
zwitschi 9cd0419c0c fix: only one media folder
CI / test (push) Successful in 6m5s
CI / docker (push) Failing after 1m52s
CI / release (push) Skipped
2026-08-04 12:37:11 +02:00
zwitschi d3266f8feb fix: reorder copy steps
CI / test (push) Successful in 1m36s
CI / docker (push) Failing after 29s
CI / release (push) Skipped
2026-08-04 12:33:05 +02:00
zwitschi b532507374 fix: adjust copy
CI / test (push) Successful in 1m32s
CI / docker (push) Failing after 15s
CI / release (push) Skipped
2026-08-04 12:27:44 +02:00
zwitschi 5c1a8cbe4f reorder copy steps
CI / test (push) Successful in 1m40s
CI / docker (push) Failing after 24s
CI / release (push) Skipped
2026-08-04 12:19:17 +02:00
zwitschi 502fefff33 fix: move demodata into playout build context, revert root media changes
CI / test (push) Successful in 1m30s
CI / docker (push) Failing after 30s
CI / release (push) Skipped
2026-08-04 12:10:38 +02:00
zwitschi bc3a950d99 fix: use repo root as build context for playout to include demodata
CI / test (push) Failing after 13s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 12:01:03 +02:00
zwitschi 1af9e59a56 rename data directory for initial demo data
CI / test (push) Failing after 2m26s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 11:54:44 +02:00
zwitschi 13d25a7ae0 remove empty line
CI / test (push) Failing after 16s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 11:49:49 +02:00
zwitschi fc799da000 copy full folder instead of playlist.txt
CI / test (push) Failing after 2m25s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 11:46:21 +02:00
zwitschi 2b091adce8 move demo-data to its own folder
CI / test (push) Failing after 2m24s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 11:40:22 +02:00
zwitschi c54210434e feat: bundle demo media and playlist in playout image for out-of-the-box HLS
CI / test (push) Failing after 2m30s
CI / docker (push) Skipped
CI / release (push) Skipped
2026-08-04 11:34:20 +02:00
zwitschi fcb68cfd8d fix: add curl to chat runtime image for healthcheck
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 3m9s
CI / release (push) Skipped
2026-08-04 11:00:49 +02:00
zwitschi cdecdd2d4a fix: add curl to chat runtime image for healthcheck 2026-08-04 11:00:00 +02:00
zwitschi 47ffae5dea fix: make playlist volume rw so playout can create demo playlist
CD / deploy (push) Successful in 37s
CI / test (push) Successful in 6m2s
CI / docker (push) Successful in 2m54s
CI / release (push) Skipped
2026-08-04 10:30:02 +02:00
zwitschi 43c1a31660 fix: nginx build context, playout health check retry loop
CI / test (push) Successful in 1m39s
CI / docker (push) Successful in 3m4s
CI / release (push) Skipped
CD / deploy (push) Failing after 1m17s
2026-08-04 10:21:18 +02:00
zwitschi afb9a41404 fix: use docker exec for playout health check in CD workflow
CD / deploy (push) Failing after 23s
CI / test (push) Successful in 6m6s
CI / docker (push) Failing after 2m42s
CI / release (push) Skipped
2026-08-04 10:12:38 +02:00
zwitschi 1f2082e0fb fix: use named volume for prometheus config, seed in CD workflow
CD / deploy (push) Failing after 26s
CI / test (push) Successful in 6m2s
CI / docker (push) Failing after 2m41s
CI / release (push) Skipped
2026-08-04 10:05:59 +02:00
zwitschi 7ce6a24a77 reinstated indentation for env
CD / deploy (push) Failing after 25s
CI / test (push) Successful in 5m59s
CI / docker (push) Failing after 2m43s
CI / release (push) Skipped
2026-08-04 09:45:58 +02:00
zwitschi 450a93150c ci: fix indentation in .env generation step of CD workflow
CI / test (push) Successful in 6m4s
CI / docker (push) Failing after 3m6s
CI / release (push) Skipped
2026-08-04 09:08:52 +02:00
zwitschi dc53bce6c9 ci: remove GitHub Actions cache configuration from Docker build steps
CD / deploy (push) Failing after 1m8s
CI / test (push) Successful in 7m49s
CI / docker (push) Failing after 2m49s
CI / release (push) Skipped
2026-08-04 08:59:45 +02:00
zwitschi 32f2b7c7ab ci: use ubuntu-latest runner for docker and release jobs
CD / deploy (push) Failing after 1m54s
CI / test (push) Successful in 1m59s
CI / docker (push) Failing after 3m41s
CI / release (push) Skipped
2026-08-04 08:24:12 +02:00
zwitschi 72422f4336 feat: add Prometheus and Grafana configurations and document Alpine Docker setup
CD / deploy (push) Failing after 34s
CI / test (push) Successful in 1m40s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
2026-08-03 22:02:09 +02:00
zwitschi 78508ed16e ci: configure CD workflow to deploy to remote Docker host and document test environment
CI / test (push) Successful in 1m33s
CD / deploy (push) Failing after 15s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
2026-08-03 19:57:58 +02:00
zwitschi 8fd80c1a4c ci: generate production .env dynamically in CD workflow and update secrets documentation
CI / test (push) Successful in 1m59s
CD / deploy (push) Failing after 16s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
2026-08-03 19:37:35 +02:00
zwitschi 5fc9bc6191 build: integrate frontend build into Nginx Dockerfile, remove separate frontend image
CD / deploy (push) Failing after 1m38s
CI / test (push) Successful in 2m7s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
2026-08-03 19:17:53 +02:00
zwitschi 3c2b29ad26 refactor: rename Gitea environment variables to CI registry prefix
CD / deploy (push) Failing after 17s
CI / test (push) Successful in 1m36s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
update SETUP.md and .env.example
2026-08-03 19:02:30 +02:00
zwitschi 6b494a34fe feat: add admin login page and authentication API
CD / deploy (push) Failing after 27s
CI / test (push) Successful in 1m54s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
- Created a new login page for admin access at frontend/admin/login/index.html.
- Implemented form submission with error handling for login credentials.
- Updated frontend/index.html to mute the video player and removed unnecessary Alpine.js data binding.
- Enhanced manifest URL resolution in frontend/src/main.ts to include cache-busting parameters.
- Modified vite.config.ts to include the new admin login page in the build process.
- Updated nginx.conf to handle admin login routing, authentication, and API proxying for admin actions.
2026-08-03 17:19:05 +02:00
zwitschi 1ae0ad121b update environment configuration for local development, enhance database initialization, and improve Nginx routing for admin access 2026-08-03 14:26:11 +02:00
zwitschi 2e061dff28 update environment configuration, enhance setup guide, and align documentation with new domain structure 2026-08-03 14:09:51 +02:00
zwitschi c484b463e3 add brand research documentation for "Linear Void" trademark and SEO strategy 2026-08-03 14:09:02 +02:00
33 changed files with 2729 additions and 228 deletions

No files matched your search

+28 -15
View File
@@ -12,21 +12,39 @@ R2_REGION=auto
# Cloudflare (for custom domain, cache rules, Workers if used) # Cloudflare (for custom domain, cache rules, Workers if used)
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_ACCOUNT_ID=your-account-id CLOUDFLARE_ACCOUNT_ID=your-account-id
MEDIA_DOMAIN=media.example.com MEDIA_DOMAIN=media.linearvoid.tv
# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager.
VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ADMIN_SESSION_TTL_HOURS=12
# Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
# Gitea Container Registry (for self-hosted Docker images) # Gitea Container Registry (for self-hosted Docker images)
GITEA_REGISTRY_URL=git.allucanget.biz CI_REGISTRY_URL=git.allucanget.biz
GITEA_USERNAME=your-gitea-username CI_REGISTRY_USER=your-gitea-username
GITEA_TOKEN=your-gitea-token CI_REGISTRY_TOKEN=your-gitea-token
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
# Gitea API access (optional, for CI/release automation)
CI_SERVER_URL=https://git.allucanget.biz
# Chat / Application Database (PostgreSQL or SQLite for local) # Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional)
DATABASE_URL=postgres://user:password@localhost:5432/linearvoid DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
# Or for SQLite (local development): # Or for SQLite-only local development:
# DATABASE_URL=sqlite://./data/chat.db # DATABASE_URL=sqlite://./data/chat.db
# Redis (for presence, rate limiting, pub/sub) # Redis (for presence, rate limiting, pub/sub)
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://redis:6379/0
# Service Ports (for local docker-compose) # Service Ports (for local docker-compose)
PLAYOUT_PORT=8080 PLAYOUT_PORT=8080
@@ -34,7 +52,7 @@ CHAT_PORT=3000
NGINX_PORT=80 NGINX_PORT=80
# Playout configuration # Playout configuration
PLAYLIST_PATH=/app/playlist.txt PLAYLIST_PATH=/app/playlist/playlist.txt
HLS_SEGMENT_DURATION=6 HLS_SEGMENT_DURATION=6
HLS_WINDOW_SIZE=24 HLS_WINDOW_SIZE=24
@@ -46,8 +64,3 @@ GRAFANA_PORT=3001
# Development flags # Development flags
NODE_ENV=development NODE_ENV=development
GO_ENV=development GO_ENV=development
# Gitea Configuration
GITEA_SERVER_URL=https://git.allucanget.biz
GITEA_TOKEN=your-gitea-token
GITEA_USERNAME=your-gitea-username
+61 -12
View File
@@ -1,13 +1,17 @@
name: CD name: CD
on: on:
push: workflow_run:
workflows: ["CI"]
types:
- completed
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
runs-on: [ubuntu-latest] runs-on: [ubuntu-latest]
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
environment: production environment: production
steps: steps:
- name: Checkout code - name: Checkout code
@@ -22,28 +26,73 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.allucanget.biz registry: git.allucanget.biz
username: ${{ secrets.GITEA_USERNAME }} username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.GITEA_TOKEN }} password: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Pull latest images - name: Prepare environment
run: | run: |
docker pull git.allucanget.biz/zwitschi/linearvoid/playout:latest || true cat > .env << 'EOF'
docker pull git.allucanget.biz/zwitschi/linearvoid/chat:latest || true DATABASE_URL="${{ secrets.DATABASE_URL }}"
docker pull git.allucanget.biz/zwitschi/linearvoid/frontend:latest || true REDIS_URL=redis://redis:6379/0
docker pull git.allucanget.biz/zwitschi/linearvoid/nginx:latest || true PLAYLIST_PATH=/app/playlist/playlist.txt
HLS_SEGMENT_DURATION=6
HLS_WINDOW_SIZE=24
LOG_LEVEL=info
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
ADMIN_SESSION_TTL_HOURS=12
NGINX_PORT=80
EOF
- name: Seed Prometheus config
run: |
mkdir -p monitoring/prometheus
cat > monitoring/prometheus/prometheus.yml << 'EOF'
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'linearvoid'
static_configs:
- targets: ['playout:8081', 'chat:3000', 'nginx:80']
EOF
- name: Deploy with docker-compose - name: Deploy with docker-compose
env:
DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans docker compose -f docker-compose.prod.yml up -d --remove-orphans
- name: Health check - name: Health check
env:
DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
sleep 10 # Wait for playout to stabilize (it may restart during init)
curl -f http://localhost:8080/health || exit 1 sleep 20
curl -f http://localhost:3000/health || exit 1 curl -f http://192.168.88.40:3000/health || exit 1
curl -f http://localhost:8081/health || exit 1 # Retry playout health check since it may still be initializing
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
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
- name: Cleanup old images - name: Cleanup old images
env:
DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker image prune -f --filter "until=24h" docker image prune -f --filter "until=24h"
+23 -39
View File
@@ -48,7 +48,7 @@ jobs:
docker: docker:
needs: test needs: test
runs-on: [self-hosted, linux, docker] runs-on: [ubuntu-latest]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Checkout code - name: Checkout code
@@ -63,8 +63,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.allucanget.biz registry: git.allucanget.biz
username: ${{ secrets.GITEA_USERNAME }} username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.GITEA_TOKEN }} password: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Extract metadata for Playout - name: Extract metadata for Playout
id: meta-playout id: meta-playout
@@ -82,11 +82,10 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./backend/playout context: ./backend/playout
file: ./backend/playout/Dockerfile
push: true push: true
tags: ${{ steps.meta-playout.outputs.tags }} tags: ${{ steps.meta-playout.outputs.tags }}
labels: ${{ steps.meta-playout.outputs.labels }} labels: ${{ steps.meta-playout.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for Chat - name: Extract metadata for Chat
id: meta-chat id: meta-chat
@@ -107,30 +106,6 @@ jobs:
push: true push: true
tags: ${{ steps.meta-chat.outputs.tags }} tags: ${{ steps.meta-chat.outputs.tags }}
labels: ${{ steps.meta-chat.outputs.labels }} labels: ${{ steps.meta-chat.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: git.allucanget.biz/zwitschi/linearvoid/frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Frontend image
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for Nginx - name: Extract metadata for Nginx
id: meta-nginx id: meta-nginx
@@ -145,18 +120,27 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Nginx image - name: Build and push Nginx image
uses: docker/build-push-action@v5 run: |
with: TAGS="${{ steps.meta-nginx.outputs.tags }}"
context: ./infrastructure/nginx LABELS="${{ steps.meta-nginx.outputs.labels }}"
push: true TAG_ARGS=""
tags: ${{ steps.meta-nginx.outputs.tags }} for tag in $TAGS; do
labels: ${{ steps.meta-nginx.outputs.labels }} TAG_ARGS="$TAG_ARGS --tag $tag"
cache-from: type=gha done
cache-to: type=gha,mode=max LABEL_ARGS=""
while IFS= read -r label; do
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
done <<< "$LABELS"
docker buildx build \
--push \
--file infrastructure/nginx/Dockerfile \
$TAG_ARGS \
$LABEL_ARGS \
.
release: release:
needs: docker needs: docker
runs-on: [self-hosted, linux, docker] runs-on: [ubuntu-latest]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
steps: steps:
- name: Checkout code - name: Checkout code
@@ -169,4 +153,4 @@ jobs:
with: with:
generate_release_notes: true generate_release_notes: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -42,7 +42,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v5 uses: peter-evans/create-pull-request@v5
with: with:
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.CI_REGISTRY_TOKEN }}
commit-message: "chore: update dependencies" commit-message: "chore: update dependencies"
title: "chore: weekly dependency updates" title: "chore: weekly dependency updates"
body: | body: |
+10 -28
View File
@@ -27,8 +27,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.allucanget.biz registry: git.allucanget.biz
username: ${{ secrets.GITEA_USERNAME }} username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.GITEA_TOKEN }} password: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Extract version - name: Extract version
id: version id: version
@@ -75,24 +75,6 @@ jobs:
tags: ${{ steps.meta-chat.outputs.tags }} tags: ${{ steps.meta-chat.outputs.tags }}
labels: ${{ steps.meta-chat.outputs.labels }} labels: ${{ steps.meta-chat.outputs.labels }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: git.allucanget.biz/zwitschi/linearvoid/frontend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push Frontend image
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
- name: Extract metadata for Nginx - name: Extract metadata for Nginx
id: meta-nginx id: meta-nginx
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
@@ -106,15 +88,15 @@ jobs:
- name: Build and push Nginx image - name: Build and push Nginx image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./infrastructure/nginx context: .
file: infrastructure/nginx/Dockerfile
push: true push: true
tags: ${{ steps.meta-nginx.outputs.tags }} tags: ${{ steps.meta-nginx.outputs.tags }}
labels: ${{ steps.meta-nginx.outputs.labels }} labels: ${{ steps.meta-nginx.outputs.labels }}
- name: Create Release - name: Create Release on Gitea
uses: softprops/action-gh-release@v1 run: |
with: curl -X POST "$CI_SERVER_URL/api/v1/repos/$CI_REPO_OWNER/$CI_REPO_NAME/releases" \
generate_release_notes: true -H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
tag_name: ${{ steps.version.outputs.version }} -H "Content-Type: application/json" \
env: -d "{\"tag_name\":\"${{ steps.version.outputs.version }}\",\"name\":\"${{ steps.version.outputs.version }}\",\"body\":\"Release ${{ steps.version.outputs.version }}\n\nSee [CHANGELOG.md](https://git.allucanget.biz/zwitschi/linearvoid/src/branch/main/CHANGELOG.md) for details.\",\"draft\":false,\"prerelease\":false}"
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
+10
View File
@@ -9,3 +9,13 @@ Agents instructions for the linearvoid project.
- Update documentation first before implementing changes to ensure traceability of decisions. - Update documentation first before implementing changes to ensure traceability of decisions.
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file. - Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning. - Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
## Security & Secrets Protection
- **Never commit raw secrets or credentials** to the repository.
- **Never print or log raw secrets** in terminal commands or output files.
- **Use Gitea Actions Secrets** for all production credentials and environment variables.
- **Avoid hardcoding sensitive values** in workflow files (`.yml`). Instead, reference them using `${{ secrets.SECRET_NAME }}`.
- **Do not use the `GITEA_` prefix** for custom secrets or environment variables in Gitea, as it is reserved internally and will be silently ignored or returned as empty. Use the `CI_` prefix instead (e.g., `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`).
- **Keep `.env` gitignored** and use `.env.example` as the template for local development.
- **For CD deployments**, generate the `.env` file dynamically on the runner using a secure step that populates values from Gitea Actions Secrets.
+4 -5
View File
@@ -50,6 +50,9 @@ data/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
!backend/playout/demodata/
# Docker # Docker
.dockerignore .dockerignore
**/.dockerignore **/.dockerignore
@@ -75,14 +78,10 @@ Thumbs.db
*.tfstate.backup *.tfstate.backup
.terraform/ .terraform/
# Monitoring & temp
prometheus/
grafana/
# Agent / Tool files # Agent / Tool files
.github/instructions/TODO.md .github/instructions/TODO.md
.github/instructions/DONE.md .github/instructions/DONE.md
# (keep AGENTS.md and other instruction files) # (keep AGENTS.md, MEMORY.md, and other instruction files)
# Temporary architecture artifacts # Temporary architecture artifacts
**/*.tmp **/*.tmp
+38 -1
View File
@@ -1,8 +1,45 @@
# Change Log for linearvoid # Change Log for linearvoid
<!-- markdownlint-disable MD024 -->
## [1.0.3] - 2026-08-03
### Added
- Added Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
### Changed
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
### Fixed
- Fixed nginx routing so `/metrics` is reachable through localhost and `linearvoid.tv`.
## [1.0.2] - 2026-08-03
### Changed
- Updated local environment documentation and examples to prefer real-domain local routing through `linearvoid.tv` and compose-safe internal service hostnames.
### Fixed
- Fixed admin production build output and nginx routing so `/admin/` serves its own entrypoint.
- Fixed static asset serving so hashed JS/CSS files return correct MIME types and stale asset URLs return `404` instead of SPA HTML.
- Fixed chat database configuration so `DATABASE_URL` is honored for compose-safe Postgres or SQLite local development.
## [1.0.1] - 2026-08-03
### Changed
- Expanded the setup guide with current `.env.example` variables, development prerequisites, Cloudflare R2 setup, Gitea registry setup, and a production deployment guide.
- Aligned frontend, nginx, and deployment docs with the `linearvoid.tv` app domain and `media.linearvoid.tv` HLS domain model.
## [1.0.0] - 2026-08-02 ## [1.0.0] - 2026-08-02
**Full Backend and Frontend Implementation Complete** ### Added
Full backend and frontend implementation complete.
- **Playout Service** (`backend/playout/`): Go-based playlist management, ffprobe normalization validation, continuous HLS with immutable segment naming, automatic R2 sync, health check endpoint, and Docker integration (ADR-001, ADR-003). - **Playout Service** (`backend/playout/`): Go-based playlist management, ffprobe normalization validation, continuous HLS with immutable segment naming, automatic R2 sync, health check endpoint, and Docker integration (ADR-001, ADR-003).
- **Chat Backend** (`backend/chat/`): Go + Gorilla WebSocket, Redis for presence/rate limiting/pub/sub, SQLite persistence for message history and ModerationLog (ADR-006/ADR-007), public rooms, private DM routing, moderation commands with immutable audit logging, health check endpoint, and docker-compose integration. - **Chat Backend** (`backend/chat/`): Go + Gorilla WebSocket, Redis for presence/rate limiting/pub/sub, SQLite persistence for message history and ModerationLog (ADR-006/ADR-007), public rooms, private DM routing, moderation commands with immutable audit logging, health check endpoint, and docker-compose integration.
+2 -1
View File
@@ -1,6 +1,6 @@
# Linear Void # Linear Void
A self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.). [Linear Void](https://linearvoid.tv) is a self-managed **24/7 linear video channel** for a primarily US audience. It runs a continuous playlist of self-owned videos with true/near-true synchronization, a custom lightweight viewer SPA (hls.js player + chat), and full independence from third-party platforms (YouTube, Twitch, etc.).
**Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid) **Repository**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
@@ -107,6 +107,7 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
## Development ## Development
- Use Docker Compose for local development (playout + chat + frontend). - Use Docker Compose for local development (playout + chat + frontend).
- Public production host model: `https://linearvoid.tv` for viewer/admin/chat and `https://media.linearvoid.tv` for HLS delivery.
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements). - Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
- Monitor cache hit ratio and R2 operations closely — they determine real cost. - Monitor cache hit ratio and R2 operations closely — they determine real cost.
+305 -20
View File
@@ -5,12 +5,31 @@ This guide covers prerequisites, environment setup, running the application loca
## Prerequisites ## Prerequisites
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com). - **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
- **Node.js 20+**: For frontend development (`cd frontend && npm run dev`). - **Node.js 20+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
- **Go 1.23+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows). - **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
- **Git**: For cloning and version control. - **Git**: For cloning and version control.
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases). - **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
- Add the following secrets:
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
- `ADMIN_PASSWORD`: A strong password for the production admin panel
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001). - **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
## Development Prerequisites
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`.
- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly.
- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly.
- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically.
- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow.
## Environment Setup ## Environment Setup
1. **Clone the Repository** (if not already done): 1. **Clone the Repository** (if not already done):
@@ -34,45 +53,148 @@ This guide covers prerequisites, environment setup, running the application loca
R2_SECRET_ACCESS_KEY=your-r2-secret-key R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=linearvoid-media R2_BUCKET_NAME=linearvoid-media
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
R2_REGION=auto
# Cloudflare (for custom domain and cache rules) # Cloudflare (for custom domain and cache rules)
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
MEDIA_DOMAIN=media.example.com CLOUDFLARE_ACCOUNT_ID=your-account-id
MEDIA_DOMAIN=media.linearvoid.tv
# Public frontend URLs
# Preferred local setup uses the real public domains through Cloudflare and Nginx Proxy Manager.
VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ADMIN_SESSION_TTL_HOURS=12
# Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
# Gitea Container Registry (for self-hosted Docker images) # Gitea Container Registry (for self-hosted Docker images)
GITEA_REGISTRY_URL=git.allucanget.biz CI_REGISTRY_URL=git.allucanget.biz
GITEA_USERNAME=your-gitea-username CI_REGISTRY_USER=your-gitea-username
GITEA_TOKEN=your-gitea-token CI_REGISTRY_TOKEN=your-gitea-token
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
# Gitea API access (optional, for CI/release automation)
CI_SERVER_URL=https://git.allucanget.biz
# Chat / Database # Chat / Database
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
# or for SQLite-only local development:
# DATABASE_URL=sqlite://./data/chat.db
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
DATABASE_URL=sqlite://./data/chat.db # or postgres://user:pass@db:5432/linearvoid
# Ports (for local development) # Ports (for local development)
PLAYOUT_PORT=8080
CHAT_PORT=3000 CHAT_PORT=3000
NGINX_PORT=8080 NGINX_PORT=80
PLAYOUT_HEALTH_PORT=8081
# Playout Configuration # Playout Configuration
PLAYLIST_PATH=/app/playlist/playlist.txt PLAYLIST_PATH=/app/playlist.txt
HLS_SEGMENT_DURATION=6 HLS_SEGMENT_DURATION=6
HLS_WINDOW_SIZE=24 HLS_WINDOW_SIZE=24
# Logging # Logging / Monitoring
LOG_LEVEL=info LOG_LEVEL=info
PROMETHEUS_PORT=9090
GRAFANA_PORT=3001
# Development flags
NODE_ENV=development
GO_ENV=development
``` ```
4. **Create Data Directories**: 4. **Check local value overrides for Docker Compose**:
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
5. **Create Data Directories**:
```bash ```bash
mkdir -p data/playlist data/media data/hls data/chat mkdir -p data/playlist data/media data/hls data/chat
``` ```
5. **Prepare Sample Videos** (optional): 6. **Prepare Sample Videos** (optional):
- Place normalized MP4 files in `data/media/`. - Place normalized MP4 files in `data/media/`.
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements). - Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
## Cloudflare R2 Setup
1. **Create an R2 bucket**:
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
2. **Generate R2 API credentials**:
- Create an access key pair with read/write permissions for the bucket.
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
3. **Find the S3 endpoint**:
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
4. **Bind a custom media domain**:
- Attach a domain such as `media.linearvoid.tv` to the bucket.
- Set `MEDIA_DOMAIN` to that hostname.
5. **Configure app-domain DNS and TLS**:
- Point `linearvoid.tv` to the public entrypoint serving nginx.
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
6. **Apply cache behavior**:
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
- Enable Smart Tiered Cache for the R2-backed hostname.
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
7. **Verify access**:
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
## Gitea Setup
1. **Create or verify a Gitea user**:
- The user should have access to the repository and permission to read from the container registry.
2. **Create an access token**:
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
3. **Configure Gitea Actions Secrets**:
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
- Add the following secrets to enable automated CI/CD and production deployment:
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
4. **Configure registry variables**:
- Set `CI_REGISTRY_URL` to the Gitea host.
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
5. **Authenticate Docker to the registry**:
```bash
docker login git.allucanget.biz
```
6. **Build and publish images when needed**:
- Local development uses `docker-compose.yml`, which builds from source.
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
7. **Validate image availability**:
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
## Running the Application ## Running the Application
### Local Development ### Local Development
@@ -84,9 +206,10 @@ This guide covers prerequisites, environment setup, running the application loca
``` ```
2. **Access the Application**: 2. **Access the Application**:
- Viewer: <http://localhost:8080> - Preferred local access with the documented proxy setup: <https://linearvoid.tv>
- Chat WebSocket: ws://localhost:3000/ws - Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
- Admin Interface: <http://localhost:8080/admin> (use the credentials from `.env` or default admin/admin) - Chat WebSocket: `wss://linearvoid.tv/ws`
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
- Health Checks: - Health Checks:
- Playout: <http://localhost:8081/health> - Playout: <http://localhost:8081/health>
- Chat: <http://localhost:3000/health> - Chat: <http://localhost:3000/health>
@@ -98,23 +221,184 @@ This guide covers prerequisites, environment setup, running the application loca
```bash ```bash
cd frontend cd frontend
npm install
npm run dev npm run dev
``` ```
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services. The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services.
4. **Stop Services**: 4. **Build frontend assets for nginx-backed local runs**:
```bash
cd frontend
npm install
npm run build
```
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
5. **Stop Services**:
```bash ```bash
docker compose down docker compose down
``` ```
### Local Test Environment Deployment (Remote Docker Host)
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket.
#### Test Environment Prerequisites
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux).
- Docker TCP socket enabled and listening on port `2375` on the remote server.
##### Enabling Docker TCP Socket on Alpine Linux
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket:
1. Edit `/etc/conf.d/docker` on the Alpine host:
```bash
vi /etc/conf.d/docker
```
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
```bash
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
```
3. Restart the Docker service:
```bash
rc-service docker restart
```
4. Verify that Docker is listening on port `2375`:
```bash
netstat -tlnp | grep 2375
```
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
#### Deployment Steps
1. **Configure Docker CLI to use the Remote Host**:
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
- **PowerShell (Windows)**:
```powershell
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
```
- **Bash (Linux/macOS)**:
```bash
export DOCKER_HOST="tcp://192.168.88.40:2375"
```
2. **Verify Connection**:
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
```bash
docker info
```
The output should show the remote server's hostname and system details.
3. **Deploy the Stack**:
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
- **Deploy from Registry (Production-like)**:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
```
- **Build and Deploy from Source**:
```bash
docker compose up -d --build
```
4. **Access the Test Environment**:
- Playout Health: `http://192.168.88.40:8081/health`
- Chat Health: `http://192.168.88.40:3000/health`
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
5. **Reset to Local Docker Host**:
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
- **PowerShell**:
```powershell
Remove-Item env:DOCKER_HOST
```
- **Bash**:
```bash
unset DOCKER_HOST
```
### Production Setup Guide
1. **Prepare the target host**:
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
2. **Clone the repository and create the environment file**:
```bash
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
cd linearvoid
cp .env.example .env
```
3. **Set production-safe `.env` values**:
- Replace placeholder secrets.
- Use compose-network hostnames for internal services, for example `redis` and `db`.
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
4. **Create runtime directories on the server**:
```bash
mkdir -p data/playlist data/media data/hls data/chat
```
5. **Publish or verify required images in Gitea**:
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
- Log in to the registry from the VPS before pulling.
6. **Deploy the production stack**:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
7. **Run post-deploy verification**:
- Check `docker compose -f docker-compose.prod.yml ps`.
- Confirm nginx, chat, and playout health endpoints respond.
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
8. **Harden and operate the deployment**:
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
### Production Deployment (Hetzner VPS + Cloudflare) ### Production Deployment (Hetzner VPS + Cloudflare)
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001). 1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
2. **Configure Cloudflare**: 2. **Configure Cloudflare**:
- Create R2 bucket with `enam` location hint. - Create R2 bucket with `enam` location hint.
- Set up custom domain (`media.example.com`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`. - Set up custom media domain (`media.linearvoid.tv`) with the cache rules from `infrastructure/cloudflare/cache-rules.md`.
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
- Use Full (strict) TLS for both app and media hostnames.
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management. - Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
3. **Deploy**: 3. **Deploy**:
- Copy the project to the VPS. - Copy the project to the VPS.
@@ -132,7 +416,8 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200. - **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard. - **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use. - **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
- **Admin Interface**: Ensure the protected route is configured in nginx and authentication is set up (default is open for local development). - **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
## Further Reading ## Further Reading
@@ -143,4 +428,4 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance. The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
**Last Updated**: 2026-08-02 **Last Updated**: 2026-08-03
+9 -9
View File
@@ -1,17 +1,17 @@
FROM golang:1.23-alpine AS builder FROM golang:1.23-alpine AS builder
WORKDIR /app RUN apk add --no-cache gcc musl-dev
COPY src/ ./src/
COPY go.mod go.sum ./
RUN go mod download WORKDIR /app
RUN CGO_ENABLED=0 GOOS=linux go build -o /chat-server ./src/main.go COPY go.mod ./
COPY src/ ./src/
RUN go mod tidy
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
FROM alpine:3.20 FROM alpine:3.20
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates curl curl
WORKDIR /app WORKDIR /app
COPY --from=builder /chat-server . COPY --from=builder /chat-server /usr/local/bin/chat-server
COPY config/ ./config/
EXPOSE 3000 EXPOSE 3000
CMD ["./chat-server"] CMD ["/usr/local/bin/chat-server"]
+9 -1
View File
@@ -1,10 +1,11 @@
module chat module chat
go 1.23 go 1.23.0
require ( require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0 github.com/redis/go-redis/v9 v9.7.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@@ -12,7 +13,14 @@ require (
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
) )
+52
View File
@@ -0,0 +1,52 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+916 -12
View File
@@ -2,16 +2,24 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -25,6 +33,8 @@ var (
} }
clients = make(map[*websocket.Conn]bool) clients = make(map[*websocket.Conn]bool)
mutex = sync.Mutex{} mutex = sync.Mutex{}
authSessions = map[string]time.Time{}
authSessionsMu = sync.Mutex{}
) )
// Message represents a chat message (persisted in SQLite per ADR-006) // Message represents a chat message (persisted in SQLite per ADR-006)
@@ -54,13 +64,133 @@ type Presence struct {
Online bool `json:"online"` Online bool `json:"online"`
} }
type AdminOverview struct {
Viewers int `json:"viewers"`
StoredMessages int64 `json:"storedMessages"`
ModerationActions int64 `json:"moderationActions"`
RedisEnabled bool `json:"redisEnabled"`
RedisClients int `json:"redisClients"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
DatabaseDriver string `json:"databaseDriver"`
GeneratedAt string `json:"generatedAt"`
}
type PlaylistPayload struct {
Content string `json:"content"`
}
type ModerationActionPayload struct {
Action string `json:"action"`
Target string `json:"target"`
Moderator string `json:"moderator"`
Reason string `json:"reason"`
Room string `json:"room"`
}
type ModerationLogsResponse struct {
Logs []ModerationLog `json:"logs"`
}
type RoomAnalytics struct {
Room string `json:"room"`
MessageCount int64 `json:"messageCount"`
}
type AdminAnalytics struct {
TotalMessages int64 `json:"totalMessages"`
MessagesLast24Hours int64 `json:"messagesLast24Hours"`
DistinctRooms int64 `json:"distinctRooms"`
ModerationActions int64 `json:"moderationActions"`
TopRooms []RoomAnalytics `json:"topRooms"`
GeneratedAt string `json:"generatedAt"`
}
type ServiceHealth struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
Checked string `json:"checked"`
}
type AdminSystemSummary struct {
DatabaseDriver string `json:"databaseDriver"`
PlaylistPath string `json:"playlistPath"`
PlaylistExists bool `json:"playlistExists"`
RedisEnabled bool `json:"redisEnabled"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
Services []ServiceHealth `json:"services"`
GeneratedAt string `json:"generatedAt"`
}
type ActiveUser struct {
Username string `json:"username"`
Room string `json:"room"`
Online bool `json:"online"`
}
type AdminUsersResponse struct {
Users []ActiveUser `json:"users"`
RedisEnabled bool `json:"redisEnabled"`
GeneratedAt string `json:"generatedAt"`
}
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
}
type AdminScheduleResponse struct {
Entries []ScheduleEntry `json:"entries"`
GeneratedAt string `json:"generatedAt"`
}
type MediaFileEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type AdminMediaResponse struct {
Files []MediaFileEntry `json:"files"`
GeneratedAt string `json:"generatedAt"`
}
type AdminLoginPayload struct {
Username string `json:"username"`
Password string `json:"password"`
}
type AdminSessionResponse struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username,omitempty"`
}
var databaseDriverName = "sqlite"
const adminSessionCookieName = "linearvoid_admin_session"
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007) // initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
func initRedis() { func initRedis() {
redisClient = redis.NewClient(&redis.Options{ redisAddress := os.Getenv("REDIS_URL")
Addr: os.Getenv("REDIS_URL"), if redisAddress == "" {
Password: "", redisAddress = "redis:6379"
DB: 0, }
})
var options *redis.Options
var err error
if strings.Contains(redisAddress, "://") {
options, err = redis.ParseURL(redisAddress)
if err != nil {
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
redisClient = nil
return
}
} else {
options = &redis.Options{Addr: redisAddress}
}
redisClient = redis.NewClient(options)
if err := redisClient.Ping(context.Background()).Err(); err != nil { if err := redisClient.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err) log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
redisClient = nil redisClient = nil
@@ -72,12 +202,773 @@ func initRedis() {
// initDB initializes SQLite for message history and moderation audit log (ADR-006) // initDB initializes SQLite for message history and moderation audit log (ADR-006)
func initDB() { func initDB() {
var err error var err error
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{}) databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if err != nil { driverName := "sqlite"
log.Fatal("Failed to connect to SQLite:", err)
switch {
case databaseURL == "":
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
case strings.HasPrefix(databaseURL, "sqlite://"):
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
if sqlitePath == "" {
sqlitePath = "chat.db"
}
db, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
driverName = "postgres"
db, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
default:
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
} }
if err != nil {
log.Fatalf("Failed to connect to %s database: %v", driverName, err)
}
databaseDriverName = driverName
db.AutoMigrate(&Message{}, &ModerationLog{}) db.AutoMigrate(&Message{}, &ModerationLog{})
log.Println("SQLite database initialized (chat history and moderation log)") log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
}
func parseRedisInfoValue(info, key string) string {
prefix := key + ":"
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func getAdminOverview() AdminOverview {
overview := AdminOverview{
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
DatabaseDriver: databaseDriverName,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
mutex.Lock()
overview.Viewers = len(clients)
mutex.Unlock()
if db != nil {
db.Model(&Message{}).Count(&overview.StoredMessages)
db.Model(&ModerationLog{}).Count(&overview.ModerationActions)
}
if redisClient != nil {
ctx := context.Background()
if info, err := redisClient.Info(ctx, "clients", "memory").Result(); err == nil {
if value := parseRedisInfoValue(info, "connected_clients"); value != "" {
if count, convErr := strconv.Atoi(value); convErr == nil {
overview.RedisClients = count
}
}
if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
overview.RedisMemoryUsed = value
}
}
}
return overview
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload AdminLoginPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
if payload.Username != getAdminUsername() || payload.Password != getAdminPassword() {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
token, err := generateSessionToken()
if err != nil {
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(getAdminSessionTTL())
authSessionsMu.Lock()
authSessions[token] = expiresAt
authSessionsMu.Unlock()
setAdminSessionCookie(w, r, token, expiresAt)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if cookie, err := r.Cookie(adminSessionCookieName); err == nil {
authSessionsMu.Lock()
delete(authSessions, cookie.Value)
authSessionsMu.Unlock()
}
clearAdminSessionCookie(w, r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
}
func handleAdminSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !validateAdminSession(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()})
}
func handleAdminOverview(w http.ResponseWriter, r *http.Request) {
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(getAdminOverview())
}
func getPlaylistPath() string {
playlistPath := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
if playlistPath == "" {
playlistPath = "/app/playlist/playlist.txt"
}
return playlistPath
}
func getMediaDir() string {
mediaDir := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
if mediaDir == "" {
mediaDir = "/app/media"
}
return mediaDir
}
func getAdminUsername() string {
username := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
if username == "" {
username = "admin"
}
return username
}
func getAdminPassword() string {
password := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if password == "" {
password = "admin"
}
return password
}
func getAdminSessionTTL() time.Duration {
if rawHours := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS")); rawHours != "" {
if hours, err := strconv.Atoi(rawHours); err == nil && hours > 0 {
return time.Duration(hours) * time.Hour
}
}
return 12 * time.Hour
}
func generateSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func sessionCookieSecure(r *http.Request) bool {
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
return strings.EqualFold(forwardedProto, "https")
}
return r.TLS != nil
}
func setAdminSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: sessionCookieSecure(r),
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
})
}
func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: sessionCookieSecure(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
func validateAdminSession(r *http.Request) bool {
cookie, err := r.Cookie(adminSessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
authSessionsMu.Lock()
defer authSessionsMu.Unlock()
expiresAt, ok := authSessions[cookie.Value]
if !ok {
return false
}
if time.Now().After(expiresAt) {
delete(authSessions, cookie.Value)
return false
}
return true
}
func requireAdminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !validateAdminSession(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func parsePlaylistEntries(content string) []ScheduleEntry {
entries := []ScheduleEntry{}
lines := strings.Split(content, "\n")
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
entries = append(entries, ScheduleEntry{
Index: len(entries) + 1,
Path: line,
Label: filepath.Base(line),
})
}
return entries
}
func handleAdminPlaylist(w http.ResponseWriter, r *http.Request) {
playlistPath := getPlaylistPath()
switch r.Method {
case http.MethodGet:
content, err := os.ReadFile(playlistPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PlaylistPayload{Content: string(content)})
case http.MethodPut:
var payload PlaylistPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
if err := os.MkdirAll(filepath.Dir(playlistPath), 0o755); err != nil {
http.Error(w, fmt.Sprintf("failed to ensure playlist directory: %v", err), http.StatusInternalServerError)
return
}
content := payload.Content
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := os.WriteFile(playlistPath, []byte(content), 0o644); err != nil {
http.Error(w, fmt.Sprintf("failed to save playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PlaylistPayload{Content: content})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
content, err := os.ReadFile(getPlaylistPath())
if err != nil {
http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminScheduleResponse{
Entries: parsePlaylistEntries(string(content)),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
})
}
func handleAdminModerationLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
if rawLimit := strings.TrimSpace(r.URL.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
}
var logs []ModerationLog
if err := db.Order("timestamp desc").Limit(limit).Find(&logs).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to load moderation logs: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ModerationLogsResponse{Logs: logs})
}
func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload ModerationActionPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
payload.Action = strings.TrimSpace(payload.Action)
payload.Target = strings.TrimSpace(payload.Target)
payload.Moderator = strings.TrimSpace(payload.Moderator)
payload.Reason = strings.TrimSpace(payload.Reason)
payload.Room = strings.TrimSpace(payload.Room)
if payload.Action == "" || payload.Target == "" || payload.Reason == "" {
http.Error(w, "action, target, and reason are required", http.StatusBadRequest)
return
}
if payload.Moderator == "" {
payload.Moderator = "admin"
}
if payload.Room == "" {
payload.Room = "main"
}
logModerationAction(payload.Action, payload.Target, payload.Moderator, payload.Reason)
broadcast(Message{
Room: payload.Room,
Username: "system",
Type: "system",
Text: fmt.Sprintf("Moderator %s %s %s for: %s", payload.Moderator, payload.Action, payload.Target, payload.Reason),
Timestamp: time.Now(),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}
func getAdminAnalytics() AdminAnalytics {
analytics := AdminAnalytics{
TopRooms: []RoomAnalytics{},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if db == nil {
return analytics
}
db.Model(&Message{}).Count(&analytics.TotalMessages)
db.Model(&Message{}).
Where("timestamp >= ?", time.Now().Add(-24*time.Hour)).
Count(&analytics.MessagesLast24Hours)
db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms)
db.Model(&ModerationLog{}).Count(&analytics.ModerationActions)
var topRooms []RoomAnalytics
db.Model(&Message{}).
Select("room, COUNT(*) as message_count").
Group("room").
Order("message_count DESC").
Limit(5).
Scan(&topRooms)
analytics.TopRooms = topRooms
return analytics
}
func handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
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(getAdminAnalytics())
}
func checkServiceHealth(name, url string) ServiceHealth {
checked := time.Now().UTC().Format(time.RFC3339)
resp, err := http.Get(url)
if err != nil {
return ServiceHealth{Name: name, Status: "down", Detail: err.Error(), Checked: checked}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
status := "healthy"
if resp.StatusCode >= 400 {
status = "degraded"
}
detail := strings.TrimSpace(string(body))
if detail == "" {
detail = resp.Status
}
return ServiceHealth{Name: name, Status: status, Detail: detail, Checked: checked}
}
func getAdminSystemSummary() AdminSystemSummary {
summary := AdminSystemSummary{
DatabaseDriver: databaseDriverName,
PlaylistPath: getPlaylistPath(),
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if _, err := os.Stat(summary.PlaylistPath); err == nil {
summary.PlaylistExists = true
}
if redisClient != nil {
if info, err := redisClient.Info(context.Background(), "memory").Result(); err == nil {
if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
summary.RedisMemoryUsed = value
}
}
}
summary.Services = []ServiceHealth{
{Name: "chat", Status: "healthy", Detail: "local process", Checked: summary.GeneratedAt},
checkServiceHealth("playout", "http://playout:8081/health"),
}
if redisClient != nil {
if err := redisClient.Ping(context.Background()).Err(); err != nil {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
}
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "disabled", Detail: "client unavailable", Checked: summary.GeneratedAt})
}
if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Ping(); err != nil {
summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
}
}
return summary
}
func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) {
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(getAdminSystemSummary())
}
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
response := AdminUsersResponse{
Users: []ActiveUser{},
RedisEnabled: redisClient != nil,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if redisClient != nil {
ctx := context.Background()
keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result()
if err != nil {
http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
return
}
for _, key := range keys {
parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 {
continue
}
response.Users = append(response.Users, ActiveUser{
Room: parts[1],
Username: parts[2],
Online: true,
})
}
}
sort.Slice(response.Users, func(i, j int) bool {
if response.Users[i].Room == response.Users[j].Room {
return response.Users[i].Username < response.Users[j].Username
}
return response.Users[i].Room < response.Users[j].Room
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func listMediaFiles() ([]MediaFileEntry, error) {
mediaDir := getMediaDir()
entries, err := os.ReadDir(mediaDir)
if err != nil {
return nil, err
}
files := make([]MediaFileEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, MediaFileEntry{
Name: entry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
return files, nil
}
func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
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)})
case http.MethodPost:
if err := r.ParseMultipartForm(256 << 20); err != nil {
http.Error(w, fmt.Sprintf("failed to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, fmt.Sprintf("missing file upload: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
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))
targetFile, err := os.Create(targetPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
return
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, file); err != nil {
http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
return
}
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.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func countConnectedClients() int {
mutex.Lock()
defer mutex.Unlock()
return len(clients)
}
func countActiveAdminSessions() int {
now := time.Now()
authSessionsMu.Lock()
defer authSessionsMu.Unlock()
active := 0
for token, expiresAt := range authSessions {
if now.After(expiresAt) {
delete(authSessions, token)
continue
}
active++
}
return active
}
func writePrometheusMetricLine(w io.Writer, name string, value any) {
fmt.Fprintf(w, "%s %v\n", name, value)
}
func handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var storedMessages int64
if err := db.Model(&Message{}).Count(&storedMessages).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to count messages: %v", err), http.StatusInternalServerError)
return
}
var moderationActions int64
if err := db.Model(&ModerationLog{}).Count(&moderationActions).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to count moderation actions: %v", err), http.StatusInternalServerError)
return
}
redisEnabled := 0
redisUp := 0
redisClients := 0
if redisClient != nil {
redisEnabled = 1
if err := redisClient.Ping(context.Background()).Err(); err == nil {
redisUp = 1
if info, err := redisClient.Info(context.Background(), "clients").Result(); err == nil {
for _, line := range strings.Split(info, "\n") {
if strings.HasPrefix(line, "connected_clients:") {
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
redisClients = value
}
break
}
}
}
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
writePrometheusMetricLine(w, "linearvoid_chat_connected_clients", countConnectedClients())
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages)
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
writePrometheusMetricLine(w, "linearvoid_chat_moderation_actions_total", moderationActions)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_enabled", redisEnabled)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_up", redisUp)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_connected_clients", redisClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writePrometheusMetricLine(w, "linearvoid_chat_admin_sessions_active", countActiveAdminSessions())
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\"} 1")
} }
// broadcast sends a message to all connected clients in a room and saves to DB // broadcast sends a message to all connected clients in a room and saves to DB
@@ -110,7 +1001,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
// Announce presence (ADR-007) // Announce presence (ADR-007)
username := r.URL.Query().Get("username") username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room") room := r.URL.Query().Get("room")
if username != "" && room != "" { if redisClient != nil && username != "" && room != "" {
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second) redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"}) broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"})
} }
@@ -119,7 +1010,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
mutex.Lock() mutex.Lock()
delete(clients, conn) delete(clients, conn)
mutex.Unlock() mutex.Unlock()
if username != "" && room != "" { if redisClient != nil && username != "" && room != "" {
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username)) redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"}) broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"})
} }
@@ -219,6 +1110,19 @@ func main() {
initDB() initDB()
http.HandleFunc("/ws", handleConnection) http.HandleFunc("/ws", handleConnection)
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/session", handleAdminSession)
http.HandleFunc("/api/admin/overview", requireAdminAuth(handleAdminOverview))
http.HandleFunc("/api/admin/playlist", requireAdminAuth(handleAdminPlaylist))
http.HandleFunc("/api/admin/moderation/logs", requireAdminAuth(handleAdminModerationLogs))
http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction))
http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics))
http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary))
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
http.HandleFunc("/metrics", handleMetrics)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -230,6 +1134,6 @@ func main() {
port = "3000" port = "3000"
} }
log.Printf("Chat Backend starting on :%s (WebSocket at /ws, health at /health, SQLite persistence enabled)", port) log.Printf("Chat Backend starting on :%s (WebSocket at /ws, health at /health, database persistence enabled)", port)
log.Fatal(http.ListenAndServe(":"+port, nil)) log.Fatal(http.ListenAndServe(":"+port, nil))
} }
+9 -12
View File
@@ -1,8 +1,7 @@
FROM golang:1.23-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
COPY src/ ./src/ COPY src/ ./src/
COPY go.mod go.sum ./
# Initialize Go module if not present # Initialize Go module if not present
RUN go mod init playout || true RUN go mod init playout || true
@@ -17,20 +16,18 @@ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
curl \ curl \
bash bash
# Set working directory for playout
WORKDIR /app WORKDIR /app
# Copy the playout start script and the built playlist manager binary
COPY --from=builder /playlist-manager /app/playlist-manager
COPY start-playout.sh /app/ COPY start-playout.sh /app/
COPY *.sh /app/ # Copy demo data for out-of-the-box playout
COPY demodata/ /app/
RUN chmod +x /app/*.sh /app/playlist-manager # 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 # Create directories for playlist, media, and HLS output
RUN mkdir -p /app/playlist /app/media /app/hls RUN mkdir -p /app/hls
RUN chmod +x /app/start-playout.sh /app/playlist-manager
# Expose HLS directory (will be served by nginx) # Expose HLS directory (will be served by nginx)
VOLUME ["/app/hls"] VOLUME ["/app/hls"]
# Default command runs the playout loop (which calls the Go validator) # Default command runs the playout loop (which calls the Go validator)
CMD ["./start-playout.sh"] CMD ["./start-playout.sh"]
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
# Linear Void Demo Playlist
file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
+70 -6
View File
@@ -64,6 +64,12 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
videos = append(videos, VideoFile{Path: line}) videos = append(videos, VideoFile{Path: line})
} }
@@ -75,7 +81,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
v := VideoFile{Path: videoPath} v := VideoFile{Path: videoPath}
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath) cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
output, err := cmd.Output() _, err := cmd.Output()
if err != nil { if err != nil {
v.Validation = "ffprobe failed: " + err.Error() v.Validation = "ffprobe failed: " + err.Error()
return v, err return v, err
@@ -137,6 +143,46 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
} }
func resetHLSOutput(hlsDir string) error {
if err := os.MkdirAll(hlsDir, 0o755); err != nil {
return err
}
matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
if err != nil {
return err
}
for _, match := range matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
if err != nil {
return err
}
for _, match := range fmp4Matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
return err
}
initPath := filepath.Join(hlsDir, "init.mp4")
if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func main() { func main() {
playlistPath := "/app/playlist/playlist.txt" playlistPath := "/app/playlist/playlist.txt"
if len(os.Args) > 1 { if len(os.Args) > 1 {
@@ -170,15 +216,29 @@ func main() {
// Run FFmpeg in a loop for continuous playback with R2 sync hook // Run FFmpeg in a loop for continuous playback with R2 sync hook
for { for {
fmt.Printf("[%s] Starting FFmpeg playout with -c copy (immutable segments for Cloudflare caching)...\n", time.Now().Format(time.RFC3339)) if err := resetHLSOutput("/app/hls"); err != nil {
fmt.Println("Failed to reset HLS output:", err)
time.Sleep(5 * time.Second)
continue
}
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath, cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
"-c:v", "copy", "-c:a", "copy", "-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
"-f", "hls", "-f", "hls",
"-hls_time", "6", "-hls_time", "6",
"-hls_list_size", "24", "-hls_list_size", "24",
"-hls_flags", "delete_segments+append_list+independent_segments", "-hls_segment_type", "fmp4",
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S_%%03d.ts", // unique immutable names per ADR-001 "-hls_fmp4_init_filename", "init.mp4",
"-strftime", "1",
"-hls_flags", "delete_segments+independent_segments",
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
"/app/hls/playlist.m3u8") "/app/hls/playlist.m3u8")
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@@ -196,7 +256,7 @@ func main() {
// syncLatestToR2 uploads new segments and the playlist manifest to R2 // syncLatestToR2 uploads new segments and the playlist manifest to R2
func syncLatestToR2(hlsDir string) error { func syncLatestToR2(hlsDir string) error {
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts")) files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
if err != nil { if err != nil {
return err return err
} }
@@ -215,6 +275,10 @@ func syncLatestToR2(hlsDir string) error {
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil { if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
return err return err
} }
initPath := filepath.Join(hlsDir, "init.mp4")
if err := SyncToR2(initPath, "live/init.mp4"); err != nil {
return err
}
fmt.Println("Synced live playlist.m3u8 to R2") fmt.Println("Synced live playlist.m3u8 to R2")
return nil return nil
+15 -4
View File
@@ -26,16 +26,27 @@ fi
echo "Playlist validated successfully. Starting continuous HLS playout..." echo "Playlist validated successfully. Starting continuous HLS playout..."
mkdir -p /app/hls
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
# Run FFmpeg in a loop for continuous playback # Run FFmpeg in a loop for continuous playback
while true; do while true; do
echo "[$(date)] Starting FFmpeg playout with -c copy (stream copy mode)..." echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \ ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
-c:v copy -c:a copy \ -map 0:v:0 -map 0:a:0? \
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
-profile:v high -level 4.0 \
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-c:a aac -ar 48000 -ac 2 -b:a 128k \
-f hls \ -f hls \
-hls_time "${HLS_SEGMENT_DURATION}" \ -hls_time "${HLS_SEGMENT_DURATION}" \
-hls_list_size "${HLS_WINDOW_SIZE}" \ -hls_list_size "${HLS_WINDOW_SIZE}" \
-hls_flags delete_segments+append_list \ -hls_segment_type fmp4 \
-hls_segment_filename "/app/hls/segment_%03d.ts" \ -hls_fmp4_init_filename init.mp4 \
-strftime 1 \
-hls_flags delete_segments+independent_segments \
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..." "/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
echo "Playout loop crashed or ended. Restarting in 5 seconds..." echo "Playout loop crashed or ended. Restarting in 5 seconds..."
+6 -3
View File
@@ -6,7 +6,7 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ./data/playlist:/app/playlist:ro - ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:ro - ./data/media:/app/media:ro
- ./data/hls:/app/hls:rw - ./data/hls:/app/hls:rw
networks: networks:
@@ -30,6 +30,8 @@ services:
- "${CHAT_PORT:-3000}:3000" - "${CHAT_PORT:-3000}:3000"
volumes: volumes:
- ./data/chat:/app:rw - ./data/chat:/app:rw
- ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:rw
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
@@ -49,7 +51,7 @@ services:
ports: ports:
- "${NGINX_PORT:-8080}:80" - "${NGINX_PORT:-8080}:80"
volumes: volumes:
- ./data/hls:/usr/share/nginx/html/hls:ro - ./data/hls:/srv/hls:ro
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
@@ -100,7 +102,7 @@ services:
container_name: linearvoid-prometheus container_name: linearvoid-prometheus
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-config:/etc/prometheus
- prometheus-data:/prometheus - prometheus-data:/prometheus
ports: ports:
- "9090:9090" - "9090:9090"
@@ -140,4 +142,5 @@ volumes:
redis-data: redis-data:
postgres-data: postgres-data:
prometheus-data: prometheus-data:
prometheus-config:
grafana-data: grafana-data:
+9 -9
View File
@@ -1,8 +1,6 @@
version: "3.9"
services: services:
playout: playout:
image: ${GITEA_REGISTRY_URL:-git.allucanget.biz}/${GITEA_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/playout:latest
build: build:
context: ./backend/playout context: ./backend/playout
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -26,7 +24,7 @@ services:
start_period: 10s start_period: 10s
chat: chat:
image: ${GITEA_REGISTRY_URL:-git.allucanget.biz}/${GITEA_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/chat:latest image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/chat:latest
build: build:
context: ./backend/chat context: ./backend/chat
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -38,6 +36,8 @@ services:
- "${CHAT_PORT:-3000}:3000" - "${CHAT_PORT:-3000}:3000"
volumes: volumes:
- ./data/chat:/app:rw - ./data/chat:/app:rw
- ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:rw
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
@@ -51,17 +51,17 @@ services:
start_period: 10s start_period: 10s
nginx: nginx:
image: ${GITEA_REGISTRY_URL:-git.allucanget.biz}/${GITEA_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/nginx:latest image: ${CI_REGISTRY_URL:-git.allucanget.biz}/${CI_REGISTRY_NAMESPACE:-zwitschi/linearvoid}/nginx:latest
build: build:
context: ./infrastructure/nginx context: .
dockerfile: Dockerfile dockerfile: infrastructure/nginx/Dockerfile
container_name: linearvoid-nginx container_name: linearvoid-nginx
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${NGINX_PORT:-8080}:80" - "${NGINX_PORT:-8080}:80"
volumes: volumes:
- ./frontend/dist:/usr/share/nginx/html:ro - ./frontend/dist:/srv/frontend:ro
- ./data/hls:/usr/share/nginx/html/hls:ro - ./data/hls:/srv/hls:ro
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
+59
View File
@@ -217,6 +217,65 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
## Deployment View ## Deployment View
### CI/CD Pipeline Architecture
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them to the production host.
```mermaid
flowchart LR
subgraph "Gitea Actions"
CI[CI Workflow<br/>Build + Push Images]
CD[CD Workflow<br/>Pull + Deploy]
end
subgraph "Gitea Container Registry"
REGISTRY[git.allucanget.biz<br/>playout:latest<br/>chat:latest<br/>nginx:latest]
end
subgraph "Production Host (192.168.88.40)"
DOCKER[Docker Engine<br/>via TCP :2375]
PLAYOUT[linearvoid-playout]
CHAT[linearvoid-chat]
NGINX[linearvoid-nginx]
REDIS[linearvoid-redis]
DB[linearvoid-db]
PROM[linearvoid-prometheus]
GRAF[linearvoid-grafana]
end
CI -->|push| REGISTRY
REGISTRY -->|pull| CD
CD -->|deploy via DOCKER_HOST| DOCKER
DOCKER --> PLAYOUT
DOCKER --> CHAT
DOCKER --> NGINX
DOCKER --> REDIS
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:**
1. **CI Workflow** (`.gitea/workflows/ci.yml`):
- Triggers on `push` to `main` or `pull_request`
- Runs `test` job: frontend lint/build, Go validation, `docker compose build`
- Runs `docker` job (after `test` passes, only on `main` push):
- Builds `playout`, `chat`, `nginx` images with Buildx
- Pushes to Gitea Container Registry with `main` and `latest` tags
2. **CD Workflow** (`.gitea/workflows/cd.yml`):
- Triggers on `workflow_run` when CI completes successfully, or manually via `workflow_dispatch`
- Pulls latest images from registry
- Generates `.env` from secrets
- Seeds Prometheus config into named volume
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375`)
- Runs health checks with retry logic for playout initialization
### Infrastructure Level 1 ### Infrastructure Level 1
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN). **Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN).
+34
View File
@@ -0,0 +1,34 @@
# Brand Research: "Linear Void"
## 1. Trademark & Brand Availability (US Market)
Based on searches of the USPTO database and general web usage, **"Linear Void" is highly viable for a video streaming platform.**
Trademark rights are granted based on specific classes of goods and services. Currently, there are no registered trademarks or active companies using "Linear Void" in the media, broadcasting, or entertainment spaces.
**Existing uses of the phrase (Non-conflicting):**
- **Software / Engineering:** There is a well-known CAD/BIM software plugin called **"LINEAR Void Manager"** developed by a German company (LINEAR GmbH) for architects to plan wall openings (voids) in HVAC design. This falls under structural engineering software and does not conflict with media broadcasting.
- **Astrophysics / Science:** "Linear void" is a common scientific term used in astrophysics to describe the evolution of underdense regions in the universe (cosmic voids).
- **Music:** There is an electronic music album titled _Linear Void_. Album titles alone do not typically constitute a conflicting brand trademark.
**Trademark Strategy:** Because your product is a self-managed video channel, you would likely file under **Class 38** (Telecommunications / Video broadcasting) and **Class 41** (Entertainment services / Providing non-downloadable videos). You should have a clear path to registration in the US since no one is using the name in these classes.
## 2. Domain Name
Your custom SPA setup requires a strong, memorable domain. We registered `linearvoid.tv` for this purpose. The `.tv` TLD is widely recognized as a media/entertainment domain, making it ideal for your platform.
## 3. SEO & Market Positioning
**Brand Fit:**
The name "Linear Void" is an excellent thematic fit for your project. "Linear" directly communicates the 24/7 continuous broadcast format (as opposed to VOD), while "Void" implies an independent, deep-internet, or alternative space—perfect for a platform rejecting third-party ecosystems like YouTube or Twitch.
**Search Engine Optimization (SEO):**
Currently, searching for "Linear Void" brings up highly niche results (the Revit architecture plugin, medical patents, and astrophysics papers).
- **The Advantage:** You are not competing with any established consumer brands, pop culture entities, or other media companies.
- **The Execution:** Because the existing search results are so strictly confined to academic and B2B engineering niches, an active video platform with decent metadata, a steady stream of traffic, and proper indexing of your HLS stream titles will very quickly overtake the front page of Google for the term "Linear Void".
## Next Steps
- **USPTO Filing:** Prepare filing an "Intent to Use" (1B) trademark application with the USPTO for "Linear Void" under Classes 38 and 41 to lock in your rights before launch.
+702 -32
View File
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="../src/style.css" /> <link rel="stylesheet" href="../src/style.css" />
</head> </head>
<body class="bg-slate-950 text-slate-200"> <body class="bg-slate-950 text-slate-200" 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%">
<div class="flex h-screen" x-data="admin()"> <div class="flex h-screen" x-data="admin()">
<!-- Sidebar --> <!-- Sidebar -->
<div class="w-64 bg-slate-900 p-6"> <div class="w-64 bg-slate-900 p-6">
@@ -18,15 +18,18 @@
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div> <div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
<div class="text-xl font-bold">Admin</div> <div class="text-xl font-bold">Admin</div>
</div> </div>
<div class="mb-6">
<button @click="logout()" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 px-4 py-2 rounded-xl text-sm text-left">Sign out</button>
</div>
<nav class="space-y-2"> <nav class="space-y-2">
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a> <a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
<a href="#" @click="currentTab = 'upload'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a> <a href="#" @click="currentTab = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
<a href="#" @click="currentTab = 'playlist'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a> <a href="#" @click="currentTab = 'playlist'; refreshPlaylist()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
<a href="#" @click="currentTab = 'schedule'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a> <a href="#" @click="currentTab = 'schedule'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
<a href="#" @click="currentTab = 'moderation'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a> <a href="#" @click="currentTab = 'moderation'; refreshModerationLogs()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
<a href="#" @click="currentTab = 'users'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a> <a href="#" @click="currentTab = 'users'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
<a href="#" @click="currentTab = 'analytics'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a> <a href="#" @click="currentTab = 'analytics'; refreshAnalytics()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
<a href="#" @click="currentTab = 'system'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a> <a href="#" @click="currentTab = 'system'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
</nav> </nav>
</div> </div>
@@ -41,26 +44,272 @@
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div> <div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
</div> </div>
<div class="bg-slate-800 p-6 rounded-3xl"> <div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-blue-400 text-sm">Cache Hit Ratio</div> <div class="text-blue-400 text-sm">Stored Messages</div>
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div> <div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
</div> </div>
<div class="bg-slate-800 p-6 rounded-3xl"> <div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-amber-400 text-sm">R2 Usage</div> <div class="text-amber-400 text-sm">Moderation Actions</div>
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div> <div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
</div> </div>
</div> </div>
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
</div> </div>
</template> </template>
<template x-if="currentTab === 'upload'"> <template x-if="currentTab === 'upload'">
<div> <div>
<h1 class="text-3xl font-bold mb-8">Upload Video</h1> <div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Upload Video</h1>
<p class="text-sm text-slate-400 mt-2">Upload media files into the local media library mounted for playout.</p>
</div>
<button @click="refreshMediaLibrary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload library</button>
</div>
<div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center"> <div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center">
<input type="file" id="video-upload" class="hidden" accept="video/*" @change="uploadVideo"> <input type="file" id="video-upload" class="hidden" accept="video/*,.mp4,.mov,.mkv,.webm" @change="uploadVideo">
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium"> <button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium" :disabled="media.uploading">
Drag & Drop or Click to Upload Drag & Drop or Click to Upload
</button> </button>
<div id="upload-status" class="mt-6 text-sm"></div> <div class="mt-6 text-sm" :class="media.error ? 'text-rose-400' : 'text-slate-400'" x-text="media.status"></div>
</div>
<div class="bg-slate-800 rounded-3xl p-6 mt-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Media Library</h2>
<div class="text-xs text-slate-400" x-text="media.generatedAt"></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">File</th>
<th class="text-left py-3 pr-4 font-medium">Size</th>
<th class="text-left py-3 font-medium">Modified</th>
</tr>
</thead>
<tbody>
<template x-if="media.files.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
</tr>
</template>
<template x-for="file in media.files" :key="file.name">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="file.name"></td>
<td class="py-3 pr-4" x-text="file.size"></td>
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template x-if="currentTab === 'schedule'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Schedule</h1>
<p class="text-sm text-slate-400 mt-2">Read-only view of the currently configured playout order.</p>
</div>
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">#</th>
<th class="text-left py-3 pr-4 font-medium">Label</th>
<th class="text-left py-3 font-medium">Path</th>
</tr>
</thead>
<tbody>
<template x-if="schedule.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
</tr>
</template>
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.index"></td>
<td class="py-3 pr-4" x-text="entry.label"></td>
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="schedule.error ? 'text-rose-400' : 'text-slate-400'" x-text="schedule.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'playlist'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Playlist</h1>
<p class="text-sm text-slate-400 mt-2">Edit the active concat playlist used by the playout service.</p>
</div>
<div class="flex gap-3">
<button @click="refreshPlaylist()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload</button>
<button @click="savePlaylist()" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-xl text-sm font-medium" :disabled="playlist.saving">
<span x-text="playlist.saving ? 'Saving...' : 'Save playlist'"></span>
</button>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<textarea x-model="playlist.content" class="w-full min-h-[24rem] bg-slate-950 border border-slate-700 rounded-2xl p-4 font-mono text-sm focus:outline-none focus:border-blue-500" spellcheck="false"></textarea>
<div class="mt-4 text-sm" :class="playlist.error ? 'text-rose-400' : 'text-slate-400'" x-text="playlist.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'moderation'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Moderation</h1>
<p class="text-sm text-slate-400 mt-2">Create moderation actions and review the recent immutable audit log.</p>
</div>
<button @click="refreshModerationLogs()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload logs</button>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[24rem,1fr] gap-6">
<div class="bg-slate-800 rounded-3xl p-6">
<h2 class="text-lg font-semibold mb-4">New Action</h2>
<div class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Action</span>
<select x-model="moderation.form.action" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500">
<option value="mute">mute</option>
<option value="ban">ban</option>
<option value="delete">delete</option>
<option value="timeout">timeout</option>
</select>
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Target</span>
<input x-model="moderation.form.target" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="username or message id">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Moderator</span>
<input x-model="moderation.form.moderator" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="admin">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Room</span>
<input x-model="moderation.form.room" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="main">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Reason</span>
<textarea x-model="moderation.form.reason" class="w-full min-h-28 bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="Explain why this action is needed"></textarea>
</label>
<button @click="submitModerationAction()" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium" :disabled="moderation.submitting">
<span x-text="moderation.submitting ? 'Submitting...' : 'Submit action'"></span>
</button>
<div class="text-sm" :class="moderation.error ? 'text-rose-400' : 'text-slate-400'" x-text="moderation.status"></div>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6 overflow-hidden">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Recent Audit Log</h2>
<div class="text-xs text-slate-400">Latest 50 actions</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">When</th>
<th class="text-left py-3 pr-4 font-medium">Action</th>
<th class="text-left py-3 pr-4 font-medium">Target</th>
<th class="text-left py-3 pr-4 font-medium">Moderator</th>
<th class="text-left py-3 font-medium">Reason</th>
</tr>
</thead>
<tbody>
<template x-if="moderation.logs.length === 0">
<tr>
<td colspan="5" class="py-6 text-slate-500">No moderation actions logged yet.</td>
</tr>
</template>
<template x-for="entry in moderation.logs" :key="entry.id">
<tr class="border-b border-slate-800 align-top">
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.timestamp"></td>
<td class="py-3 pr-4" x-text="entry.action"></td>
<td class="py-3 pr-4" x-text="entry.target"></td>
<td class="py-3 pr-4" x-text="entry.moderator"></td>
<td class="py-3" x-text="entry.reason"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<template x-if="currentTab === 'analytics'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
<p class="text-sm text-slate-400 mt-2">Basic activity counters from chat history and moderation audit logs.</p>
</div>
<button @click="refreshAnalytics()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload analytics</button>
</div>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Total Messages</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.totalMessages"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Last 24 Hours</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.messagesLast24Hours"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Distinct Rooms</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.distinctRooms"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Moderation Actions</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.moderationActions"></div>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Top Rooms</h2>
<div class="text-xs text-slate-400" x-text="analytics.generatedAt"></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">Room</th>
<th class="text-left py-3 font-medium">Messages</th>
</tr>
</thead>
<tbody>
<template x-if="analytics.topRooms.length === 0">
<tr>
<td colspan="2" class="py-6 text-slate-500">No room analytics available yet.</td>
</tr>
</template>
<template x-for="room in analytics.topRooms" :key="room.room">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="room.room"></td>
<td class="py-3" x-text="room.messageCount"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="analytics.error ? 'text-rose-400' : 'text-slate-400'" x-text="analytics.status"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -75,15 +324,15 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span>Memory Used</span> <span>Memory Used</span>
<span class="font-mono">248 MB</span> <span class="font-mono" x-text="system.redisMemoryUsed"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Connected Clients</span> <span>Connected Redis Clients</span>
<span class="font-mono">12</span> <span class="font-mono" x-text="system.redisClients"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Rate Limit Threshold</span> <span>Redis Enabled</span>
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center"> <span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -91,41 +340,462 @@
<h3 class="font-medium mb-4">Storage</h3> <h3 class="font-medium mb-4">Storage</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span>SQLite Size</span> <span>Database Driver</span>
<span class="font-mono">142 MB</span> <span class="font-mono" x-text="system.databaseDriver"></span>
</div>
<div class="flex justify-between">
<span>Stored Messages</span>
<span class="font-mono" x-text="stats.storedMessages"></span>
</div>
<div class="flex justify-between">
<span>Last Refresh</span>
<span class="font-mono" x-text="system.generatedAt"></span>
</div>
<div class="flex justify-between">
<span>Playlist Path</span>
<span class="font-mono text-right" x-text="system.playlistPath"></span>
</div>
<div class="flex justify-between">
<span>Playlist Present</span>
<span class="font-mono" x-text="system.playlistExists ? 'yes' : 'no'"></span>
</div> </div>
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button> <button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-slate-800 p-8 rounded-3xl mt-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Service Health</h2>
<button @click="refreshSystemSummary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload health</button>
</div>
<div class="space-y-4">
<template x-for="service in system.services" :key="service.name">
<div class="flex items-start justify-between gap-6 border-b border-slate-700 pb-4">
<div>
<div class="font-medium" x-text="service.name"></div>
<div class="text-xs text-slate-400 mt-1" x-text="service.checked"></div>
</div>
<div class="text-right max-w-xl">
<div class="uppercase tracking-wide text-xs" :class="service.status === 'healthy' ? 'text-emerald-400' : service.status === 'disabled' ? 'text-amber-400' : 'text-rose-400'" x-text="service.status"></div>
<div class="text-sm text-slate-300 mt-1 break-all" x-text="service.detail"></div>
</div>
</div>
</template>
</div>
<div class="mt-4 text-sm" :class="system.error ? 'text-rose-400' : 'text-slate-400'" x-text="system.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'users'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Users</h1>
<p class="text-sm text-slate-400 mt-2">Current room presence derived from Redis presence keys.</p>
</div>
<button @click="refreshUsers()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload users</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">Username</th>
<th class="text-left py-3 pr-4 font-medium">Room</th>
<th class="text-left py-3 font-medium">Online</th>
</tr>
</thead>
<tbody>
<template x-if="users.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No active users reported.</td>
</tr>
</template>
<template x-for="entry in users.entries" :key="entry.room + ':' + entry.username">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.username"></td>
<td class="py-3 pr-4" x-text="entry.room"></td>
<td class="py-3" x-text="entry.online ? 'yes' : 'no'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="users.error ? 'text-rose-400' : 'text-slate-400'" x-text="users.status"></div>
</div>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<script> <script>
const adminConfig = (() => {
const { dataset } = document.body;
const moderationLogLimit = Number.parseInt(dataset.adminModerationLogLimit || '', 10);
return {
defaultModerator: dataset.adminDefaultModerator || 'admin',
defaultRoom: dataset.adminDefaultRoom || 'main',
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50
};
})();
function admin() { function admin() {
return { return {
currentTab: 'dashboard', currentTab: 'dashboard',
stats: { stats: {
viewers: 142, viewers: 0,
cacheHit: 97, storedMessages: 0,
r2Usage: '18.4 GB' moderationActions: 0
},
system: {
redisEnabled: false,
redisClients: 0,
redisMemoryUsed: 'loading...',
databaseDriver: 'loading...',
generatedAt: 'loading...',
playlistPath: 'loading...',
playlistExists: false,
services: [],
status: 'Loading system summary...',
error: false
},
analytics: {
totalMessages: 0,
messagesLast24Hours: 0,
distinctRooms: 0,
moderationActions: 0,
topRooms: [],
generatedAt: 'loading...',
status: 'Loading analytics...',
error: false
},
schedule: {
entries: [],
status: 'Loading schedule...',
error: false
},
playlist: {
content: '',
status: 'Loading playlist...',
saving: false,
error: false
},
media: {
files: [],
generatedAt: 'loading...',
uploading: false,
status: 'Loading media library...',
error: false
},
moderation: {
logs: [],
submitting: false,
error: false,
status: 'Loading moderation logs...',
form: {
action: 'mute',
target: '',
moderator: adminConfig.defaultModerator,
reason: '',
room: adminConfig.defaultRoom
}
},
users: {
entries: [],
status: 'Loading active users...',
error: false
},
statusMessage: 'Loading live admin overview...',
async refreshOverview() {
try {
const response = await fetch('/api/admin/overview');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const overview = await response.json();
this.stats.viewers = overview.viewers;
this.stats.storedMessages = overview.storedMessages;
this.stats.moderationActions = overview.moderationActions;
this.system.redisEnabled = overview.redisEnabled;
this.system.redisClients = overview.redisClients;
this.system.redisMemoryUsed = overview.redisMemoryUsed;
this.system.databaseDriver = overview.databaseDriver;
this.system.generatedAt = overview.generatedAt;
this.statusMessage = `Live data loaded at ${overview.generatedAt}`;
} catch (error) {
console.error('Failed to load admin overview', error);
this.statusMessage = 'Failed to load live admin data.';
this.system.redisMemoryUsed = 'unavailable';
this.system.databaseDriver = 'unavailable';
this.system.generatedAt = 'unavailable';
}
},
async refreshAnalytics() {
this.analytics.status = 'Loading analytics...';
this.analytics.error = false;
try {
const response = await fetch('/api/admin/analytics');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.analytics.totalMessages = payload.totalMessages;
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
this.analytics.distinctRooms = payload.distinctRooms;
this.analytics.moderationActions = payload.moderationActions;
this.analytics.topRooms = payload.topRooms || [];
this.analytics.generatedAt = payload.generatedAt;
this.analytics.status = 'Analytics loaded.';
} catch (error) {
console.error('Failed to load analytics', error);
this.analytics.status = 'Failed to load analytics.';
this.analytics.error = true;
}
},
async refreshPlaylist() {
this.playlist.status = 'Loading playlist...';
this.playlist.error = false;
try {
const response = await fetch('/api/admin/playlist');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.playlist.content = payload.content;
this.playlist.status = 'Playlist loaded.';
} catch (error) {
console.error('Failed to load playlist', error);
this.playlist.status = 'Failed to load playlist.';
this.playlist.error = true;
}
},
async refreshSchedule() {
this.schedule.status = 'Loading schedule...';
this.schedule.error = false;
try {
const response = await fetch('/api/admin/schedule');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.schedule.entries = payload.entries || [];
this.schedule.status = 'Schedule loaded.';
} catch (error) {
console.error('Failed to load schedule', error);
this.schedule.status = 'Failed to load schedule.';
this.schedule.error = true;
}
},
async refreshSystemSummary() {
this.system.status = 'Loading system summary...';
this.system.error = false;
try {
const response = await fetch('/api/admin/system');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.system.databaseDriver = payload.databaseDriver;
this.system.playlistPath = payload.playlistPath;
this.system.playlistExists = payload.playlistExists;
this.system.redisEnabled = payload.redisEnabled;
this.system.redisMemoryUsed = payload.redisMemoryUsed;
this.system.services = payload.services || [];
this.system.generatedAt = payload.generatedAt;
this.system.status = 'System summary loaded.';
} catch (error) {
console.error('Failed to load system summary', error);
this.system.status = 'Failed to load system summary.';
this.system.error = true;
}
},
async refreshMediaLibrary() {
this.media.status = 'Loading media library...';
this.media.error = false;
try {
const response = await fetch('/api/admin/media');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.media.files = payload.files || [];
this.media.generatedAt = payload.generatedAt;
this.media.status = 'Media library loaded.';
} catch (error) {
console.error('Failed to load media library', error);
this.media.status = 'Failed to load media library.';
this.media.error = true;
}
},
async savePlaylist() {
this.playlist.saving = true;
this.playlist.status = 'Saving playlist...';
this.playlist.error = false;
try {
const response = await fetch('/api/admin/playlist', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: this.playlist.content })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.playlist.content = payload.content;
this.playlist.status = 'Playlist saved.';
} catch (error) {
console.error('Failed to save playlist', error);
this.playlist.status = 'Failed to save playlist.';
this.playlist.error = true;
} finally {
this.playlist.saving = false;
}
},
async refreshModerationLogs() {
this.moderation.status = 'Loading moderation logs...';
this.moderation.error = false;
try {
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.moderation.logs = payload.logs || [];
this.moderation.status = 'Moderation logs loaded.';
} catch (error) {
console.error('Failed to load moderation logs', error);
this.moderation.status = 'Failed to load moderation logs.';
this.moderation.error = true;
}
},
async submitModerationAction() {
this.moderation.submitting = true;
this.moderation.error = false;
this.moderation.status = 'Submitting moderation action...';
try {
const response = await fetch('/api/admin/moderation/actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.moderation.form)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.moderation.status = 'Moderation action recorded.';
this.moderation.form.target = '';
this.moderation.form.reason = '';
await this.refreshModerationLogs();
await this.refreshOverview();
} catch (error) {
console.error('Failed to submit moderation action', error);
this.moderation.status = 'Failed to submit moderation action.';
this.moderation.error = true;
} finally {
this.moderation.submitting = false;
}
},
async refreshUsers() {
this.users.status = 'Loading active users...';
this.users.error = false;
try {
const response = await fetch('/api/admin/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.users.entries = payload.users || [];
this.users.status = payload.redisEnabled ? 'Active users loaded.' : 'Redis disabled; no live presence data available.';
} catch (error) {
console.error('Failed to load users', error);
this.users.status = 'Failed to load active users.';
this.users.error = true;
}
},
async logout() {
try {
await fetch('/api/admin/logout', { method: 'POST' });
} finally {
window.location.href = '/admin/login/';
}
}, },
init() { init() {
this.refreshOverview();
this.refreshAnalytics();
this.refreshMediaLibrary();
this.refreshPlaylist();
this.refreshSchedule();
this.refreshModerationLogs();
this.refreshSystemSummary();
this.refreshUsers();
console.log('Admin Interface initialized (ADR-004/ADR-005)'); console.log('Admin Interface initialized (ADR-004/ADR-005)');
} }
} }
} }
function uploadVideo(event) { async function uploadVideo(event) {
const file = event.target.files[0]; const file = event.target.files[0];
const status = document.getElementById('upload-status'); if (!file) {
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`; return;
setTimeout(() => { }
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
}, 1500); const component = Alpine.$data(document.querySelector('[x-data="admin()"]'));
component.media.uploading = true;
component.media.error = false;
component.media.status = `Uploading ${file.name}...`;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/admin/media', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
component.media.files = payload.files || [];
component.media.generatedAt = payload.generatedAt;
component.media.status = `${file.name} uploaded.`;
document.getElementById('video-upload').value = '';
} catch (error) {
console.error('Failed to upload media file', error);
component.media.status = 'Failed to upload media file.';
component.media.error = true;
} finally {
component.media.uploading = false;
}
} }
function runVacuum() { function runVacuum() {
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../../src/style.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
<div class="mb-8">
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
</div>
<form id="login-form" class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Username</span>
<input id="username" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="username" required />
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Password</span>
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required />
</label>
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
</form>
</main>
<script>
const form = document.getElementById('login-form');
const username = document.getElementById('username');
const password = document.getElementById('password');
const submit = document.getElementById('submit');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
submit.disabled = true;
status.textContent = 'Signing in...';
status.className = 'text-sm text-slate-400 min-h-6';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
window.location.href = '/admin/';
} catch (error) {
console.error('Failed to sign in', error);
status.textContent = 'Login failed. Check your credentials.';
status.className = 'text-sm text-rose-400 min-h-6';
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>
+2 -2
View File
@@ -11,7 +11,7 @@
</head> </head>
<body class="bg-slate-950 text-slate-200"> <body class="bg-slate-950 text-slate-200">
<div class="flex h-screen" x-data="app()"> <div class="flex h-screen">
<!-- Video Player --> <!-- Video Player -->
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900"> <div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
@@ -29,7 +29,7 @@
</div> </div>
<div class="flex-1 flex items-center justify-center p-8 bg-black relative"> <div class="flex-1 flex items-center justify-center p-8 bg-black relative">
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay></video> <video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video>
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden"> <div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="text-emerald-400">● LIVE</div> <div class="text-emerald-400">● LIVE</div>
+46 -5
View File
@@ -16,13 +16,54 @@ let ws: WebSocket | null = null;
let currentRoom = "main"; let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000); let username = "viewer" + Math.floor(Math.random() * 1000);
function resolveWebSocketBaseUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
if (configuredUrl) {
return configuredUrl;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function resolveManifestUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
const cacheBust = `stream=${Date.now()}`;
const sameOriginManifestUrl = new URL(
"/hls/playlist.m3u8",
window.location.origin,
);
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
const allowCrossOriginManifest =
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
"true";
if (configuredUrl) {
const manifestUrl = new URL(configuredUrl);
manifestUrl.searchParams.set("stream", cacheBust);
if (
!allowCrossOriginManifest &&
manifestUrl.origin !== window.location.origin
) {
return sameOriginManifestUrl.toString();
}
return manifestUrl.toString();
}
return sameOriginManifestUrl.toString();
}
function connectWebSocket(room: string) { function connectWebSocket(room: string) {
if (ws) ws.close(); if (ws) ws.close();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const socketUrl = new URL(resolveWebSocketBaseUrl());
ws = new WebSocket( socketUrl.searchParams.set("username", username);
`${protocol}//localhost:3000/ws?username=${username}&room=${room}`, socketUrl.searchParams.set("room", room);
); ws = new WebSocket(socketUrl);
ws.onopen = () => { ws.onopen = () => {
console.log(`Connected to chat room: ${room}`); console.log(`Connected to chat room: ${room}`);
@@ -113,7 +154,7 @@ window.app = () => {
// Initialize HLS Player // Initialize HLS Player
const video = document.getElementById("player") as HTMLVideoElement; const video = document.getElementById("player") as HTMLVideoElement;
const manifestUrl = "http://localhost:8080/hls/playlist.m3u8"; // from nginx/pla yout const manifestUrl = resolveManifestUrl();
if (Hls.isSupported()) { if (Hls.isSupported()) {
hls = new Hls(); hls = new Hls();
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
export default defineConfig({
envDir: "..",
build: {
rollupOptions: {
input: {
main: "index.html",
admin: "admin/index.html",
adminLogin: "admin/login/index.html",
},
},
},
});
+15 -3
View File
@@ -1,9 +1,21 @@
# Stage 1: Build frontend
FROM node:20-alpine AS builder
WORKDIR /build
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Stage 2: Nginx with bundled frontend
FROM nginx:alpine FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
# Create directories for HLS and frontend # Copy prebuilt frontend from builder stage
RUN mkdir -p /usr/share/nginx/html/hls /usr/share/nginx/html/admin COPY --from=builder /build/dist/ /srv/frontend/
# Create HLS directory
RUN mkdir -p /srv/hls
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+157 -7
View File
@@ -3,13 +3,66 @@ events {
} }
http { http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off valid=30s;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server { server {
listen 80; listen 80;
server_name localhost; server_name www.linearvoid.tv;
root /usr/share/nginx/html; return 301 https://linearvoid.tv$request_uri;
}
server {
listen 80 default_server;
server_name linearvoid.tv localhost 127.0.0.1;
root /srv/frontend;
index index.html; index index.html;
location = /admin {
return 301 /admin/;
}
location = /admin/login {
return 301 /admin/login/;
}
location = /_admin_auth {
internal;
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream/api/admin/session;
proxy_http_version 1.1;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /admin/login/ {
try_files $uri $uri/ /admin/login/index.html;
}
location /admin/ {
auth_request /_admin_auth;
error_page 401 = @admin_login_redirect;
try_files $uri $uri/ /admin/index.html;
}
location /assets/ {
try_files $uri =404;
}
# Serve static frontend # Serve static frontend
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@@ -17,17 +70,114 @@ http {
# Proxy chat WebSocket # Proxy chat WebSocket
location /ws { location /ws {
proxy_pass http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# Serve HLS from playout (with cache headers for Cloudflare) location = /api/admin/login {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location = /api/admin/logout {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location = /api/admin/session {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location /api/admin/ {
auth_request /_admin_auth;
error_page 401 = @admin_api_unauthorized;
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location /api/ {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location = /metrics {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location @admin_api_unauthorized {
add_header Content-Type application/json;
return 401 '{"error":"unauthorized"}';
}
location @admin_login_redirect {
return 302 /admin/login/;
}
location = /hls/playlist.m3u8 {
alias /srv/hls/playlist.m3u8;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Access-Control-Allow-Origin "*";
}
location = /hls/init.mp4 {
alias /srv/hls/init.mp4;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
location ~* ^/hls/(.+\.(ts|m4s))$ {
alias /srv/hls/$1;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
location /hls/ { location /hls/ {
alias /usr/share/nginx/html/hls/; alias /srv/hls/;
add_header Cache-Control "public, max-age=3600"; add_header Cache-Control "public, max-age=60";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
+39
View File
@@ -0,0 +1,39 @@
{
"title": "Linear Void Monitoring",
"panels": [
{
"title": "Concurrent Viewers",
"type": "timeseries",
"targets": [{ "expr": "linearvoid_chat_connected_clients" }]
},
{
"title": "Stored Messages",
"type": "stat",
"targets": [
{
"expr": "linearvoid_chat_messages_total"
}
]
},
{
"title": "Moderation Actions",
"type": "timeseries",
"targets": [{ "expr": "linearvoid_chat_moderation_actions_total" }]
},
{
"title": "Redis Reachable",
"type": "stat",
"targets": [{ "expr": "linearvoid_chat_redis_up" }]
},
{
"title": "Admin Sessions",
"type": "timeseries",
"targets": [{ "expr": "linearvoid_chat_admin_sessions_active" }]
},
{
"title": "Chat Scrape Health",
"type": "stat",
"targets": [{ "expr": "up{job=\"chat\"}" }]
}
]
}
+10
View File
@@ -0,0 +1,10 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: "chat"
static_configs:
- targets: ["chat:3000"]
- job_name: "prometheus"
static_configs:
- targets: ["prometheus:9090"]