Compare commits
37
Commits
6995763ccb
...
1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1e227d50 | ||
|
|
0891606321 | ||
|
|
c6f28ff7af | ||
|
|
072a91961f | ||
|
|
7184240dd4 | ||
|
|
9cd0419c0c | ||
|
|
d3266f8feb | ||
|
|
b532507374 | ||
|
|
5c1a8cbe4f | ||
|
|
502fefff33 | ||
|
|
bc3a950d99 | ||
|
|
1af9e59a56 | ||
|
|
13d25a7ae0 | ||
|
|
fc799da000 | ||
|
|
2b091adce8 | ||
|
|
c54210434e | ||
|
|
fcb68cfd8d | ||
|
|
cdecdd2d4a | ||
|
|
47ffae5dea | ||
|
|
43c1a31660 | ||
|
|
afb9a41404 | ||
|
|
1f2082e0fb | ||
|
|
7ce6a24a77 | ||
|
|
450a93150c | ||
|
|
dc53bce6c9 | ||
|
|
32f2b7c7ab | ||
|
|
72422f4336 | ||
|
|
78508ed16e | ||
|
|
8fd80c1a4c | ||
|
|
5fc9bc6191 | ||
|
|
3c2b29ad26 | ||
|
|
6b494a34fe | ||
|
|
1ae0ad121b | ||
|
|
2e061dff28 | ||
|
|
c484b463e3 | ||
|
|
3fbd0b7ba2 | ||
|
|
80c1f4acce |
No files matched your search
+29
-16
@@ -12,21 +12,39 @@ R2_REGION=auto
|
||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||
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_REGISTRY_URL=git.allucanget.biz
|
||||
GITEA_USERNAME=your-gitea-username
|
||||
GITEA_TOKEN=your-gitea-token
|
||||
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
||||
CI_REGISTRY_URL=git.allucanget.biz
|
||||
CI_REGISTRY_USER=your-gitea-username
|
||||
CI_REGISTRY_TOKEN=your-gitea-token
|
||||
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)
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/linearvoid
|
||||
# Or for SQLite (local development):
|
||||
# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional)
|
||||
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
||||
# Or for SQLite-only local development:
|
||||
# DATABASE_URL=sqlite://./data/chat.db
|
||||
|
||||
# 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)
|
||||
PLAYOUT_PORT=8080
|
||||
@@ -34,7 +52,7 @@ CHAT_PORT=3000
|
||||
NGINX_PORT=80
|
||||
|
||||
# Playout configuration
|
||||
PLAYLIST_PATH=/app/playlist.txt
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
|
||||
@@ -45,9 +63,4 @@ GRAFANA_PORT=3001
|
||||
|
||||
# Development flags
|
||||
NODE_ENV=development
|
||||
GO_ENV=development
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_SERVER_URL=https://git.allucanget.biz
|
||||
GITEA_TOKEN=your-gitea-token
|
||||
GITEA_USERNAME=your-gitea-username
|
||||
GO_ENV=development
|
||||
+62
-13
@@ -1,13 +1,17 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -22,28 +26,73 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.GITEA_USERNAME }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Pull latest images
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
docker pull git.allucanget.biz/zwitschi/linearvoid/playout:latest || true
|
||||
docker pull git.allucanget.biz/zwitschi/linearvoid/chat:latest || true
|
||||
docker pull git.allucanget.biz/zwitschi/linearvoid/frontend:latest || true
|
||||
docker pull git.allucanget.biz/zwitschi/linearvoid/nginx:latest || true
|
||||
cat > .env << 'EOF'
|
||||
DATABASE_URL="${{ secrets.DATABASE_URL }}"
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
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
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
- name: Health check
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
sleep 10
|
||||
curl -f http://localhost:8080/health || exit 1
|
||||
curl -f http://localhost:3000/health || exit 1
|
||||
curl -f http://localhost:8081/health || exit 1
|
||||
# Wait for playout to stabilize (it may restart during init)
|
||||
sleep 20
|
||||
curl -f http://192.168.88.40:3000/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
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker image prune -f --filter "until=24h"
|
||||
+24
-40
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -63,8 +63,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.GITEA_USERNAME }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Playout
|
||||
id: meta-playout
|
||||
@@ -82,11 +82,10 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend/playout
|
||||
file: ./backend/playout/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata for Chat
|
||||
id: meta-chat
|
||||
@@ -107,30 +106,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||
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
|
||||
id: meta-nginx
|
||||
@@ -145,18 +120,27 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Nginx image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./infrastructure/nginx
|
||||
push: true
|
||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
run: |
|
||||
TAGS="${{ steps.meta-nginx.outputs.tags }}"
|
||||
LABELS="${{ steps.meta-nginx.outputs.labels }}"
|
||||
TAG_ARGS=""
|
||||
for tag in $TAGS; do
|
||||
TAG_ARGS="$TAG_ARGS --tag $tag"
|
||||
done
|
||||
LABEL_ARGS=""
|
||||
while IFS= read -r label; do
|
||||
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
|
||||
done <<< "$LABELS"
|
||||
docker buildx build \
|
||||
--push \
|
||||
--file infrastructure/nginx/Dockerfile \
|
||||
$TAG_ARGS \
|
||||
$LABEL_ARGS \
|
||||
.
|
||||
|
||||
release:
|
||||
needs: docker
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -169,4 +153,4 @@ jobs:
|
||||
with:
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
@@ -2,24 +2,24 @@ name: Dependency Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Every Monday at 2 AM UTC
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-dependencies:
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Update Frontend Dependencies
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
|
||||
- name: Update Go Dependencies
|
||||
@@ -42,17 +42,17 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
commit-message: 'chore: update dependencies'
|
||||
title: 'chore: weekly dependency updates'
|
||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
commit-message: "chore: update dependencies"
|
||||
title: "chore: weekly dependency updates"
|
||||
body: |
|
||||
Automated weekly dependency updates.
|
||||
|
||||
|
||||
This PR updates:
|
||||
- Frontend npm dependencies
|
||||
- Go module dependencies
|
||||
|
||||
|
||||
Please review the changes and ensure tests pass before merging.
|
||||
branch: dependency-updates
|
||||
delete-branch: true
|
||||
labels: dependencies, automated
|
||||
labels: dependencies, automated
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -27,8 +27,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.allucanget.biz
|
||||
username: ${{ secrets.GITEA_USERNAME }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
@@ -75,24 +75,6 @@ jobs:
|
||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||
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
|
||||
id: meta-nginx
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -106,15 +88,15 @@ jobs:
|
||||
- name: Build and push Nginx image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./infrastructure/nginx
|
||||
context: .
|
||||
file: infrastructure/nginx/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
- name: Create Release on Gitea
|
||||
run: |
|
||||
curl -X POST "$CI_SERVER_URL/api/v1/repos/$CI_REPO_OWNER/$CI_REPO_NAME/releases" \
|
||||
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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}"
|
||||
@@ -9,3 +9,13 @@ Agents instructions for the linearvoid project.
|
||||
- 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.
|
||||
- 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
@@ -50,6 +50,9 @@ data/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
|
||||
!backend/playout/demodata/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
**/.dockerignore
|
||||
@@ -75,14 +78,10 @@ Thumbs.db
|
||||
*.tfstate.backup
|
||||
.terraform/
|
||||
|
||||
# Monitoring & temp
|
||||
prometheus/
|
||||
grafana/
|
||||
|
||||
# Agent / Tool files
|
||||
.github/instructions/TODO.md
|
||||
.github/instructions/DONE.md
|
||||
# (keep AGENTS.md and other instruction files)
|
||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
+38
-1
@@ -1,8 +1,45 @@
|
||||
# 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
|
||||
|
||||
**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).
|
||||
- **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.
|
||||
|
||||
@@ -11,6 +11,12 @@ Thank you for considering contributing to Linear Void!
|
||||
5. **Commit Messages**: Reference the relevant ADR (e.g., "feat(playout): add R2 sync per ADR-001").
|
||||
6. **Test with Docker**: Run `docker compose up --build` to test changes.
|
||||
|
||||
## Repository
|
||||
|
||||
- **Primary**: [git.allucanget.biz/zwitschi/linearvoid](https://git.allucanget.biz/zwitschi/linearvoid)
|
||||
- **Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||
- **CI/CD**: Gitea Actions with self-hosted runners (see `.gitea/workflows/`)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Go: Use standard formatting (`go fmt`).
|
||||
@@ -19,6 +25,7 @@ Thank you for considering contributing to Linear Void!
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||
- Reference the related ADR in the PR description.
|
||||
- Ensure all tests pass and documentation is updated.
|
||||
- The CI workflow will validate normalization, linting, and Docker builds.
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
# 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)
|
||||
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
|
||||
## Architecture
|
||||
|
||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||
|
||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat.
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
||||
- Lightweight static frontend and WebSocket chat backend.
|
||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||
|
||||
See [`docs/architecture/adr/ADR-001.md`](docs/architecture/adr/ADR-001.md) for the consolidated decision record, storage calculations, cache rules, cost model, and implementation plan.
|
||||
See [`docs/architecture/adr/`](docs/architecture/adr/) for the full set of decision records (ADR-001 through ADR-007) covering architecture, storage calculations, cache rules, cost model, module structure, tech stack, admin interfaces, data storage, and caching strategy.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -55,7 +64,13 @@ linearvoid/
|
||||
│ └── architecture/
|
||||
│ ├── README.md # arc42 template (fully populated)
|
||||
│ └── adr/
|
||||
│ └── ADR-001.md # Consolidated decision record
|
||||
│ ├── ADR-001.md # Consolidated decision record
|
||||
│ ├── ADR-002-module-structure.md
|
||||
│ ├── ADR-003-tech-stack.md
|
||||
│ ├── ADR-004-admin-video.md
|
||||
│ ├── ADR-005-admin-moderation-analytics.md
|
||||
│ ├── ADR-006-data-storage.md
|
||||
│ └── ADR-007-redis-usage.md
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── docker-compose.yml # Top-level for dev (playout + chat + nginx)
|
||||
@@ -72,21 +87,27 @@ linearvoid/
|
||||
- **`infrastructure/`**: Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||
- **`scripts/`**: One-off and CI-friendly scripts for video preparation, R2 sync, backups, and playlist management.
|
||||
- **`monitoring/`**: Grafana dashboards, Prometheus rules, logging configuration.
|
||||
- **`docs/architecture/`**: Complete arc42 documentation + ADR-001 (the single source of truth for all architectural decisions).
|
||||
- **`docs/architecture/`**: Complete arc42 documentation + ADRs 001-007 (the single source of truth for all architectural decisions).
|
||||
|
||||
This structure directly reflects the building blocks, runtime flows, deployment view, and cross-cutting concepts defined in the arc42 documentation.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. `cp .env.example .env` and configure secrets (R2 keys, Cloudflare, etc.).
|
||||
2. Prepare normalized videos (`scripts/normalize-video.sh`).
|
||||
3. Run locally: `docker compose up` (or individual services).
|
||||
4. Deploy: Provision Hetzner VPS, push to R2, configure Cloudflare cache rules (see `infrastructure/cloudflare/` and ADR-001).
|
||||
5. Full setup and operational runbooks are in the [architecture docs](docs/architecture/README.md).
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Full setup guide: [`SETUP.md`](SETUP.md).
|
||||
|
||||
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
||||
|
||||
## Development
|
||||
|
||||
- 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).
|
||||
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
||||
|
||||
@@ -98,10 +119,26 @@ The architecture is fully documented using the [arc42 template](docs/architectur
|
||||
|
||||
- **[ADR-001](docs/architecture/adr/ADR-001.md)**: Overall system architecture, HLS + Cloudflare R2 caching strategy, storage calculations, cost model, risks, and implementation plan.
|
||||
- **[ADR-002](docs/architecture/adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) and mapping to arc42 building blocks.
|
||||
- **[ADR-003](docs/architecture/adr/ADR-003-tech-stack.md)**: Technology stack, language choices per module (TypeScript for frontend, Go preferred for chat and playout wrapper, Bash/Python for scripts), Docker-first approach, and all implicit assumptions (mandatory video normalization, Cloudflare-centric delivery, monitoring focus, etc.).
|
||||
|
||||
These three ADRs together provide complete traceability for all major design decisions. Future ADRs will cover Docker Compose layout, API contracts, monitoring strategy, and access control.
|
||||
- **[ADR-003](docs/architecture/adr/ADR-003-tech-stack.md)**: Technology stack, language choices per module (TypeScript for frontend, Go preferred for chat and playout wrapper, Bash/Python for scripts), Docker-first approach, and all implicit assumptions.
|
||||
- **[ADR-004](docs/architecture/adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
|
||||
- **[ADR-005](docs/architecture/adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
|
||||
- **[ADR-006](docs/architecture/adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
|
||||
- **[ADR-007](docs/architecture/adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
|
||||
|
||||
See the [architecture README](docs/architecture/README.md) for the big-picture view and the individual ADR files for detailed rationale, alternatives, and consequences.
|
||||
|
||||
Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE) should reference the architecture documentation.
|
||||
|
||||
## Resources
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||
|
||||
**CI/CD**: [View workflows](https://git.allucanget.biz/zwitschi/linearvoid/actions) on Gitea Actions.
|
||||
|
||||
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||
|
||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
||||
@@ -5,12 +5,31 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
## Prerequisites
|
||||
|
||||
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
||||
- **Node.js 20+**: 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).
|
||||
- **Git**: For cloning and version control.
|
||||
- **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).
|
||||
|
||||
## 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
|
||||
|
||||
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_BUCKET_NAME=linearvoid-media
|
||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||
R2_REGION=auto
|
||||
|
||||
# Cloudflare (for custom domain and cache rules)
|
||||
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_REGISTRY_URL=git.allucanget.biz
|
||||
GITEA_USERNAME=your-gitea-username
|
||||
GITEA_TOKEN=your-gitea-token
|
||||
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
||||
CI_REGISTRY_URL=git.allucanget.biz
|
||||
CI_REGISTRY_USER=your-gitea-username
|
||||
CI_REGISTRY_TOKEN=your-gitea-token
|
||||
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
|
||||
# Gitea API access (optional, for CI/release automation)
|
||||
CI_SERVER_URL=https://git.allucanget.biz
|
||||
|
||||
# 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
|
||||
DATABASE_URL=sqlite://./data/chat.db # or postgres://user:pass@db:5432/linearvoid
|
||||
|
||||
# Ports (for local development)
|
||||
PLAYOUT_PORT=8080
|
||||
CHAT_PORT=3000
|
||||
NGINX_PORT=8080
|
||||
PLAYOUT_HEALTH_PORT=8081
|
||||
NGINX_PORT=80
|
||||
|
||||
# Playout Configuration
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
PLAYLIST_PATH=/app/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
|
||||
# Logging
|
||||
# Logging / Monitoring
|
||||
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
|
||||
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/`.
|
||||
- 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
|
||||
|
||||
### Local Development
|
||||
@@ -84,9 +206,10 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
```
|
||||
|
||||
2. **Access the Application**:
|
||||
- Viewer: <http://localhost:8080>
|
||||
- Chat WebSocket: ws://localhost:3000/ws
|
||||
- Admin Interface: <http://localhost:8080/admin> (use the credentials from `.env` or default admin/admin)
|
||||
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
|
||||
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
|
||||
- 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:
|
||||
- Playout: <http://localhost:8081/health>
|
||||
- Chat: <http://localhost:3000/health>
|
||||
@@ -98,23 +221,184 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||
2. **Configure Cloudflare**:
|
||||
- Create R2 bucket with `enam` location hint.
|
||||
- Set up custom 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.
|
||||
3. **Deploy**:
|
||||
- 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**Last Updated**: 2026-08-02
|
||||
**Last Updated**: 2026-08-03
|
||||
@@ -1,17 +1,17 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
COPY go.mod go.sum ./
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
WORKDIR /app
|
||||
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
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN apk --no-cache add ca-certificates curl curl
|
||||
WORKDIR /app
|
||||
COPY --from=builder /chat-server .
|
||||
COPY config/ ./config/
|
||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["./chat-server"]
|
||||
CMD ["/usr/local/bin/chat-server"]
|
||||
+10
-2
@@ -1,10 +1,11 @@
|
||||
module chat
|
||||
|
||||
go 1.23
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
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/gorm v1.25.12
|
||||
)
|
||||
@@ -12,7 +13,14 @@ require (
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/now v1.1.5 // 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
|
||||
)
|
||||
@@ -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
@@ -2,16 +2,24 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -25,6 +33,8 @@ var (
|
||||
}
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
mutex = sync.Mutex{}
|
||||
authSessions = map[string]time.Time{}
|
||||
authSessionsMu = sync.Mutex{}
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQLite per ADR-006)
|
||||
@@ -54,13 +64,133 @@ type Presence struct {
|
||||
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)
|
||||
func initRedis() {
|
||||
redisClient = redis.NewClient(&redis.Options{
|
||||
Addr: os.Getenv("REDIS_URL"),
|
||||
Password: "",
|
||||
DB: 0,
|
||||
})
|
||||
redisAddress := os.Getenv("REDIS_URL")
|
||||
if redisAddress == "" {
|
||||
redisAddress = "redis:6379"
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
redisClient = nil
|
||||
@@ -72,12 +202,773 @@ func initRedis() {
|
||||
// initDB initializes SQLite for message history and moderation audit log (ADR-006)
|
||||
func initDB() {
|
||||
var err error
|
||||
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to SQLite:", err)
|
||||
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
driverName := "sqlite"
|
||||
|
||||
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{})
|
||||
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
|
||||
@@ -110,7 +1001,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
// Announce presence (ADR-007)
|
||||
username := r.URL.Query().Get("username")
|
||||
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)
|
||||
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()
|
||||
delete(clients, conn)
|
||||
mutex.Unlock()
|
||||
if username != "" && room != "" {
|
||||
if redisClient != nil && username != "" && room != "" {
|
||||
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"})
|
||||
}
|
||||
@@ -219,6 +1110,19 @@ func main() {
|
||||
initDB()
|
||||
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -230,6 +1134,6 @@ func main() {
|
||||
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))
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Initialize Go module if not present
|
||||
RUN go mod init playout || true
|
||||
@@ -17,20 +16,18 @@ RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
curl \
|
||||
bash
|
||||
|
||||
# Set working directory for playout
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
# Copy the playout start script and the built playlist manager binary
|
||||
COPY start-playout.sh /app/
|
||||
COPY *.sh /app/
|
||||
|
||||
RUN chmod +x /app/*.sh /app/playlist-manager
|
||||
|
||||
# Copy demo data for out-of-the-box playout
|
||||
COPY demodata/ /app/
|
||||
# Copy the built playlist manager binary from the builder stage
|
||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||
# Create directories for playlist, media, and HLS output
|
||||
RUN mkdir -p /app/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)
|
||||
VOLUME ["/app/hls"]
|
||||
|
||||
# Default command runs the playout loop (which calls the Go validator)
|
||||
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'
|
||||
@@ -64,6 +64,12 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "file ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||
line = strings.Trim(line, "'\"")
|
||||
}
|
||||
|
||||
videos = append(videos, VideoFile{Path: line})
|
||||
}
|
||||
|
||||
@@ -75,7 +81,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
v := VideoFile{Path: 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 {
|
||||
v.Validation = "ffprobe failed: " + err.Error()
|
||||
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)+`"}`)
|
||||
}
|
||||
|
||||
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() {
|
||||
playlistPath := "/app/playlist/playlist.txt"
|
||||
if len(os.Args) > 1 {
|
||||
@@ -170,15 +216,29 @@ func main() {
|
||||
|
||||
// Run FFmpeg in a loop for continuous playback with R2 sync hook
|
||||
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,
|
||||
"-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",
|
||||
"-hls_time", "6",
|
||||
"-hls_list_size", "24",
|
||||
"-hls_flags", "delete_segments+append_list+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S_%%03d.ts", // unique immutable names per ADR-001
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", "init.mp4",
|
||||
"-strftime", "1",
|
||||
"-hls_flags", "delete_segments+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||
"/app/hls/playlist.m3u8")
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
@@ -196,7 +256,7 @@ func main() {
|
||||
|
||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -215,6 +275,10 @@ func syncLatestToR2(hlsDir string) error {
|
||||
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
|
||||
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")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -26,16 +26,27 @@ fi
|
||||
|
||||
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
|
||||
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" \
|
||||
-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 \
|
||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
||||
-hls_flags delete_segments+append_list \
|
||||
-hls_segment_filename "/app/hls/segment_%03d.ts" \
|
||||
-hls_segment_type fmp4 \
|
||||
-hls_fmp4_init_filename init.mp4 \
|
||||
-strftime 1 \
|
||||
-hls_flags delete_segments+independent_segments \
|
||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
|
||||
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
|
||||
|
||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data/playlist:/app/playlist:ro
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:ro
|
||||
- ./data/hls:/app/hls:rw
|
||||
networks:
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -49,7 +51,7 @@ services:
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./data/hls:/usr/share/nginx/html/hls:ro
|
||||
- ./data/hls:/srv/hls:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -100,7 +102,7 @@ services:
|
||||
container_name: linearvoid-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-config:/etc/prometheus
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
@@ -140,4 +142,5 @@ volumes:
|
||||
redis-data:
|
||||
postgres-data:
|
||||
prometheus-data:
|
||||
prometheus-config:
|
||||
grafana-data:
|
||||
+9
-9
@@ -1,8 +1,6 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
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:
|
||||
context: ./backend/playout
|
||||
dockerfile: Dockerfile
|
||||
@@ -26,7 +24,7 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
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:
|
||||
context: ./backend/chat
|
||||
dockerfile: Dockerfile
|
||||
@@ -38,6 +36,8 @@ services:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
@@ -51,17 +51,17 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
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:
|
||||
context: ./infrastructure/nginx
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: infrastructure/nginx/Dockerfile
|
||||
container_name: linearvoid-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||
- ./data/hls:/usr/share/nginx/html/hls:ro
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
- ./data/hls:/srv/hls:ro
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
|
||||
@@ -217,6 +217,65 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
|
||||
|
||||
## 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
|
||||
|
||||
**Overview Diagram**: See Mermaid in Building Block section (VPS + Cloudflare R2/Pages/CDN).
|
||||
@@ -274,17 +333,22 @@ The primary decisions are captured in the following consolidated ADRs (all locat
|
||||
|
||||
- **[ADR-001](./adr/ADR-001.md)**: Overall architecture, HLS + Cloudflare R2 caching strategy, storage calculations, cost model, risks.
|
||||
- **[ADR-002](./adr/ADR-002-module-structure.md)**: Chosen project folder/module structure (frontend, backend/playout, backend/chat, infrastructure, scripts, monitoring) and rationale.
|
||||
- **[ADR-003](./adr/ADR-003-tech-stack.md)**: Technology stack, programming language choices per module (TypeScript frontend, Go for chat/playout wrapper, Bash/Python scripts), Docker-first approach, and explicit documentation of all implicit assumptions (normalization requirement, Cloudflare-centric delivery, monitoring focus, etc.).
|
||||
- **[ADR-003](./adr/ADR-003-tech-stack.md)**: Technology stack, programming language choices per module (TypeScript frontend, Go for chat/playout wrapper, Bash/Python scripts), Docker-first approach, and explicit documentation of all implicit assumptions.
|
||||
- **[ADR-004](./adr/ADR-004-admin-video.md)**: Video admin web interface (upload, playlist management, schedule overrides, normalization enforcement).
|
||||
- **[ADR-005](./adr/ADR-005-admin-moderation-analytics.md)**: Web admin interfaces for user management, moderation dashboard, analytics, and system configuration.
|
||||
- **[ADR-006](./adr/ADR-006-data-storage.md)**: Data storage strategy (SQLite vs PostgreSQL for v1, migration path, admin interface capabilities).
|
||||
- **[ADR-007](./adr/ADR-007-redis-usage.md)**: Redis usage (exact use cases, TTL policies, persistence, fallback behavior, admin configuration).
|
||||
|
||||
These ADRs together form the canonical decision log. Future ADRs (e.g., Docker Compose layout, API contracts, monitoring strategy) will be added to the same folder and referenced here.
|
||||
These ADRs together form the canonical decision log.
|
||||
|
||||
**Summary of Key Decisions** (from ADR-001/002/003):
|
||||
**Summary of Key Decisions** (from ADR-001 through ADR-007):
|
||||
|
||||
- Self-managed US-East playout (Hetzner Ashburn) with FFmpeg + MediaMTX/SRS → HLS → Cloudflare R2 + edge caching.
|
||||
- Module structure that directly reflects the arc42 building blocks and context diagram.
|
||||
- Go preferred for performance-critical services; TypeScript for frontend; Docker for all deployment.
|
||||
|
||||
See the individual ADR files for full context, alternatives considered, consequences, and references.
|
||||
- Admin interfaces for video management, moderation, analytics, and system configuration using HTMX + Tailwind.
|
||||
- SQLite as primary database for v1 with PostgreSQL migration path if needed.
|
||||
- Redis for rate limiting, presence, pub/sub, session storage with defined TTL policies and fallback behavior.
|
||||
|
||||
## Quality Requirements
|
||||
|
||||
|
||||
@@ -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
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</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()">
|
||||
<!-- Sidebar -->
|
||||
<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="text-xl font-bold">Admin</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">
|
||||
<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 = 'playlist'" 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 = 'moderation'" 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 = 'analytics'" 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 = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</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'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</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'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</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'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -41,26 +44,272 @@
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-blue-400 text-sm">Cache Hit Ratio</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div>
|
||||
<div class="text-blue-400 text-sm">Stored Messages</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-amber-400 text-sm">R2 Usage</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div>
|
||||
<div class="text-amber-400 text-sm">Moderation Actions</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'upload'">
|
||||
<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">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*" @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">
|
||||
<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" :disabled="media.uploading">
|
||||
Drag & Drop or Click to Upload
|
||||
</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>
|
||||
</template>
|
||||
@@ -75,15 +324,15 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>Memory Used</span>
|
||||
<span class="font-mono">248 MB</span>
|
||||
<span class="font-mono" x-text="system.redisMemoryUsed"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Connected Clients</span>
|
||||
<span class="font-mono">12</span>
|
||||
<span>Connected Redis Clients</span>
|
||||
<span class="font-mono" x-text="system.redisClients"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Rate Limit Threshold</span>
|
||||
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center">
|
||||
<span>Redis Enabled</span>
|
||||
<span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,41 +340,462 @@
|
||||
<h3 class="font-medium mb-4">Storage</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>SQLite Size</span>
|
||||
<span class="font-mono">142 MB</span>
|
||||
<span>Database Driver</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>
|
||||
<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 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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
return {
|
||||
currentTab: 'dashboard',
|
||||
stats: {
|
||||
viewers: 142,
|
||||
cacheHit: 97,
|
||||
r2Usage: '18.4 GB'
|
||||
viewers: 0,
|
||||
storedMessages: 0,
|
||||
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() {
|
||||
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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadVideo(event) {
|
||||
async function uploadVideo(event) {
|
||||
const file = event.target.files[0];
|
||||
const status = document.getElementById('upload-status');
|
||||
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`;
|
||||
setTimeout(() => {
|
||||
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
|
||||
}, 1500);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<div class="flex h-screen" x-data="app()">
|
||||
<div class="flex h-screen">
|
||||
<!-- Video Player -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<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 class="flex items-center gap-3">
|
||||
<div class="text-emerald-400">● LIVE</div>
|
||||
|
||||
+46
-5
@@ -16,13 +16,54 @@ let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
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) {
|
||||
if (ws) ws.close();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(
|
||||
`${protocol}//localhost:3000/ws?username=${username}&room=${room}`,
|
||||
);
|
||||
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
||||
socketUrl.searchParams.set("username", username);
|
||||
socketUrl.searchParams.set("room", room);
|
||||
ws = new WebSocket(socketUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`Connected to chat room: ${room}`);
|
||||
@@ -113,7 +154,7 @@ window.app = () => {
|
||||
|
||||
// Initialize HLS Player
|
||||
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()) {
|
||||
hls = new Hls();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Create directories for HLS and frontend
|
||||
RUN mkdir -p /usr/share/nginx/html/hls /usr/share/nginx/html/admin
|
||||
# Copy prebuilt frontend from builder stage
|
||||
COPY --from=builder /build/dist/ /srv/frontend/
|
||||
|
||||
# Create HLS directory
|
||||
RUN mkdir -p /srv/hls
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -3,13 +3,66 @@ events {
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
|
||||
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
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -17,17 +70,114 @@ http {
|
||||
|
||||
# Proxy chat WebSocket
|
||||
location /ws {
|
||||
proxy_pass http://chat:3000;
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
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 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/ {
|
||||
alias /usr/share/nginx/html/hls/;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
alias /srv/hls/;
|
||||
add_header Cache-Control "public, max-age=60";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
|
||||
@@ -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\"}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user