Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ac04856b | ||
|
|
69df0e51b8 | ||
|
|
9a06dc389d | ||
|
|
51f3e98816 | ||
|
|
68ba3a25b9 | ||
|
|
7e9308c48a | ||
|
|
21a34a4bd5 | ||
|
|
ee16441b79 | ||
|
|
74133ec589 | ||
|
|
f650a3a94e | ||
|
|
c10d98f3b4 | ||
|
|
207401b5fb | ||
|
|
d4d7806189 | ||
|
|
497f7743f2 | ||
|
|
2c06265d11 | ||
|
|
930cdfdff9 | ||
|
|
ab3fab3e68 | ||
|
|
dd5789fcd9 | ||
|
|
fca4286d87 | ||
|
|
7cfddfc7ef | ||
|
|
47740e3f4a | ||
|
|
693b4a42f6 |
No files matched your search
+24
-5
@@ -73,23 +73,42 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
# Wait for playout to stabilize (it may restart during init)
|
# Wait for services to stabilize
|
||||||
sleep 20
|
sleep 20
|
||||||
curl -f http://192.168.88.40:3000/health || exit 1
|
|
||||||
|
# Check chat health
|
||||||
|
echo "Checking chat health..."
|
||||||
|
if ! curl -f http://192.168.88.40:3000/health; then
|
||||||
|
echo "Chat health check failed"
|
||||||
|
docker logs linearvoid-chat --tail 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check nginx health
|
||||||
|
echo "Checking nginx health..."
|
||||||
|
if ! curl -f http://192.168.88.40/health/; then
|
||||||
|
echo "Nginx health check failed"
|
||||||
|
docker logs linearvoid-nginx --tail 50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Retry playout health check since it may still be initializing
|
# Retry playout health check since it may still be initializing
|
||||||
|
echo "Checking playout health..."
|
||||||
for i in $(seq 1 6); do
|
for i in $(seq 1 6); do
|
||||||
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout is healthy"
|
echo "Playout is healthy"
|
||||||
exit 0
|
break
|
||||||
fi
|
fi
|
||||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
echo "Playout failed to become healthy after 6 attempts"
|
||||||
docker logs linearvoid-playout --tail 50
|
docker logs linearvoid-playout --tail 50
|
||||||
exit 1
|
exit 1
|
||||||
curl -f http://192.168.88.40:3000/health || exit 1
|
fi
|
||||||
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
|
|
||||||
|
echo "All services healthy"
|
||||||
|
|
||||||
- name: Cleanup old images
|
- name: Cleanup old images
|
||||||
env:
|
env:
|
||||||
|
|||||||
+19
-23
@@ -34,14 +34,14 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: true
|
cache: true
|
||||||
|
cache-dependency-path: backend/chat/go.sum
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Validate Playlist Normalization
|
- name: Download Go modules
|
||||||
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
|
run: cd backend/chat && GOTOOLCHAIN=local go mod download
|
||||||
|
|
||||||
- name: Build Docker Images
|
|
||||||
run: docker compose build --no-cache
|
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
run: echo "Tests will be added in future CI steps (per TODO.md)"
|
||||||
@@ -86,6 +86,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Chat
|
- name: Extract metadata for Chat
|
||||||
id: meta-chat
|
id: meta-chat
|
||||||
@@ -106,6 +108,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Nginx
|
- name: Extract metadata for Nginx
|
||||||
id: meta-nginx
|
id: meta-nginx
|
||||||
@@ -120,23 +124,15 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Nginx image
|
- name: Build and push Nginx image
|
||||||
run: |
|
uses: docker/build-push-action@v5
|
||||||
TAGS="${{ steps.meta-nginx.outputs.tags }}"
|
with:
|
||||||
LABELS="${{ steps.meta-nginx.outputs.labels }}"
|
context: .
|
||||||
TAG_ARGS=""
|
file: infrastructure/nginx/Dockerfile
|
||||||
for tag in $TAGS; do
|
push: true
|
||||||
TAG_ARGS="$TAG_ARGS --tag $tag"
|
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||||
done
|
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||||
LABEL_ARGS=""
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||||
while IFS= read -r label; do
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
[ -n "$label" ] && LABEL_ARGS="$LABEL_ARGS --label $label"
|
|
||||||
done <<< "$LABELS"
|
|
||||||
docker buildx build \
|
|
||||||
--push \
|
|
||||||
--file infrastructure/nginx/Dockerfile \
|
|
||||||
$TAG_ARGS \
|
|
||||||
$LABEL_ARGS \
|
|
||||||
.
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: docker
|
needs: docker
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -31,28 +30,48 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: true
|
cache: true
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Update Go Dependencies
|
- name: Update Go Dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend/playout && go get -u ./... && go mod tidy
|
cd backend/playout && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
cd ../chat && go get -u ./... && go mod tidy
|
cd ../chat && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Commit and push changes
|
||||||
uses: peter-evans/create-pull-request@v5
|
run: |
|
||||||
with:
|
git config user.name "linearvoid-bot"
|
||||||
token: ${{ secrets.CI_REGISTRY_TOKEN }}
|
git config user.email "bot@linearvoid.local"
|
||||||
commit-message: "chore: update dependencies"
|
git add frontend/package*.json backend/playout/go.* backend/chat/go.*
|
||||||
title: "chore: weekly dependency updates"
|
if git diff --cached --quiet; then
|
||||||
body: |
|
echo "No dependency changes detected."
|
||||||
Automated weekly dependency updates.
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore: update dependencies"
|
||||||
|
git push origin HEAD:dependency-updates --force
|
||||||
|
|
||||||
This PR updates:
|
- name: Create Pull Request via Gitea API
|
||||||
- Frontend npm dependencies
|
run: |
|
||||||
- Go module dependencies
|
RESPONSE=$(curl -s -o /tmp/pr-response.json -w "%{http_code}" \
|
||||||
|
-X POST "$CI_SERVER_URL/api/v1/repos/zwitschi/linearvoid/pulls" \
|
||||||
Please review the changes and ensure tests pass before merging.
|
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||||
branch: dependency-updates
|
-H "Content-Type: application/json" \
|
||||||
delete-branch: true
|
-d '{
|
||||||
labels: dependencies, automated
|
"title": "chore: weekly dependency updates",
|
||||||
|
"head": "dependency-updates",
|
||||||
|
"base": "main",
|
||||||
|
"body": "Automated weekly dependency updates.\n\nThis PR updates:\n- Frontend npm dependencies\n- Go module dependencies\n\nPlease review the changes and ensure tests pass before merging."
|
||||||
|
}')
|
||||||
|
if [ "$RESPONSE" -eq 201 ]; then
|
||||||
|
PR_URL=$(cat /tmp/pr-response.json | jq -r '.html_url')
|
||||||
|
echo "PR created: $PR_URL"
|
||||||
|
elif [ "$RESPONSE" -eq 409 ]; then
|
||||||
|
echo "PR already exists. Pushing updated commits..."
|
||||||
|
git push origin dependency-updates --force
|
||||||
|
else
|
||||||
|
echo "Error creating PR (HTTP $RESPONSE):"
|
||||||
|
cat /tmp/pr-response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -3,11 +3,11 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to release (e.g., v1.0.0)"
|
description: "Version to release (e.g., 1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -34,10 +34,25 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
echo "::error::VERSION is empty. Provide a version via workflow_dispatch input or push a tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Releasing version: $VERSION"
|
||||||
|
|
||||||
|
- name: Print version to gitea runner log
|
||||||
|
run: |
|
||||||
|
echo "Releasing version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo "===DEBUG==="
|
||||||
|
echo "VERSION: $VERSION"
|
||||||
|
echo "GITHUB_REF: $GITHUB_REF"
|
||||||
|
echo "steps.version.outputs.version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo "github.event_name: ${{ github.event_name }}"
|
||||||
|
|
||||||
- name: Extract metadata for Playout
|
- name: Extract metadata for Playout
|
||||||
id: meta-playout
|
id: meta-playout
|
||||||
@@ -56,6 +71,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Chat
|
- name: Extract metadata for Chat
|
||||||
id: meta-chat
|
id: meta-chat
|
||||||
@@ -74,6 +91,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Nginx
|
- name: Extract metadata for Nginx
|
||||||
id: meta-nginx
|
id: meta-nginx
|
||||||
@@ -93,10 +112,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
|
|
||||||
- name: Create Release on Gitea
|
- name: Create Release on Gitea
|
||||||
run: |
|
run: |
|
||||||
curl -X POST "$CI_SERVER_URL/api/v1/repos/$CI_REPO_OWNER/$CI_REPO_NAME/releases" \
|
curl -X POST "https://git.allucanget.biz/api/v1/repos/zwitschi/linearvoid/releases" \
|
||||||
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.CI_REGISTRY_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-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}"
|
-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}"
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
Agents instructions for the linearvoid project.
|
Agents instructions for the linearvoid project.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- use /caveman skill, latest version from <https://github.com/JuliusBrussee/caveman> is <https://github.com/JuliusBrussee/caveman/tree/main/skills/caveman>
|
||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- Use the architecture documentation (arc42 template and ADRs) as the source of truth for design decisions.
|
- Use the architecture documentation (arc42 template and ADRs in `docs`) as the source of truth for design decisions.
|
||||||
- Follow the project folder structure defined in ADR-002 and reflected in the root README.
|
- Follow the project folder structure in the root README.
|
||||||
- Update documentation first before implementing changes to ensure traceability of decisions.
|
- Update documentation first before implementing changes to ensure traceability of decisions.
|
||||||
|
|
||||||
|
## Task Management
|
||||||
|
|
||||||
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
- Plan any task in the `TODO.md` file with clear steps and mark them as completed when done. Move completed sections to DONE.md. Write entry in root `CHANGELOG.md` for any significant changes. Avoid using in-memory todo lists or update them frequently to sync from the `TODO.md` file.
|
||||||
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
- Before taking any action except for planning and writing `TODO.md`, always ask user for confirmation, accept `go`, `ok`, `yes`, `next`, or `continue` to proceed. If the user says `no`, `stop`, or `cancel`, abort the action and return to planning.
|
||||||
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
This document describes the currently implemented API surfaces in Linear Void.
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
|
||||||
|
- Public app and admin: <https://linearvoid.tv>
|
||||||
|
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
||||||
|
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
||||||
|
|
||||||
|
## Authentication Model
|
||||||
|
|
||||||
|
Admin REST endpoints use a cookie session.
|
||||||
|
|
||||||
|
- Cookie name: linearvoid_admin_session
|
||||||
|
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
|
||||||
|
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
|
||||||
|
|
||||||
|
## Chat WebSocket API
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- GET /ws?username={name}&room={room}
|
||||||
|
|
||||||
|
Client sends JSON messages over the socket.
|
||||||
|
|
||||||
|
### Message Envelope (client -> server)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "main",
|
||||||
|
"username": "viewer123",
|
||||||
|
"text": "hello world",
|
||||||
|
"type": "public"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- type is usually public for room chat and private for DM room routing.
|
||||||
|
- Private room names are formatted like dm:userA-userB (sorted usernames).
|
||||||
|
- Moderation/system events are sent as type=system messages.
|
||||||
|
|
||||||
|
### Message Envelope (server -> client)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "main",
|
||||||
|
"username": "viewer123",
|
||||||
|
"text": "hello world",
|
||||||
|
"type": "public",
|
||||||
|
"timestamp": "2026-08-04T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Session API
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
- POST /api/admin/login
|
||||||
|
- Auth: none
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
- POST /api/admin/logout
|
||||||
|
- Auth: optional (clears cookie either way)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Check
|
||||||
|
|
||||||
|
- GET /api/admin/session
|
||||||
|
- Auth: cookie required
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure:
|
||||||
|
|
||||||
|
- 401 unauthorized
|
||||||
|
|
||||||
|
## Admin Data API
|
||||||
|
|
||||||
|
All endpoints below require admin authentication.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
- GET /api/admin/overview
|
||||||
|
|
||||||
|
Returns aggregate counters and runtime/system snapshot fields.
|
||||||
|
|
||||||
|
### Playlist
|
||||||
|
|
||||||
|
- GET /api/admin/playlist
|
||||||
|
- PUT /api/admin/playlist
|
||||||
|
|
||||||
|
PUT request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
- GET /api/admin/schedule
|
||||||
|
|
||||||
|
Returns parsed playlist entries plus matched media metadata/runtime.
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
|
||||||
|
- GET /api/admin/moderation/logs?limit=50
|
||||||
|
- POST /api/admin/moderation/actions
|
||||||
|
|
||||||
|
Action request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "mute",
|
||||||
|
"target": "username-or-id",
|
||||||
|
"moderator": "admin",
|
||||||
|
"reason": "spam",
|
||||||
|
"room": "main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
|
||||||
|
- GET /api/admin/analytics
|
||||||
|
|
||||||
|
Returns totals (messages, moderation actions, rooms) and top room summary.
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
- GET /api/admin/system
|
||||||
|
|
||||||
|
Returns service health snapshot for chat/playout/redis/database and config metadata.
|
||||||
|
|
||||||
|
### Active Users
|
||||||
|
|
||||||
|
- GET /api/admin/users
|
||||||
|
|
||||||
|
Returns currently discovered presence users (room + username) when Redis is available.
|
||||||
|
|
||||||
|
## Media Library API
|
||||||
|
|
||||||
|
### Media Collection
|
||||||
|
|
||||||
|
- GET /api/admin/media
|
||||||
|
- POST /api/admin/media (multipart upload, legacy path)
|
||||||
|
|
||||||
|
Multipart form field name: file
|
||||||
|
|
||||||
|
### Media Item
|
||||||
|
|
||||||
|
- GET /api/admin/media/{id}
|
||||||
|
- PUT /api/admin/media/{id}
|
||||||
|
- DELETE /api/admin/media/{id}
|
||||||
|
|
||||||
|
PUT request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"title": "My Clip",
|
||||||
|
"tags": "music,live"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resumable Upload API
|
||||||
|
|
||||||
|
Maximum file size: 2GB
|
||||||
|
|
||||||
|
Default chunk size: 8MB
|
||||||
|
|
||||||
|
### Initialize or Resume Session
|
||||||
|
|
||||||
|
- POST /api/admin/media/uploads
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "optional-existing-id",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uploadId": "hex-token",
|
||||||
|
"fileName": "clip.mp4",
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"chunkSize": 8388608,
|
||||||
|
"totalChunks": 15,
|
||||||
|
"uploadedChunks": 3,
|
||||||
|
"progress": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Upload Status
|
||||||
|
|
||||||
|
- GET /api/admin/media/uploads/{uploadId}
|
||||||
|
|
||||||
|
Returns same status envelope as initialize.
|
||||||
|
|
||||||
|
### Upload Chunk
|
||||||
|
|
||||||
|
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
|
||||||
|
- Content-Type: application/octet-stream
|
||||||
|
|
||||||
|
Response returns updated upload status envelope.
|
||||||
|
|
||||||
|
### Complete Upload
|
||||||
|
|
||||||
|
- POST /api/admin/media/uploads/{uploadId}/complete
|
||||||
|
|
||||||
|
On success returns admin media listing payload (files + generatedAt).
|
||||||
|
|
||||||
|
## Metrics and Health
|
||||||
|
|
||||||
|
### Chat metrics
|
||||||
|
|
||||||
|
- GET /metrics
|
||||||
|
|
||||||
|
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
- GET /health on chat service
|
||||||
|
- GET /health on playout service
|
||||||
|
- GET /health on nginx reverse proxy
|
||||||
|
|
||||||
|
## HTTP Status Conventions
|
||||||
|
|
||||||
|
- 200/201: successful request
|
||||||
|
- 400: malformed payload, invalid input, missing required fields
|
||||||
|
- 401: unauthorized admin request
|
||||||
|
- 404: not found
|
||||||
|
- 405: method not allowed
|
||||||
|
- 409: logical conflict (for example, mismatched resumed upload)
|
||||||
|
- 413: payload too large
|
||||||
|
- 500: internal server error
|
||||||
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
|
## [1.0.6] - 2026-08-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `GET /api/admin/chat-log` endpoint with `?room=`, `?limit=`, `?before=`, `?username=`, `?text=` filter/pagination support.
|
||||||
|
- `GET /api/admin/chat-log/:id` endpoint returning a single chat log entry as JSON.
|
||||||
|
- `DELETE /api/admin/chat-log/:id` endpoint returning 204 No Content on success.
|
||||||
|
- `DELETE /api/admin/chat-log/bulk` endpoint accepting `{"ids": [...]}` for bulk deletion (max 500).
|
||||||
|
- Chat Log admin tab with table, individual delete with confirmation dialog, pagination (prev/next), search/filter by room/username/text, select-all checkbox, and bulk delete button.
|
||||||
|
- Added `"lint": "tsc --noEmit"` script to `frontend/package.json`.
|
||||||
|
- `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
||||||
|
- Resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||||
|
- Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
|
||||||
|
- Dedicated operational documentation: `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, `SECURITY.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Split `frontend/admin/index.html` into a template shell plus 9 per-tab page HTML files loaded dynamically via `loadPageContent` / `switchTab` / `bindPageEvents`.
|
||||||
|
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
|
||||||
|
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||||
|
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
|
||||||
|
- Applied new color palette (`#32292f` dark, `#b4c4ae` light, `#587792` brand, `#2ebfa5` focus, `#04650d` ok) across all 22 CSS custom properties in dark and light themes.
|
||||||
|
- Replaced hardcoded admin sidebar and tab-trigger colors with CSS custom properties.
|
||||||
|
- Optimized CI pipeline: removed duplicate `docker compose build --no-cache` from test job, removed broken playlist validation step, fixed Go module cache path, added Docker Buildx registry cache to docker/release jobs.
|
||||||
|
- Bumped chat healthcheck `start_period` from 10s to 30s in `docker-compose.prod.yml`.
|
||||||
|
- CD workflow now verifies nginx (port 8080) + chat (3000) + playout (8081); dumps container logs for all failing services.
|
||||||
|
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Go module cache in CI now correctly references only `backend/chat/go.sum` (playout go.sum did not exist); added `go mod download` step to seed cache.
|
||||||
|
- Removed broken playlist validation step (`go run src/playlist.go` without go.mod) from CI test job.
|
||||||
|
- Converted nginx CI build from custom shell `docker buildx` to `docker/build-push-action@v5` for cache consistency.
|
||||||
|
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
||||||
|
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks without 413 errors.
|
||||||
|
- Removed unused `data-admin-*` Vite env var attributes from admin HTML template.
|
||||||
|
- Removed Tailwind and Alpine runtime/build dependencies; eliminated Tailwind directives/warnings during production build.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-03
|
## [1.0.3] - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+119
-2
@@ -20,8 +20,8 @@ Thank you for considering contributing to Linear Void!
|
|||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Go: Use standard formatting (`go fmt`).
|
- Go: Use standard formatting (`go fmt`).
|
||||||
- TypeScript: ESLint and Prettier.
|
- TypeScript: `tsc --noEmit` for type-checking (see `frontend/package.json`).
|
||||||
- Tailwind: Follow the utility-first approach.
|
- CSS: Design-token system via CSS custom properties in `frontend/src/style.css`. No utility framework.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
@@ -30,4 +30,121 @@ Thank you for considering contributing to Linear Void!
|
|||||||
- Ensure all tests pass and documentation is updated.
|
- Ensure all tests pass and documentation is updated.
|
||||||
- The CI workflow will validate normalization, linting, and Docker builds.
|
- The CI workflow will validate normalization, linting, and Docker builds.
|
||||||
|
|
||||||
|
## Versioning and Releases
|
||||||
|
|
||||||
|
### Version Scheme
|
||||||
|
|
||||||
|
Linear Void follows **Semantic Versioning** ([SemVer](https://semver.org)): `MAJOR.MINOR.PATCH`.
|
||||||
|
|
||||||
|
- **MAJOR** — breaking changes to the viewer, API, or deployment model.
|
||||||
|
- **MINOR** — new features (endpoints, admin tabs, UI capabilities) that are backward-compatible.
|
||||||
|
- **PATCH** — bug fixes, optimizations, documentation polish.
|
||||||
|
|
||||||
|
Git tags use a `v` prefix: `1.0.2`, `1.1.0`, `v2.0.0`.
|
||||||
|
|
||||||
|
### Where Versions Are Declared
|
||||||
|
|
||||||
|
| File | Token | Example |
|
||||||
|
| ------------------------------ | -------------- | ------------------------- |
|
||||||
|
| `frontend/package.json` | `"version"` | `"1.0.2"` |
|
||||||
|
| `CHANGELOG.md` | `## [X.Y.Z]` | `## [1.0.2] - 2026-08-05` |
|
||||||
|
| `README.md` | Version badge | `version-1.0.2-blue` |
|
||||||
|
| `.github/instructions/TODO.md` | Section header | `## Version 1.0.2` |
|
||||||
|
|
||||||
|
### Tag Triggers
|
||||||
|
|
||||||
|
| Tag Pattern | Workflow | What Happens |
|
||||||
|
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `v*` (e.g. `1.0.2`) | `release.yml` | Builds all 3 images, pushes to registry with semver tags (`1.0.2`, `1.0`, `latest`), creates Gitea Release via API |
|
||||||
|
| Push to `main` | `ci.yml` → `cd.yml` | CI builds + pushes images with `main`/`latest` tags, CD deploys to production |
|
||||||
|
|
||||||
|
### Full Release Procedure
|
||||||
|
|
||||||
|
#### Step 1: Bump version numbers
|
||||||
|
|
||||||
|
Update these files to the new version (replace `X.Y.Z`):
|
||||||
|
|
||||||
|
- [frontend/package.json](frontend/package.json)
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — add new section at top
|
||||||
|
- [README.md](README.md) — version badge
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# frontend/package.json
|
||||||
|
"version": "X.Y.Z"
|
||||||
|
|
||||||
|
# CHANGELOG.md — add new section at top
|
||||||
|
## [X.Y.Z] - YYYY-MM-DD
|
||||||
|
|
||||||
|
# README.md — version badge
|
||||||
|
version-X.Y.Z-blue
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Write changelog
|
||||||
|
|
||||||
|
Add entries under `## [X.Y.Z]` in `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com) format (`Added`, `Changed`, `Fixed`, `Removed`).
|
||||||
|
|
||||||
|
#### Step 3: Commit and tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stage all version bumps + changelog
|
||||||
|
git add frontend/package.json CHANGELOG.md README.md .github/instructions/TODO.md
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git commit -m "chore: bump version to X.Y.Z"
|
||||||
|
|
||||||
|
# Create annotated tag
|
||||||
|
git tag -a X.Y.Z -m "Release X.Y.Z"
|
||||||
|
|
||||||
|
# Push commit and tag
|
||||||
|
git push origin main
|
||||||
|
git push origin X.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Verify CI/CD
|
||||||
|
|
||||||
|
- CI workflow (`ci.yml`) runs on push to `main` → builds & pushes `main`/`latest` images.
|
||||||
|
- Release workflow (`release.yml`) triggers on `X.Y.Z` tag → builds images with semver tags (`X.Y.Z`, `X.Y`, `latest`), creates Gitea Release.
|
||||||
|
- CD workflow (`cd.yml`) triggers after CI completes → deploys to production.
|
||||||
|
|
||||||
|
Monitor at: [git.allucanget.biz/zwitschi/linearvoid/actions](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
|
|
||||||
|
#### Step 5: Verify release
|
||||||
|
|
||||||
|
Check the Gitea Releases page for the new tag:
|
||||||
|
[git.allucanget.biz/zwitschi/linearvoid/releases](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
|
|
||||||
|
### Manual Release (workflow_dispatch)
|
||||||
|
|
||||||
|
If a tag push fails or a re-release is needed:
|
||||||
|
|
||||||
|
1. Go to [Actions → Release workflow](https://git.allucanget.biz/zwitschi/linearvoid/actions?workflow=release.yml)
|
||||||
|
2. Click "Run workflow"
|
||||||
|
3. Enter version (e.g. `1.0.2`)
|
||||||
|
4. The workflow builds images, pushes semver tags, and creates the Gitea Release
|
||||||
|
|
||||||
|
### Docker Image Tags
|
||||||
|
|
||||||
|
Every release produces these tags in the Gitea Container Registry:
|
||||||
|
|
||||||
|
| Image | Tags |
|
||||||
|
| --------- | ------------------------ |
|
||||||
|
| `playout` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
| `chat` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
| `nginx` | `X.Y.Z`, `X.Y`, `latest` |
|
||||||
|
|
||||||
|
Example for `1.0.2`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:1.0.2
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:1.0
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/chat:latest
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:1.0.2
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:1.0
|
||||||
|
git.allucanget.biz/zwitschi/linearvoid/playout:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Moving Completed Tasks
|
||||||
|
|
||||||
|
After release, move completed sections from `TODO.md` to `DONE.md` and update `CHANGELOG.md` if needed. The `DONE.md` file serves as the historical record linking tasks to releases.
|
||||||
|
|
||||||
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
|
See the ADRs for architectural decisions and `docs/architecture/README.md` for the big picture. Thank you for contributing!
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
# Production Deployment Guide
|
||||||
|
|
||||||
|
This guide describes a production-style deployment for Linear Void using Docker Compose, Gitea registry images, and Cloudflare fronting.
|
||||||
|
|
||||||
|
## Target Topology
|
||||||
|
|
||||||
|
- Host: Linux server with Docker Engine
|
||||||
|
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||||
|
- Core services: playout, chat, redis, postgres
|
||||||
|
- Observability: prometheus, grafana
|
||||||
|
- CDN/edge: Cloudflare
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine + Docker Compose plugin
|
||||||
|
- Network access to git.allucanget.biz
|
||||||
|
- A valid .env file with production values
|
||||||
|
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
||||||
|
|
||||||
|
## Environment File
|
||||||
|
|
||||||
|
Use .env with at least:
|
||||||
|
|
||||||
|
- CI_REGISTRY_URL
|
||||||
|
- CI_REGISTRY_NAMESPACE
|
||||||
|
- CHAT_PORT
|
||||||
|
- NGINX_PORT
|
||||||
|
- DATABASE_URL (if overridden)
|
||||||
|
- REDIS_URL (if overridden)
|
||||||
|
- ADMIN_USERNAME
|
||||||
|
- ADMIN_PASSWORD
|
||||||
|
- VITE_PUBLIC_APP_URL
|
||||||
|
- VITE_PUBLIC_WS_URL
|
||||||
|
- VITE_PUBLIC_HLS_URL
|
||||||
|
- Cloudflare/R2 variables when media sync is enabled
|
||||||
|
|
||||||
|
See .env.example and SETUP.md for full variable reference.
|
||||||
|
|
||||||
|
## Pull-and-Run Deployment
|
||||||
|
|
||||||
|
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||||
|
|
||||||
|
Every push to `main` produces `:main` and `:latest` tags. Semantic version tags (`v*`) produce `:X.Y.Z`, `:X.Y`, and `:latest` tags.
|
||||||
|
|
||||||
|
From repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.allucanget.biz
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
|
1. Ensure host paths exist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Confirm volumes and networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls
|
||||||
|
docker network ls
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Start stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Verification
|
||||||
|
|
||||||
|
After startup, check service status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint checks:
|
||||||
|
|
||||||
|
- App: <http://{host}:8080/>
|
||||||
|
- Nginx health: <http://{host}:8080/health>
|
||||||
|
- Chat health: <http://{host}:3000/health>
|
||||||
|
- Playout health: <http://{host}:8081/health>
|
||||||
|
- Prometheus: <http://{host}:9090>
|
||||||
|
- Grafana: <http://{host}:3001>
|
||||||
|
|
||||||
|
Domain checks:
|
||||||
|
|
||||||
|
- <https://linearvoid.tv>
|
||||||
|
- <https://linearvoid.tv/admin>
|
||||||
|
- <https://linearvoid.tv/metrics>
|
||||||
|
|
||||||
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
|
1. Pull newest images.
|
||||||
|
1. Recreate changed services only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Re-run health checks.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If new images are unhealthy:
|
||||||
|
|
||||||
|
1. Pin previous tags in docker-compose.prod.yml.
|
||||||
|
2. Pull pinned tags.
|
||||||
|
3. Restart impacted services.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Tasks
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart one service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml restart chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep volumes (recommended) vs remove volumes (destructive)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# keep data
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# destructive (remove data)
|
||||||
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx and Proxy Considerations
|
||||||
|
|
||||||
|
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx.
|
||||||
|
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
|
## Security Baseline
|
||||||
|
|
||||||
|
- Use strong ADMIN_PASSWORD and rotate regularly.
|
||||||
|
- Keep Docker host access restricted.
|
||||||
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
|
- Restrict exposed ports if only proxy access is needed.
|
||||||
|
|
||||||
|
## Common Deployment Pitfalls
|
||||||
|
|
||||||
|
- Missing frontend build artifacts in local-only compose workflows.
|
||||||
|
- Wrong registry credentials or missing login.
|
||||||
|
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||||
|
- Proxy-layer body size limits returning 413 on upload.
|
||||||
|
- Health checks failing before service warmup is complete.
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Monitoring Guide
|
||||||
|
|
||||||
|
This guide covers Prometheus and Grafana setup for Linear Void.
|
||||||
|
|
||||||
|
## Current Monitoring Components
|
||||||
|
|
||||||
|
- Prometheus container: linearvoid-prometheus
|
||||||
|
- Grafana container: linearvoid-grafana
|
||||||
|
- Metrics target: chat service /metrics
|
||||||
|
- Base scrape config: monitoring/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
## Prometheus Configuration
|
||||||
|
|
||||||
|
Current scrape config:
|
||||||
|
|
||||||
|
- Job chat -> chat:3000
|
||||||
|
- Job prometheus -> prometheus:9090
|
||||||
|
- Global scrape interval: 15s
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
- monitoring/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
## Start Monitoring Stack
|
||||||
|
|
||||||
|
With compose stack up:
|
||||||
|
|
||||||
|
- Prometheus UI: <http://localhost:9090>
|
||||||
|
- Grafana UI: <http://localhost:3001>
|
||||||
|
|
||||||
|
Compose services include prometheus and grafana in both docker-compose.yml and docker-compose.prod.yml.
|
||||||
|
|
||||||
|
## Grafana Provisioning
|
||||||
|
|
||||||
|
Current mount paths:
|
||||||
|
|
||||||
|
- Dashboards directory: monitoring/grafana/dashboards
|
||||||
|
- Datasources directory: monitoring/grafana/datasources
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The dashboards directory includes a Prometheus overview dashboard file.
|
||||||
|
- Datasource provisioning can be done with a YAML file in monitoring/grafana/datasources, or by configuring a datasource in Grafana UI.
|
||||||
|
|
||||||
|
Example datasource provisioning file (create if needed):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard References
|
||||||
|
|
||||||
|
- Provisioned dashboard: [monitoring/grafana/dashboards/dashboard.json](monitoring/grafana/dashboards/dashboard.json)
|
||||||
|
- Root dashboard snapshot: [monitoring/grafana/dashboard.json](monitoring/grafana/dashboard.json)
|
||||||
|
|
||||||
|
Grafana screenshot:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Key Metrics to Track
|
||||||
|
|
||||||
|
### Chat/Application
|
||||||
|
|
||||||
|
- linearvoid_chat_connected_clients
|
||||||
|
- linearvoid_chat_stored_messages_total
|
||||||
|
- linearvoid_chat_moderation_actions_total
|
||||||
|
- linearvoid_chat_redis_enabled
|
||||||
|
- linearvoid_chat_redis_up
|
||||||
|
- linearvoid_chat_active_admin_sessions
|
||||||
|
|
||||||
|
### Prometheus Runtime
|
||||||
|
|
||||||
|
- up
|
||||||
|
- process_cpu_seconds_total
|
||||||
|
- process_resident_memory_bytes
|
||||||
|
- prometheus_tsdb_head_series
|
||||||
|
- prometheus_tsdb_wal_writes_failed_total
|
||||||
|
- prometheus_tsdb_compactions_failed_total
|
||||||
|
|
||||||
|
## Suggested Alerts
|
||||||
|
|
||||||
|
Create a Prometheus rules file (for example monitoring/prometheus/alerts.yml) and wire it into Prometheus.
|
||||||
|
|
||||||
|
Recommended baseline alerts:
|
||||||
|
|
||||||
|
- Chat target down for 2m
|
||||||
|
- Prometheus target down for 2m
|
||||||
|
- Redis indicator down for 2m
|
||||||
|
- TSDB WAL/compaction failure rate above zero
|
||||||
|
- Elevated handler/query latency p99
|
||||||
|
|
||||||
|
## Validate Monitoring End-to-End
|
||||||
|
|
||||||
|
1. Verify /metrics through nginx:
|
||||||
|
|
||||||
|
- <https://linearvoid.tv/metrics>
|
||||||
|
|
||||||
|
1. Confirm targets in Prometheus:
|
||||||
|
|
||||||
|
- Status -> Targets page in Prometheus UI
|
||||||
|
|
||||||
|
1. Confirm dashboard data in Grafana:
|
||||||
|
|
||||||
|
- Import or open provisioned dashboards
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No chat metrics in Prometheus
|
||||||
|
|
||||||
|
- Check chat container health and logs.
|
||||||
|
- Confirm /metrics is reachable from Prometheus container.
|
||||||
|
- Verify scrape target is chat:3000 in prometheus.yml.
|
||||||
|
|
||||||
|
### Grafana shows no data
|
||||||
|
|
||||||
|
- Ensure datasource points to <http://prometheus:9090>.
|
||||||
|
- Confirm Prometheus is healthy.
|
||||||
|
- Check dashboard query labels match active jobs.
|
||||||
|
|
||||||
|
### High cardinality or query slowness
|
||||||
|
|
||||||
|
- Reduce unneeded labels.
|
||||||
|
- Limit expensive dashboard panels.
|
||||||
|
- Tune retention and query windows.
|
||||||
|
|
||||||
|
## Operational Recommendations
|
||||||
|
|
||||||
|
- Keep alert thresholds tied to actual user impact.
|
||||||
|
- Review dashboards after each release that changes endpoints or metrics names.
|
||||||
|
- Periodically test alert routing and incident response flow.
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -27,6 +29,23 @@ See [`docs/architecture/adr/`](docs/architecture/adr/) for the full set of decis
|
|||||||
- Video normalization, monitoring, fallback slate, and easy playlist management.
|
- Video normalization, monitoring, fallback slate, and easy playlist management.
|
||||||
- US-centric performance (Ashburn origin + Cloudflare POPs).
|
- US-centric performance (Ashburn origin + Cloudflare POPs).
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
V[US Viewers] -->|HTTPS| CF[Cloudflare CDN]
|
||||||
|
CF -->|Cache HIT| R2[Cloudflare R2]
|
||||||
|
CF -->|Cache MISS| N[Nginx :80]
|
||||||
|
N -->|/ws| CH[Chat :3000]
|
||||||
|
N -->|/admin| CH
|
||||||
|
N -->|/hls| P[Playout :8081]
|
||||||
|
N -->|/*| FE[Frontend SPA]
|
||||||
|
CH --> R[(Redis)]
|
||||||
|
CH --> DB[(PostgreSQL)]
|
||||||
|
P -->|ffmpeg| HLS[/data/hls/]
|
||||||
|
P -->|sync| R2
|
||||||
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -97,9 +116,16 @@ This structure directly reflects the building blocks, runtime flows, deployment
|
|||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||||
cd linearvoid
|
cd linearvoid
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# Edit .env with your R2 credentials and domain settings
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- **Viewer**: [https://linearvoid.tv](https://linearvoid.tv) (or `http://localhost`)
|
||||||
|
- **Admin**: [https://linearvoid.tv/admin](https://linearvoid.tv/admin) (or `http://localhost/admin`)
|
||||||
|
- **Metrics**: [https://linearvoid.tv/metrics](https://linearvoid.tv/metrics)
|
||||||
|
|
||||||
Full setup guide: [`SETUP.md`](SETUP.md).
|
Full setup guide: [`SETUP.md`](SETUP.md).
|
||||||
|
|
||||||
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
See the [architecture docs](docs/architecture/README.md) for detailed architecture and ADRs.
|
||||||
@@ -111,6 +137,50 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
|||||||
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
|
- Normalize all source videos before adding to playlist (see ADR-001 for exact codec/fps/keyframe requirements).
|
||||||
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
- Monitor cache hit ratio and R2 operations closely — they determine real cost.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ---------------------- | ----------------------------------------------- | -------------------------------- |
|
||||||
|
| `R2_ACCESS_KEY_ID` | — | Cloudflare R2 access key |
|
||||||
|
| `R2_SECRET_ACCESS_KEY` | — | Cloudflare R2 secret key |
|
||||||
|
| `R2_BUCKET_NAME` | `linearvoid-media` | R2 bucket for HLS segments |
|
||||||
|
| `DATABASE_URL` | `postgres://...@db:5432/...` | Chat DB (Postgres or SQLite) |
|
||||||
|
| `REDIS_URL` | `redis://redis:6379/0` | Redis for presence/rate limiting |
|
||||||
|
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | Public viewer URL |
|
||||||
|
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | WebSocket endpoint |
|
||||||
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||||
|
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||||
|
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
||||||
|
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
||||||
|
|
||||||
|
Full reference: [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Check |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------------------------- |
|
||||||
|
| **Playout not starting** | Verify playlist exists in `data/playlist/playlist.txt` and media files are accessible |
|
||||||
|
| **HLS not playing** | Check `docker compose logs playout` for ffmpeg errors; verify `data/hls/` permissions |
|
||||||
|
| **Chat not connecting** | Ensure Redis is running: `docker compose logs redis`; check `DATABASE_URL` |
|
||||||
|
| **Admin panel 401** | Verify `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env` match login credentials |
|
||||||
|
| **Docker build fails** | Run `docker compose build --no-cache`; check `backend/playout/.dockerignore` |
|
||||||
|
| **R2 upload failures** | Verify `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`; check bucket CORS settings |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Service | Endpoint | Description |
|
||||||
|
| ----------- | ------------------------ | -------------------------- |
|
||||||
|
| **Chat** | `GET /health` | Health check |
|
||||||
|
| **Chat** | `GET /metrics` | Prometheus metrics |
|
||||||
|
| **Chat** | `GET /ws` | WebSocket upgrade for chat |
|
||||||
|
| **Chat** | `GET /api/admin/*` | Admin API (auth required) |
|
||||||
|
| **Chat** | `POST /api/admin/login` | Admin login |
|
||||||
|
| **Playout** | `GET /health` | Health check (port 8081) |
|
||||||
|
| **Nginx** | `GET /` | Viewer SPA |
|
||||||
|
| **Nginx** | `GET /admin/` | Admin panel |
|
||||||
|
| **Nginx** | `GET /hls/playlist.m3u8` | HLS manifest |
|
||||||
|
|
||||||
## Architecture Documentation
|
## Architecture Documentation
|
||||||
|
|
||||||
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
|
The architecture is fully documented using the [arc42 template](docs/architecture/README.md) (with populated sections, Mermaid context and building-block diagrams, deployment view, cross-cutting concepts, quality goals, risks, and glossary).
|
||||||
@@ -134,6 +204,10 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||||
|
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
|
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
|
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||||
|
|
||||||
@@ -141,4 +215,17 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
|
|
||||||
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
**Container Registry**: `git.allucanget.biz/zwitschi/linearvoid/<service>:latest`
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines. Quick overview:
|
||||||
|
|
||||||
|
1. Read the [architecture docs](docs/architecture/README.md) and ADRs first.
|
||||||
|
2. Plan tasks in `.github/instructions/TODO.md`.
|
||||||
|
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||||
|
4. CI validates builds and tests automatically.
|
||||||
|
|
||||||
|
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry.
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
# Security Guide
|
||||||
|
|
||||||
|
This document summarizes security controls and best practices for Linear Void.
|
||||||
|
|
||||||
|
## Security Model Overview
|
||||||
|
|
||||||
|
Security is layered across:
|
||||||
|
|
||||||
|
- Cloudflare edge
|
||||||
|
- nginx reverse proxy
|
||||||
|
- chat/admin backend
|
||||||
|
- container and host operations
|
||||||
|
|
||||||
|
## Authentication and Sessions
|
||||||
|
|
||||||
|
Admin access is session-cookie based.
|
||||||
|
|
||||||
|
- Login endpoint: /api/admin/login
|
||||||
|
- Session endpoint: /api/admin/session
|
||||||
|
- Logout endpoint: /api/admin/logout
|
||||||
|
- Cookie: linearvoid_admin_session
|
||||||
|
- Cookie flags: HttpOnly, SameSite=Strict, Secure over HTTPS
|
||||||
|
- Session expiry enforced server-side
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Set strong ADMIN_USERNAME and ADMIN_PASSWORD values.
|
||||||
|
- Rotate admin credentials periodically.
|
||||||
|
- Keep ADMIN_SESSION_TTL_HOURS aligned with operational policy.
|
||||||
|
|
||||||
|
## Transport Security
|
||||||
|
|
||||||
|
- Enforce HTTPS for app/admin endpoints.
|
||||||
|
- Use WSS for chat WebSocket.
|
||||||
|
- Keep Cloudflare TLS mode strict and valid at origin.
|
||||||
|
|
||||||
|
## Reverse Proxy Controls
|
||||||
|
|
||||||
|
nginx controls include:
|
||||||
|
|
||||||
|
- auth_request guard for /admin and /api/admin paths
|
||||||
|
- dedicated unauthorized behavior for admin API responses
|
||||||
|
- explicit websocket upgrade headers for /ws
|
||||||
|
- upload body-size limits for resumable media chunk uploads
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Keep proxy body-size and timeout settings aligned across all proxy layers.
|
||||||
|
- Avoid exposing backend service ports publicly unless required.
|
||||||
|
|
||||||
|
## Input Validation and Upload Safety
|
||||||
|
|
||||||
|
Server-side protections include:
|
||||||
|
|
||||||
|
- extension allow-list for media uploads
|
||||||
|
- MIME checks
|
||||||
|
- file-size ceiling (2GB)
|
||||||
|
- resumable chunk bounds and chunk-index validation
|
||||||
|
- media path safety checks on delete
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Keep upload chunk size bounded.
|
||||||
|
- Reject unsupported content types consistently.
|
||||||
|
- Monitor 4xx/5xx rates on upload routes.
|
||||||
|
|
||||||
|
## Rate Limiting and Abuse Controls
|
||||||
|
|
||||||
|
- Chat rate limiting uses Redis INCR + EXPIRE windows.
|
||||||
|
- Current baseline: more than 10 messages in 10 seconds is throttled.
|
||||||
|
- Moderation actions are logged for auditability.
|
||||||
|
|
||||||
|
Best practices:
|
||||||
|
|
||||||
|
- Tune thresholds by observed abuse patterns.
|
||||||
|
- Keep moderation logs reviewed and retained per policy.
|
||||||
|
|
||||||
|
## Infrastructure and Secrets
|
||||||
|
|
||||||
|
- Store secrets in .env (not in committed source).
|
||||||
|
- Use Gitea Actions secrets for CI/CD credentials and admin credentials.
|
||||||
|
- Restrict Docker host control plane exposure.
|
||||||
|
- Limit who can read deployment environment files.
|
||||||
|
|
||||||
|
## Cloudflare and Edge Security
|
||||||
|
|
||||||
|
Recommended baseline:
|
||||||
|
|
||||||
|
- WAF enabled
|
||||||
|
- Rate limits on sensitive paths
|
||||||
|
- Bot protection where appropriate
|
||||||
|
- Cache rules scoped carefully to avoid caching private/control-plane content
|
||||||
|
|
||||||
|
## Data Protection and Retention
|
||||||
|
|
||||||
|
- Persistent data: postgres, redis append-only, chat state, playlist/media metadata
|
||||||
|
- Backup and restore drills should be periodic
|
||||||
|
- Restore procedures should be tested in isolated environments
|
||||||
|
|
||||||
|
## Logging and Monitoring for Security
|
||||||
|
|
||||||
|
Track and alert on:
|
||||||
|
|
||||||
|
- repeated failed admin login attempts
|
||||||
|
- spikes in 401 and 403 responses
|
||||||
|
- sudden upload errors (413/400/500 patterns)
|
||||||
|
- unusual websocket disconnect or message-rate anomalies
|
||||||
|
|
||||||
|
## Hardening Checklist
|
||||||
|
|
||||||
|
- [ ] HTTPS/WSS enforced end-to-end
|
||||||
|
- [ ] Strong admin credentials configured
|
||||||
|
- [ ] Secrets only in env/secrets stores
|
||||||
|
- [ ] Proxy auth and websocket headers validated
|
||||||
|
- [ ] Upload limits and validation active
|
||||||
|
- [ ] Rate limiting active and tested
|
||||||
|
- [ ] Cloudflare WAF/rate-limit rules active
|
||||||
|
- [ ] Backup/restore drills completed
|
||||||
|
- [ ] Security-related monitoring alerts routed
|
||||||
|
|
||||||
|
## Incident Response Baseline
|
||||||
|
|
||||||
|
1. Detect issue (alerts/logs/user reports).
|
||||||
|
2. Triage scope (edge, proxy, app, data plane).
|
||||||
|
3. Contain impact (rate limit, credential rotate, route disable).
|
||||||
|
4. Recover services and verify health.
|
||||||
|
5. Record timeline and corrective actions.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache gcc musl-dev
|
RUN apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ RUN go mod tidy
|
|||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk --no-cache add ca-certificates curl curl
|
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
module chat
|
module chat
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
|||||||
+1043
-27
File diff suppressed because it is too large.
Load diff
@@ -18,14 +18,15 @@ RUN apk add --no-cache \
|
|||||||
bash
|
bash
|
||||||
# Set working directory for playout
|
# Set working directory for playout
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Create directories for playlist, media, and HLS output
|
||||||
|
RUN mkdir -p /app/hls /app/media /app/playlists
|
||||||
# Copy the playout start script and the built playlist manager binary
|
# Copy the playout start script and the built playlist manager binary
|
||||||
COPY start-playout.sh /app/
|
COPY start-playout.sh /app/
|
||||||
# Copy demo data for out-of-the-box playout
|
# Copy demo data for out-of-the-box playout
|
||||||
COPY demodata/ /app/
|
COPY demodata/ /app/
|
||||||
# Copy the built playlist manager binary from the builder stage
|
# Copy the built playlist manager binary from the builder stage
|
||||||
COPY --from=builder /playlist-manager /app/playlist-manager
|
COPY --from=builder /playlist-manager /app/playlist-manager
|
||||||
# Create directories for playlist, media, and HLS output
|
# Set execute permissions for the start script and playlist manager binary
|
||||||
RUN mkdir -p /app/hls
|
|
||||||
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
RUN chmod +x /app/start-playout.sh /app/playlist-manager
|
||||||
# Expose HLS directory (will be served by nginx)
|
# Expose HLS directory (will be served by nginx)
|
||||||
VOLUME ["/app/hls"]
|
VOLUME ["/app/hls"]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
image: git.allucanget.biz/zwitschi/linearvoid/nginx:latest
|
||||||
|
|||||||
+438
-189
@@ -4,18 +4,86 @@
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Introduction and Goals](#introduction-and-goals)
|
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||||
- [Architecture Constraints](#architecture-constraints)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Context and Scope](#context-and-scope)
|
- [Companion Documentation](#companion-documentation)
|
||||||
- [Solution Strategy](#solution-strategy)
|
- [Introduction and Goals](#introduction-and-goals)
|
||||||
- [Building Block View](#building-block-view)
|
- [Requirements Overview](#requirements-overview)
|
||||||
- [Runtime View](#runtime-view)
|
- [Quality Goals](#quality-goals)
|
||||||
- [Deployment View](#deployment-view)
|
- [Stakeholders](#stakeholders)
|
||||||
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
- [Architecture Constraints](#architecture-constraints)
|
||||||
- [Architecture Decisions](#architecture-decisions)
|
- [Context and Scope](#context-and-scope)
|
||||||
- [Quality Requirements](#quality-requirements)
|
- [Business Context](#business-context)
|
||||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
- [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
|
||||||
- [Glossary](#glossary)
|
- [Technical Context](#technical-context)
|
||||||
|
- [Solution Strategy](#solution-strategy)
|
||||||
|
- [Building Block View](#building-block-view)
|
||||||
|
- [Whitebox Overall System](#whitebox-overall-system)
|
||||||
|
- [Level 2](#level-2)
|
||||||
|
- [Level 3](#level-3)
|
||||||
|
- [Runtime View](#runtime-view)
|
||||||
|
- [Playout \& Delivery Scenario](#playout--delivery-scenario)
|
||||||
|
- [Deployment View](#deployment-view)
|
||||||
|
- [CI/CD Pipeline Architecture](#cicd-pipeline-architecture)
|
||||||
|
- [Infrastructure Level 1](#infrastructure-level-1)
|
||||||
|
- [Infrastructure Level 2](#infrastructure-level-2)
|
||||||
|
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
||||||
|
- [Caching \& Cost Optimization](#caching--cost-optimization)
|
||||||
|
- [Video Normalization \& Playout](#video-normalization--playout)
|
||||||
|
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
|
||||||
|
- [Reference Normalization Command](#reference-normalization-command)
|
||||||
|
- [Verification Gate (ffprobe)](#verification-gate-ffprobe)
|
||||||
|
- [Monitoring \& Resilience](#monitoring--resilience)
|
||||||
|
- [Prometheus Baseline](#prometheus-baseline)
|
||||||
|
- [Grafana Dashboards](#grafana-dashboards)
|
||||||
|
- [Alerting Rules (Prometheus Rule Groups)](#alerting-rules-prometheus-rule-groups)
|
||||||
|
- [API Documentation Reference](#api-documentation-reference)
|
||||||
|
- [Chat WebSocket API](#chat-websocket-api)
|
||||||
|
- [Playout and Delivery REST API](#playout-and-delivery-rest-api)
|
||||||
|
- [Admin REST API](#admin-rest-api)
|
||||||
|
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
|
||||||
|
- [DNS and Zone Routing](#dns-and-zone-routing)
|
||||||
|
- [Cache Rules Baseline](#cache-rules-baseline)
|
||||||
|
- [Edge Security and Controls](#edge-security-and-controls)
|
||||||
|
- [Worker and Header Policy](#worker-and-header-policy)
|
||||||
|
- [Operations and Validation](#operations-and-validation)
|
||||||
|
- [Security \& Moderation](#security--moderation)
|
||||||
|
- [Transport and Edge Protection](#transport-and-edge-protection)
|
||||||
|
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
|
||||||
|
- [Admin Authentication and Session Security](#admin-authentication-and-session-security)
|
||||||
|
- [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
|
||||||
|
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
|
||||||
|
- [Moderation and Privacy](#moderation-and-privacy)
|
||||||
|
- [Chat](#chat)
|
||||||
|
- [Architecture Decisions](#architecture-decisions)
|
||||||
|
- [Quality Requirements](#quality-requirements)
|
||||||
|
- [Performance Benchmarks and Capacity Planning](#performance-benchmarks-and-capacity-planning)
|
||||||
|
- [Baseline SLO Targets (v1)](#baseline-slo-targets-v1)
|
||||||
|
- [Workload Assumptions](#workload-assumptions)
|
||||||
|
- [Initial Capacity Model](#initial-capacity-model)
|
||||||
|
- [Benchmark Plan](#benchmark-plan)
|
||||||
|
- [Capacity Triggers and Actions](#capacity-triggers-and-actions)
|
||||||
|
- [Scaling Strategy](#scaling-strategy)
|
||||||
|
- [Phase 1: Vertical + Cache-First (Current Baseline)](#phase-1-vertical--cache-first-current-baseline)
|
||||||
|
- [Phase 2: Service Separation](#phase-2-service-separation)
|
||||||
|
- [Phase 3: Multi-Origin Resilience](#phase-3-multi-origin-resilience)
|
||||||
|
- [Phase 4: Regional and Throughput Expansion](#phase-4-regional-and-throughput-expansion)
|
||||||
|
- [Scaling Triggers](#scaling-triggers)
|
||||||
|
- [Disaster Recovery and Backup Procedures](#disaster-recovery-and-backup-procedures)
|
||||||
|
- [Recovery Targets](#recovery-targets)
|
||||||
|
- [Backup Scope](#backup-scope)
|
||||||
|
- [Backup Cadence](#backup-cadence)
|
||||||
|
- [Recovery Runbook (Minimum)](#recovery-runbook-minimum)
|
||||||
|
- [Failure Modes and Strategy](#failure-modes-and-strategy)
|
||||||
|
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||||
|
- [Glossary](#glossary)
|
||||||
|
|
||||||
|
## Companion Documentation
|
||||||
|
|
||||||
|
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
||||||
|
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
|
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
|
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
@@ -107,13 +175,6 @@ flowchart TD
|
|||||||
Chat --> VPS
|
Chat --> VPS
|
||||||
VPS -->|Proxy / Static| Pages
|
VPS -->|Proxy / Static| Pages
|
||||||
CDN -->|Origin Pull on Miss| R2
|
CDN -->|Origin Pull on Miss| R2
|
||||||
|
|
||||||
classDef cloud fill:#f0f0ff,stroke:#4263eb
|
|
||||||
classDef vps fill:#f0fff0,stroke:#2e7d32
|
|
||||||
classDef user fill:#fff0f0,stroke:#c62828
|
|
||||||
class Cloudflare,CDN,Pages,R2 cloud
|
|
||||||
class VPS,Playout,Chat vps
|
|
||||||
class Viewers,Admins user
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**External Interfaces**:
|
**External Interfaces**:
|
||||||
@@ -196,7 +257,11 @@ graph TD
|
|||||||
|
|
||||||
Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
|
Detailed black-box descriptions, level-2/3 views, and code locations are in `adr/ADR-001.md` and future implementation ADRs.
|
||||||
|
|
||||||
### Level 2 & 3
|
### Level 2
|
||||||
|
|
||||||
|
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
||||||
|
|
||||||
|
### Level 3
|
||||||
|
|
||||||
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
Deferred to implementation (see ADR for component responsibilities, interfaces, quality attributes, and risks). Playout enforces normalization; Delivery layer focuses on cache rules and immutable naming.
|
||||||
|
|
||||||
@@ -251,13 +316,6 @@ flowchart LR
|
|||||||
DOCKER --> DB
|
DOCKER --> DB
|
||||||
DOCKER --> PROM
|
DOCKER --> PROM
|
||||||
DOCKER --> GRAF
|
DOCKER --> GRAF
|
||||||
|
|
||||||
classDef ci fill:#e1f5fe,stroke:#0288d1
|
|
||||||
classDef reg fill:#fff3e0,stroke:#f57c00
|
|
||||||
classDef prod fill:#e8f5e9,stroke:#388e3c
|
|
||||||
class CI,CD ci
|
|
||||||
class REGISTRY reg
|
|
||||||
class DOCKER,PLAYOUT,CHAT,NGINX,REDIS,DB,PROM,GRAF prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pipeline Sequence:**
|
**Pipeline Sequence:**
|
||||||
@@ -311,15 +369,224 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
|
|||||||
|
|
||||||
### Video Normalization & Playout
|
### Video Normalization & Playout
|
||||||
|
|
||||||
All source assets must be pre-normalized (H.264/AVC, AAC-LC, constant 30 fps, 2s keyframe interval, CRF). Prevents glitches during continuous concat. FFmpeg `-c copy` preferred for efficiency.
|
All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout.
|
||||||
|
|
||||||
|
#### Required Source Profile (Linear Void v1)
|
||||||
|
|
||||||
|
| Field | Required Value | Notes |
|
||||||
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------- |
|
||||||
|
| Container | MP4 | Single-video-asset input format |
|
||||||
|
| Video codec | H.264/AVC (`libx264`) | Baseline delivery codec for broad compatibility |
|
||||||
|
| Audio codec | AAC-LC | Stereo preferred |
|
||||||
|
| Resolution | 1280x720 or 1920x1080 | Keep single resolution per playlist block when possible |
|
||||||
|
| Framerate | Constant 30 fps (`-r 30`) | Variable frame rate content must be normalized |
|
||||||
|
| GOP / keyframe interval | 2 seconds (`-g 60 -keyint_min 60`) | Matches HLS segment boundaries better |
|
||||||
|
| Pixel format | `yuv420p` | Browser compatibility baseline |
|
||||||
|
| Audio sample rate | 48 kHz | Stable timing for concat playout |
|
||||||
|
| Audio bitrate | 128k-192k | Predictable quality envelope |
|
||||||
|
|
||||||
|
#### Reference Normalization Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i input.mov \
|
||||||
|
-c:v libx264 -preset medium -crf 21 \
|
||||||
|
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||||
|
-pix_fmt yuv420p \
|
||||||
|
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||||
|
-movflags +faststart \
|
||||||
|
output-normalized.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Verification Gate (ffprobe)
|
||||||
|
|
||||||
|
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance.
|
||||||
|
- Reject non-conforming files from admin ingest with actionable error feedback.
|
||||||
|
- Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible.
|
||||||
|
|
||||||
### Monitoring & Resilience
|
### Monitoring & Resilience
|
||||||
|
|
||||||
Alert on no new segment (>30–60s), low cache hits, high R2 ops, WebSocket errors. Maintain emergency slate, auto-restart, weekly backups, incident runbook.
|
Linear Void uses a two-layer monitoring model:
|
||||||
|
|
||||||
|
1. **Application layer (chat + platform health)** via Prometheus metrics and a lightweight Grafana dashboard.
|
||||||
|
2. **Infrastructure/runtime layer (Prometheus self-observability, TSDB, query engine, Go runtime)** via a detailed Grafana dashboard.
|
||||||
|
|
||||||
|
#### Prometheus Baseline
|
||||||
|
|
||||||
|
- Config file: `monitoring/prometheus/prometheus.yml`
|
||||||
|
- Default scrape interval: `15s`
|
||||||
|
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
|
||||||
|
|
||||||
|
Recommended next-step scrape targets (if/when exporters are introduced):
|
||||||
|
|
||||||
|
- `nginx` (via nginx-exporter)
|
||||||
|
- `redis` (via redis-exporter)
|
||||||
|
- `postgres` (via postgres-exporter)
|
||||||
|
- `node` (via node-exporter)
|
||||||
|
|
||||||
|
#### Grafana Dashboards
|
||||||
|
|
||||||
|
- `monitoring/grafana/dashboard.json` (Linear Void app metrics: connected clients, messages, moderation actions, Redis reachability, admin sessions, scrape health).
|
||||||
|
- `monitoring/grafana/dashboards/dashboard.json` (Prometheus operational visibility: status, uptime, CPU/memory, TSDB ingestion/churn/errors, HTTP latencies, query load, GC/scheduler).
|
||||||
|
|
||||||
|
Use the app dashboard for service-level behavior and the Prometheus overview dashboard for platform diagnostics and capacity planning.
|
||||||
|
|
||||||
|
#### Alerting Rules (Prometheus Rule Groups)
|
||||||
|
|
||||||
|
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
|
||||||
|
|
||||||
|
Minimum recommended alert set:
|
||||||
|
|
||||||
|
- **Availability**
|
||||||
|
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
|
||||||
|
- **Chat service health**
|
||||||
|
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
|
||||||
|
- **Playout continuity**
|
||||||
|
No HLS segment progression for `30-60s` (from playout-specific metric when available).
|
||||||
|
- **Prometheus capacity and ingestion health**
|
||||||
|
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
|
||||||
|
- **Latency and user-impact**
|
||||||
|
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
|
||||||
|
|
||||||
|
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
|
||||||
|
|
||||||
|
Operational resilience baseline:
|
||||||
|
|
||||||
|
- Emergency slate fallback for playout failure.
|
||||||
|
- Auto-restart policies for playout/chat/nginx containers.
|
||||||
|
- Weekly backup and restore drills for stateful data (`data/chat`, playlist metadata, Postgres).
|
||||||
|
- Incident runbook with triage order: edge reachability -> origin health -> playout progression -> chat connectivity -> data-plane dependencies.
|
||||||
|
|
||||||
|
### API Documentation Reference
|
||||||
|
|
||||||
|
Linear Void currently exposes three operational API surfaces. The canonical detailed contract should live in `API.md`, while this section provides the architecture-level map.
|
||||||
|
|
||||||
|
#### Chat WebSocket API
|
||||||
|
|
||||||
|
- Endpoint: `/ws`
|
||||||
|
- Transport: `wss://` in production, `ws://` locally
|
||||||
|
- Query parameters:
|
||||||
|
- `username`
|
||||||
|
- `room`
|
||||||
|
- Message envelope (typical):
|
||||||
|
- `room`
|
||||||
|
- `username`
|
||||||
|
- `text`
|
||||||
|
- `type` (`public`, `private`, `system`)
|
||||||
|
- Responsibilities:
|
||||||
|
- room fan-out
|
||||||
|
- private DM channel routing
|
||||||
|
- moderation command handling
|
||||||
|
- rate limiting and presence integration
|
||||||
|
|
||||||
|
#### Playout and Delivery REST API
|
||||||
|
|
||||||
|
- Health endpoints:
|
||||||
|
- playout health (`/health` on playout service)
|
||||||
|
- edge/origin health (`/health` via nginx)
|
||||||
|
- Stream delivery:
|
||||||
|
- HLS manifest and segment path namespace under `/hls/`
|
||||||
|
- Responsibilities:
|
||||||
|
- expose liveness for orchestration and monitoring
|
||||||
|
- provide stable HLS path contract for viewer and CDN
|
||||||
|
|
||||||
|
#### Admin REST API
|
||||||
|
|
||||||
|
- Auth/session:
|
||||||
|
- `/api/admin/login`
|
||||||
|
- `/api/admin/logout`
|
||||||
|
- `/api/admin/session`
|
||||||
|
- Operational/admin domains:
|
||||||
|
- overview, analytics, users, moderation, schedule, playlist, media
|
||||||
|
- Media upload domain:
|
||||||
|
- `/api/admin/media/uploads` (init/resume)
|
||||||
|
- `/api/admin/media/uploads/{uploadId}/chunk`
|
||||||
|
- `/api/admin/media/uploads/{uploadId}/complete`
|
||||||
|
- Responsibilities:
|
||||||
|
- protected control plane for operator workflows
|
||||||
|
- media metadata + upload orchestration
|
||||||
|
- moderation/audit and system status management
|
||||||
|
|
||||||
|
### Cloudflare Configuration Reference
|
||||||
|
|
||||||
|
Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets.
|
||||||
|
|
||||||
|
#### DNS and Zone Routing
|
||||||
|
|
||||||
|
- `linearvoid.tv` -> application origin (nginx)
|
||||||
|
- `www.linearvoid.tv` -> redirect to apex
|
||||||
|
- `media.linearvoid.tv` -> media domain for HLS/object delivery when split-domain mode is enabled
|
||||||
|
|
||||||
|
#### Cache Rules Baseline
|
||||||
|
|
||||||
|
- HLS segments (`.ts`, `.m4s`):
|
||||||
|
- Cache Everything
|
||||||
|
- long TTL (immutable)
|
||||||
|
- respect immutable semantics
|
||||||
|
- Manifests (`.m3u8`):
|
||||||
|
- short TTL (1-10s)
|
||||||
|
- revalidation-friendly policy for timeline freshness
|
||||||
|
|
||||||
|
#### Edge Security and Controls
|
||||||
|
|
||||||
|
- Enforce HTTPS/WSS and redirect HTTP to HTTPS.
|
||||||
|
- Enable WAF baseline protections for common web exploits.
|
||||||
|
- Apply rate limits for abusive request patterns (especially auth/admin paths).
|
||||||
|
- Keep origin exposure minimized (prefer Cloudflare-only public ingress where feasible).
|
||||||
|
|
||||||
|
#### Worker and Header Policy
|
||||||
|
|
||||||
|
- Use Worker logic for immutable cache headers and request normalization where cache rules alone are insufficient.
|
||||||
|
- Keep CORS behavior explicit for HLS resources to support browser playback.
|
||||||
|
|
||||||
|
#### Operations and Validation
|
||||||
|
|
||||||
|
- Validate cache behavior after deployment:
|
||||||
|
- expected cache status progression (MISS -> HIT)
|
||||||
|
- manifest freshness under active playout
|
||||||
|
- Track edge metrics and adjust:
|
||||||
|
- cache hit ratio
|
||||||
|
- request volume and error rates
|
||||||
|
- ToS/cost posture for sustained traffic profiles
|
||||||
|
|
||||||
### Security & Moderation
|
### Security & Moderation
|
||||||
|
|
||||||
HTTPS/WSS only, strict CORS, Cloudflare WAF, authenticated admin paths, rate limiting, immutable moderation log. Private DMs not E2EE by default.
|
Security is split between edge controls (Cloudflare), reverse proxy controls (nginx), and application controls (chat/admin backend).
|
||||||
|
|
||||||
|
#### Transport and Edge Protection
|
||||||
|
|
||||||
|
- Public traffic uses HTTPS/WSS at the edge.
|
||||||
|
- Cloudflare WAF and rate limiting are part of the baseline (see `infrastructure/cloudflare/cache-rules.md`).
|
||||||
|
- Cloudflare Worker can enforce immutable cache headers for segment paths (`infrastructure/cloudflare/worker.js`).
|
||||||
|
|
||||||
|
#### Reverse Proxy Enforcement (nginx)
|
||||||
|
|
||||||
|
- Admin UI and admin API paths are protected by `auth_request` before reaching backend handlers (`/admin/`, `/api/admin/`).
|
||||||
|
- Unauthorized admin UI requests are redirected to `/admin/login/`.
|
||||||
|
- Unauthorized admin API requests return JSON `401` from a dedicated location handler.
|
||||||
|
- WebSocket upgrade headers are explicitly forwarded for `/ws`.
|
||||||
|
|
||||||
|
#### Admin Authentication and Session Security
|
||||||
|
|
||||||
|
- Admin authentication is cookie-session based (`linearvoid_admin_session`).
|
||||||
|
- Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`).
|
||||||
|
- Session expiration is enforced server-side.
|
||||||
|
|
||||||
|
#### Rate Limiting and Abuse Controls
|
||||||
|
|
||||||
|
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing.
|
||||||
|
- Current baseline in chat handler: `>10` messages within `10s` triggers a user-facing throttle response.
|
||||||
|
- If Redis is unavailable, presence/rate-limit features degrade gracefully (warning logged; core chat remains available).
|
||||||
|
|
||||||
|
#### CORS and Cross-Origin Behavior
|
||||||
|
|
||||||
|
- HLS endpoints exposed by nginx include explicit `Access-Control-Allow-Origin: *` for player compatibility.
|
||||||
|
- Admin/authenticated routes are expected to run same-origin behind nginx auth gates.
|
||||||
|
|
||||||
|
#### Moderation and Privacy
|
||||||
|
|
||||||
|
- Moderation commands (`/mod`, `/mute`, `/ban`) are handled in backend chat flow and persisted through chat storage paths.
|
||||||
|
- Private DMs are application-level private channels, not end-to-end encrypted in current architecture.
|
||||||
|
- Moderation and audit retention policies should be formalized in runbook/ops docs as part of production hardening.
|
||||||
|
|
||||||
### Chat
|
### Chat
|
||||||
|
|
||||||
@@ -354,6 +621,147 @@ These ADRs together form the canonical decision log.
|
|||||||
|
|
||||||
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
See **Quality Goals** table in the Introduction section. Primary scenarios focus on cost (near-zero egress), availability (auto-restart + fallback), scalability (edge caching), and usability (synchronized player + responsive chat).
|
||||||
|
|
||||||
|
### Performance Benchmarks and Capacity Planning
|
||||||
|
|
||||||
|
#### Baseline SLO Targets (v1)
|
||||||
|
|
||||||
|
- Playback startup: target < 4s after first manifest request on warm cache.
|
||||||
|
- Steady-state playback: no segment starvation during normal operation.
|
||||||
|
- End-to-end HLS latency: expected 15-35s (standard HLS trade-off).
|
||||||
|
- Chat delivery latency: p95 < 500ms inside same region under normal load.
|
||||||
|
- Admin page responsiveness: p95 API response < 1s for common read operations.
|
||||||
|
|
||||||
|
#### Workload Assumptions
|
||||||
|
|
||||||
|
- Primary audience in US; origin in US-East.
|
||||||
|
- Edge cache serves most segment traffic; origin handles manifest churn and cache misses.
|
||||||
|
- Chat traffic scales with concurrent viewers but remains low-bandwidth compared to video.
|
||||||
|
|
||||||
|
#### Initial Capacity Model
|
||||||
|
|
||||||
|
- Video bitrate envelopes (from ADR-001):
|
||||||
|
- 720p30: ~2.6-4.2 Mbps total with audio.
|
||||||
|
- 1080p30: ~5.1-8.2 Mbps total with audio.
|
||||||
|
- Storage planning for 48h rolling library:
|
||||||
|
- 720p30: ~57-90 GB raw (allocate ~80-110 GB with headroom).
|
||||||
|
- 1080p30: ~110-175 GB raw (allocate ~130-180 GB with headroom).
|
||||||
|
- Cost behavior:
|
||||||
|
- Viewer scale is mostly constrained by cache efficiency and request volume, not by direct origin egress.
|
||||||
|
|
||||||
|
#### Benchmark Plan
|
||||||
|
|
||||||
|
- Playback path:
|
||||||
|
- Measure time-to-first-frame and rebuffer events from multiple US regions.
|
||||||
|
- Track manifest freshness and segment availability at edge.
|
||||||
|
- Chat path:
|
||||||
|
- Load-test concurrent websocket connections and message fan-out.
|
||||||
|
- Measure p50/p95 send-to-receive latency and Redis fallback behavior.
|
||||||
|
- Admin/API path:
|
||||||
|
- Benchmark playlist/system endpoints for p95 latency and error rate.
|
||||||
|
- Platform path:
|
||||||
|
- Observe TSDB ingestion, query p99, and GC pause behavior in Grafana.
|
||||||
|
|
||||||
|
#### Capacity Triggers and Actions
|
||||||
|
|
||||||
|
- Trigger: sustained viewer growth with rising cache-miss ratio.
|
||||||
|
- Action: tighten cache policy, enable/verify Tiered Cache, review manifest TTL.
|
||||||
|
- Trigger: chat p95 latency drift or websocket error growth.
|
||||||
|
- Action: scale chat service, tune Redis, separate noisy moderation workloads.
|
||||||
|
- Trigger: Prometheus WAL/compaction failures or query saturation.
|
||||||
|
- Action: tune retention/cardinality, increase resources, split dashboards/queries.
|
||||||
|
- Trigger: origin CPU/memory pressure on playout host.
|
||||||
|
- Action: optimize FFmpeg profile, isolate services, add secondary origin.
|
||||||
|
|
||||||
|
### Scaling Strategy
|
||||||
|
|
||||||
|
Scaling is designed to preserve cost efficiency first, then increase throughput and resilience in controlled phases.
|
||||||
|
|
||||||
|
#### Phase 1: Vertical + Cache-First (Current Baseline)
|
||||||
|
|
||||||
|
- Single primary origin in US-East with strong Cloudflare edge caching.
|
||||||
|
- Optimize segment immutability and manifest TTL before adding infrastructure.
|
||||||
|
- Scale compute vertically when CPU/memory pressure appears on playout/chat host.
|
||||||
|
|
||||||
|
#### Phase 2: Service Separation
|
||||||
|
|
||||||
|
- Split playout, chat, and observability workloads across dedicated nodes/VMs.
|
||||||
|
- Isolate noisy workloads (analytics, heavy admin tasks) from latency-sensitive chat paths.
|
||||||
|
- Introduce managed PostgreSQL/Redis or dedicated state nodes where operationally justified.
|
||||||
|
|
||||||
|
#### Phase 3: Multi-Origin Resilience
|
||||||
|
|
||||||
|
- Add warm standby or active-secondary origin.
|
||||||
|
- Use health-aware DNS/origin failover strategy.
|
||||||
|
- Keep HLS path and cache contract stable so viewer impact during failover is minimized.
|
||||||
|
|
||||||
|
#### Phase 4: Regional and Throughput Expansion
|
||||||
|
|
||||||
|
- Add additional origin region only when audience distribution and latency justify it.
|
||||||
|
- Keep chat affinity strategy explicit (regional routing, state replication considerations).
|
||||||
|
- Continuously evaluate cache miss economics vs additional origin complexity.
|
||||||
|
|
||||||
|
#### Scaling Triggers
|
||||||
|
|
||||||
|
- Rising cache miss ratio with sustained traffic growth.
|
||||||
|
- Chat p95/p99 latency drift or websocket error growth.
|
||||||
|
- Persistent origin resource saturation despite optimization.
|
||||||
|
- Increased blast-radius concerns from single-host dependency.
|
||||||
|
|
||||||
|
### Disaster Recovery and Backup Procedures
|
||||||
|
|
||||||
|
#### Recovery Targets
|
||||||
|
|
||||||
|
- RPO target: <= 24h for chat/moderation/admin metadata.
|
||||||
|
- RTO target: <= 60 minutes for restoring baseline streaming + chat operations.
|
||||||
|
|
||||||
|
#### Backup Scope
|
||||||
|
|
||||||
|
- Persistent service volumes:
|
||||||
|
- Postgres data (`postgres-data` volume).
|
||||||
|
- Redis append-only persistence (`redis-data` volume) where enabled.
|
||||||
|
- Prometheus long-term TSDB (`prometheus-data` volume) if historical metrics retention is required.
|
||||||
|
- Grafana state (`grafana-data` volume) if dashboards/users are managed in-instance.
|
||||||
|
- Host-mounted state directories:
|
||||||
|
- `data/chat`
|
||||||
|
- `data/playlist`
|
||||||
|
- `data/media` (authoritative source may be R2/object storage)
|
||||||
|
- `data/hls` (optional; can be regenerated by playout)
|
||||||
|
- Configuration and infra source of truth:
|
||||||
|
- Compose files, nginx config, monitoring configs, and environment template.
|
||||||
|
|
||||||
|
#### Backup Cadence
|
||||||
|
|
||||||
|
- Daily logical Postgres backups (plus weekly full snapshot).
|
||||||
|
- Daily sync of chat/playlist state directories.
|
||||||
|
- Weekly integrity check by restoring backups into an isolated environment.
|
||||||
|
- Retention policy baseline:
|
||||||
|
- Daily backups: 14 days
|
||||||
|
- Weekly backups: 8 weeks
|
||||||
|
- Monthly backups: 6 months
|
||||||
|
|
||||||
|
#### Recovery Runbook (Minimum)
|
||||||
|
|
||||||
|
1. Restore infrastructure configuration (compose, nginx, env, monitoring config).
|
||||||
|
2. Restore Postgres and Redis persisted state.
|
||||||
|
3. Restore `data/playlist` and chat persistence directories.
|
||||||
|
4. Rehydrate media from authoritative object storage if local media is missing.
|
||||||
|
5. Start stack and validate in order:
|
||||||
|
- `playout` health endpoint
|
||||||
|
- `chat` health endpoint
|
||||||
|
- nginx `/health`
|
||||||
|
- HLS playlist availability
|
||||||
|
- websocket chat connectivity
|
||||||
|
6. Verify admin authentication and moderation audit visibility.
|
||||||
|
7. Announce recovery and continue elevated monitoring for at least one playlist cycle.
|
||||||
|
|
||||||
|
#### Failure Modes and Strategy
|
||||||
|
|
||||||
|
- Single-host loss: redeploy stack on standby host, restore data backups, repoint DNS/edge origin.
|
||||||
|
- Storage corruption: restore last known-good snapshot and replay latest logical backups.
|
||||||
|
- Partial service failure: replace only impacted service and restore scoped data (for example Postgres only).
|
||||||
|
|
||||||
|
Document and rehearse this runbook quarterly; recovery that is not exercised is not reliable.
|
||||||
|
|
||||||
## Risks and Technical Debts
|
## Risks and Technical Debts
|
||||||
|
|
||||||
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
- Cloudflare ToS risk for heavy video on free tier (use R2 path; fallback to BunnyCDN if challenged).
|
||||||
@@ -381,162 +789,3 @@ See **Quality Goals** table in the Introduction section. Primary scenarios focus
|
|||||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||||
|
|
||||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
||||||
|
|
||||||
### Whitebox Overall System
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<text explanation\>_
|
|
||||||
|
|
||||||
Contained Building Blocks
|
|
||||||
|
|
||||||
: _\<Description of contained building block (black boxes)\>_
|
|
||||||
|
|
||||||
Important Interfaces
|
|
||||||
|
|
||||||
: _\<Description of important interfaces\>_
|
|
||||||
|
|
||||||
#### \<Name black box 1\>
|
|
||||||
|
|
||||||
_\<Purpose/Responsibility\>_
|
|
||||||
|
|
||||||
_\<Interface(s)\>_
|
|
||||||
|
|
||||||
_\<(Optional) Quality/Performance Characteristics\>_
|
|
||||||
|
|
||||||
_\<(Optional) Directory/File Location\>_
|
|
||||||
|
|
||||||
_\<(Optional) Fulfilled Requirements\>_
|
|
||||||
|
|
||||||
_\<(optional) Open Issues/Problems/Risks\>_
|
|
||||||
|
|
||||||
#### \<Name black box 2\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name black box n\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name interface 1\>
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### \<Name interface m\>
|
|
||||||
|
|
||||||
### Level 2
|
|
||||||
|
|
||||||
#### White Box _\<building block 1\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box _\<building block 2\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### White Box _\<building block m\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
### Level 3
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.2\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block y.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
## Runtime View
|
|
||||||
|
|
||||||
### \<Runtime Scenario 1\>
|
|
||||||
|
|
||||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
|
||||||
|
|
||||||
- _\<insert description of the notable aspects of the interactions
|
|
||||||
between the building block instances depicted in this diagram.\>_
|
|
||||||
|
|
||||||
### \<Runtime Scenario 2\>
|
|
||||||
|
|
||||||
### ...
|
|
||||||
|
|
||||||
### \<Runtime Scenario n\>
|
|
||||||
|
|
||||||
## Deployment View
|
|
||||||
|
|
||||||
### Infrastructure Level 1
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Quality and/or Performance Features
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Mapping of Building Blocks to Infrastructure
|
|
||||||
|
|
||||||
: _\<description of the mapping\>_
|
|
||||||
|
|
||||||
### Infrastructure Level 2
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 1\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 2\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element n\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
## Cross-cutting Concepts
|
|
||||||
|
|
||||||
### _\<Concept 1\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
### _\<Concept 2\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
### _\<Concept n\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
|
|
||||||
## Quality Requirements
|
|
||||||
|
|
||||||
### Quality Requirements Overview
|
|
||||||
|
|
||||||
### Quality Scenarios
|
|
||||||
|
|
||||||
## Risks and Technical Debts
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| Term | Definition |
|
|
||||||
+======================+===============================================+
|
|
||||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
# ADR-006: Data Storage Strategy (SQLite vs PostgreSQL)
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Accepted
|
||||||
**Date:** 2026-08-02
|
**Date:** 2026-08-04
|
||||||
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
**Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005)
|
||||||
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
**Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md)
|
||||||
|
|
||||||
@@ -12,89 +12,78 @@ The system needs persistent storage for:
|
|||||||
- Chat messages and history (public rooms and private DMs).
|
- Chat messages and history (public rooms and private DMs).
|
||||||
- User accounts, roles, and ban lists.
|
- User accounts, roles, and ban lists.
|
||||||
- Moderation audit log (immutable record of admin and moderator actions).
|
- Moderation audit log (immutable record of admin and moderator actions).
|
||||||
- Playlist metadata and schedule overrides.
|
- Playlist and media metadata used by admin flows.
|
||||||
- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007).
|
- System configuration visibility in admin APIs.
|
||||||
|
|
||||||
From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration.
|
The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite).
|
1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it.
|
||||||
2. **Performance & Concurrency** – Chat and moderation may have high write volume.
|
2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior.
|
||||||
3. **Backup and Recovery** – Easy point-in-time recovery and replication.
|
3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL.
|
||||||
4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path).
|
4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing.
|
||||||
5. **Auditability** – Moderation log must be immutable and queryable.
|
5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows.
|
||||||
6. **Cost** – Avoid managed database costs during early stages.
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
**Primary storage for v1: SQLite** (embedded in the chat and admin services).
|
**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).**
|
||||||
|
|
||||||
**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500).
|
**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios.
|
||||||
|
|
||||||
**Admin Interface Capabilities** (extension of ADR-005):
|
**Current Backend Behavior**:
|
||||||
|
|
||||||
- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status.
|
- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup.
|
||||||
- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup.
|
- `postgres://` or `postgresql://` selects PostgreSQL driver.
|
||||||
- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully.
|
- `sqlite://` selects SQLite driver.
|
||||||
- Audit log of all configuration changes.
|
- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience.
|
||||||
|
- GORM AutoMigrate is used for chat/admin data models.
|
||||||
|
|
||||||
**Technical Details**:
|
**Media Model Decision (Admin UI Feature Track)**:
|
||||||
|
|
||||||
- Use SQLite with WAL mode for better concurrency.
|
- Media metadata is persisted in the same database as messages and moderation logs.
|
||||||
- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations.
|
- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`).
|
||||||
- Store large chat history in a separate table with retention policy (e.g., 30 days).
|
- The database is the source of truth for media library metadata and future runtime analytics.
|
||||||
- Moderation log as an append-only table with triggers for immutability.
|
|
||||||
|
|
||||||
**Folder Placement** (per ADR-002):
|
|
||||||
|
|
||||||
- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`.
|
|
||||||
- Admin UI pages for storage management in `frontend/admin/system/`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS.
|
- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage.
|
||||||
- It eliminates the need for a separate database container in `docker-compose.yml` during early development.
|
- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL.
|
||||||
- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan.
|
- This aligns architecture documentation with implemented behavior and avoids drift.
|
||||||
- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat).
|
- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**Positive**:
|
**Positive**:
|
||||||
|
|
||||||
- Zero-config database for quick local development and small-scale production.
|
- Better write concurrency and query flexibility for chat + admin workloads.
|
||||||
- Simple backups (just copy the `.db` file).
|
- Clear operational model for containerized deployments (health checks, named volume, service dependency).
|
||||||
- Admin UI can directly manage and monitor storage without external tools.
|
- Easier extension for admin analytics and media metadata features.
|
||||||
|
|
||||||
**Negative / Risks**:
|
**Negative / Risks**:
|
||||||
|
|
||||||
- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement).
|
- Additional service to operate versus embedded-only SQLite.
|
||||||
- Large chat history or analytics queries may become slow – plan retention policy and archiving.
|
- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose.
|
||||||
- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR).
|
- Requires backup/restore procedures for PostgreSQL volume in production runbooks.
|
||||||
|
|
||||||
**Alternatives Considered**:
|
## Alternatives Considered
|
||||||
|
|
||||||
- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost).
|
- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL.
|
||||||
- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries.
|
- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility.
|
||||||
- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001.
|
- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model.
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Status
|
||||||
|
|
||||||
1. Create this ADR-006 (done).
|
1. ADR updated to match implemented state. Done.
|
||||||
2. Add SQLite support in `backend/shared/db/` with GORM migrations.
|
2. Compose includes PostgreSQL service and persistent volume. Done.
|
||||||
3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005).
|
3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done.
|
||||||
4. Add configuration reload capability without full restart where possible.
|
4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing).
|
||||||
5. Write migration script from SQLite to PostgreSQL.
|
5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned.
|
||||||
6. Update `docker-compose.yml` to optionally include PostgreSQL.
|
|
||||||
7. Add tests for data access patterns and admin configuration changes.
|
|
||||||
|
|
||||||
**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities).
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
- ADR-001: Chat persistence, moderation log, monitoring requirements.
|
||||||
- ADR-003: Preference for operational simplicity and Go backend.
|
- ADR-003: Preference for operational simplicity and Go backend.
|
||||||
- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities.
|
- ADR-005: Admin interface for user management, moderation, and analytics.
|
||||||
- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts.
|
- ADR-007: Redis usage for presence, rate limiting, pub/sub.
|
||||||
|
- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions.
|
||||||
This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability.
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
+25
-790
@@ -5,803 +5,38 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin</title>
|
<title>Linear Void Admin</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<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%">
|
<body class="admin-body">
|
||||||
<div class="flex h-screen" x-data="admin()">
|
<div class="admin-shell">
|
||||||
<!-- Sidebar -->
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
<div class="w-64 bg-slate-900 p-6">
|
<div class="brand-block">
|
||||||
<div class="flex items-center gap-3 mb-8">
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
<div class="brand-text">Admin</div>
|
||||||
<div class="text-xl font-bold">Admin</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
|
||||||
<button @click="logout()" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 px-4 py-2 rounded-xl text-sm text-left">Sign out</button>
|
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||||
</div>
|
|
||||||
<nav class="space-y-2">
|
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||||
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
|
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">Dashboard</button>
|
||||||
<a href="#" @click="currentTab = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
|
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">Upload Video</button>
|
||||||
<a href="#" @click="currentTab = 'playlist'; refreshPlaylist()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||||
<a href="#" @click="currentTab = 'schedule'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||||
<a href="#" @click="currentTab = 'moderation'; refreshModerationLogs()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||||
<a href="#" @click="currentTab = 'users'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
|
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||||
<a href="#" @click="currentTab = 'analytics'; refreshAnalytics()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||||
<a href="#" @click="currentTab = 'system'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||||
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
<div class="flex-1 p-8 overflow-auto">
|
|
||||||
<template x-if="currentTab === 'dashboard'">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
|
|
||||||
<div class="grid grid-cols-3 gap-6">
|
|
||||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
|
||||||
<div class="text-emerald-400 text-sm">Concurrent Viewers</div>
|
|
||||||
<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">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">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>
|
|
||||||
<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/*,.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 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>
|
|
||||||
|
|
||||||
<template x-if="currentTab === 'system'">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold mb-8">System Configuration (Redis & Storage)</h1>
|
|
||||||
<div class="bg-slate-800 p-8 rounded-3xl">
|
|
||||||
<div class="grid grid-cols-2 gap-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-medium mb-4">Redis Stats</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Memory Used</span>
|
|
||||||
<span class="font-mono" x-text="system.redisMemoryUsed"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Connected Redis Clients</span>
|
|
||||||
<span class="font-mono" x-text="system.redisClients"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span>Redis Enabled</span>
|
|
||||||
<span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-medium mb-4">Storage</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<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: 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)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadVideo(event) {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
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() {
|
|
||||||
alert('VACUUM executed on SQLite database (per ADR-006). Audit log entry created.');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -5,30 +5,27 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin Login</title>
|
<title>Linear Void Admin Login</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
<link rel="stylesheet" href="../../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
|
<body class="auth-body">
|
||||||
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
|
<main class="auth-shell" aria-label="Admin authentication">
|
||||||
<div class="mb-8">
|
<section class="auth-card" aria-labelledby="auth-title">
|
||||||
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
|
<p class="auth-kicker">Linear Void</p>
|
||||||
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
|
<h1 id="auth-title">Admin Login</h1>
|
||||||
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
|
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="login-form" class="space-y-4">
|
<form id="login-form" class="form-stack" novalidate>
|
||||||
<label class="block text-sm">
|
<label for="username">Username</label>
|
||||||
<span class="block text-slate-400 mb-2">Username</span>
|
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||||
<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 for="password">Password</label>
|
||||||
<label class="block text-sm">
|
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||||
<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 />
|
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||||
</label>
|
<p id="status" class="status-line" aria-live="polite"></p>
|
||||||
<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>
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -42,7 +39,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
status.textContent = 'Signing in...';
|
status.textContent = 'Signing in...';
|
||||||
status.className = 'text-sm text-slate-400 min-h-6';
|
status.className = 'status-line';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/login', {
|
const response = await fetch('/api/admin/login', {
|
||||||
@@ -59,7 +56,7 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to sign in', error);
|
console.error('Failed to sign in', error);
|
||||||
status.textContent = 'Login failed. Check your credentials.';
|
status.textContent = 'Login failed. Check your credentials.';
|
||||||
status.className = 'text-sm text-rose-400 min-h-6';
|
status.className = 'status-line status-error';
|
||||||
} finally {
|
} finally {
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-55
@@ -5,76 +5,51 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-slate-950 text-slate-200">
|
<body class="lv-body">
|
||||||
<div class="flex h-screen">
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
<!-- Video Player -->
|
<header class="viewer-header">
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="viewer-brand-group">
|
||||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-white">Linear Void</h1>
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
<p class="text-xs text-emerald-400">24/7 Synchronized Channel • Live</p>
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="now-playing px-4 py-1 rounded-full text-sm font-medium flex items-center gap-2">
|
|
||||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
|
||||||
<span id="now-playing">Loading schedule...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center p-8 bg-black relative">
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video>
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<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">
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div class="flex items-center gap-3">
|
</section>
|
||||||
<div class="text-emerald-400">● LIVE</div>
|
|
||||||
<div id="overlay-title" class="font-medium"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-slate-900 border-t border-slate-800 text-xs text-slate-400 flex justify-between">
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
<div>Latency: ~25s • Synced for all viewers (per ADR-001)</div>
|
<header class="chat-header">
|
||||||
<div class="flex gap-6">
|
<div class="chat-room-title">
|
||||||
<a href="#" onclick="switchRoom('main')" class="hover:text-white">Main Channel</a>
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
<a href="#" onclick="switchRoom('chill')" class="hover:text-white">Chill Room</a>
|
|
||||||
<a href="#" onclick="switchRoom('talk')" class="hover:text-white">Talk Room</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Sidebar -->
|
|
||||||
<div class="w-96 flex flex-col border-l border-slate-800 bg-slate-900 chat-sidebar">
|
|
||||||
<div class="p-4 border-b border-slate-700 font-medium flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-emerald-400">●</span>
|
|
||||||
<span id="room-name">Main Channel</span>
|
<span id="room-name">Main Channel</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="togglePrivateDM()" class="text-xs px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg">New DM</button>
|
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Messages -->
|
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||||
<div id="messages" class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
|
|
||||||
<!-- Messages populated by JS -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input -->
|
<div class="chat-input-area">
|
||||||
<div class="p-4 border-t border-slate-700">
|
<div class="chat-input-row">
|
||||||
<div class="flex gap-2">
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<input id="message-input" type="text" placeholder="Type a message..." class="input flex-1 px-4 py-3 rounded-2xl text-sm focus:outline-none" onkeydown="if(event.key === 'Enter') sendMessage()">
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||||
<button onclick="sendMessage()" class="bg-blue-600 hover:bg-blue-500 px-6 rounded-2xl text-sm font-medium">Send</button>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-slate-500 mt-2 text-center">
|
<p class="chat-note">
|
||||||
Synced with video • Moderation active
|
Synced with video • Moderation active
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+5
-209
@@ -8,11 +8,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "^3.15.12",
|
"hls.js": "^1.6.16"
|
||||||
"autoprefixer": "^10.5.4",
|
|
||||||
"hls.js": "^1.6.16",
|
|
||||||
"postcss": "^8.5.25",
|
|
||||||
"tailwindcss": "^4.3.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
@@ -375,131 +371,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
|
||||||
"version": "3.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
|
||||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vue/shared": "3.1.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vue/shared": {
|
|
||||||
"version": "3.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
|
||||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/alpinejs": {
|
|
||||||
"version": "3.15.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
|
|
||||||
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@vue/reactivity": "~3.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/autoprefixer": {
|
|
||||||
"version": "10.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
|
||||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"browserslist": "^4.28.6",
|
|
||||||
"caniuse-lite": "^1.0.30001806",
|
|
||||||
"fraction.js": "^5.3.4",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"postcss-value-parser": "^4.2.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"autoprefixer": "bin/autoprefixer"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"postcss": "^8.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/baseline-browser-mapping": {
|
|
||||||
"version": "2.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
|
|
||||||
"integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
|
||||||
"version": "4.28.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
|
||||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"baseline-browser-mapping": "^2.10.44",
|
|
||||||
"caniuse-lite": "^1.0.30001806",
|
|
||||||
"electron-to-chromium": "^1.5.393",
|
|
||||||
"node-releases": "^2.0.51",
|
|
||||||
"update-browserslist-db": "^1.2.3"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"browserslist": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/caniuse-lite": {
|
|
||||||
"version": "1.0.30001806",
|
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
|
||||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "CC-BY-4.0"
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -510,21 +381,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
|
||||||
"version": "1.5.399",
|
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
|
|
||||||
"integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/escalade": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -543,19 +399,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fraction.js": {
|
|
||||||
"version": "5.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
|
||||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/rawify"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -854,6 +697,7 @@
|
|||||||
"version": "3.3.16",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -868,19 +712,11 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
|
||||||
"version": "2.0.51",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
|
||||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -900,6 +736,7 @@
|
|||||||
"version": "8.5.25",
|
"version": "8.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -924,12 +761,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-value-parser": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
|
||||||
@@ -968,17 +799,12 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
|
||||||
"version": "4.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
|
||||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -1018,36 +844,6 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"escalade": "^3.2.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"update-browserslist-db": "cli.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"browserslist": ">= 4.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "^3.15.12",
|
"hls.js": "^1.6.16"
|
||||||
"autoprefixer": "^10.5.4",
|
|
||||||
"hls.js": "^1.6.16",
|
|
||||||
"postcss": "^8.5.25",
|
|
||||||
"tailwindcss": "^4.3.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Analytics</h1>
|
||||||
|
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Total Messages</h2>
|
||||||
|
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Last 24 Hours</h2>
|
||||||
|
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Distinct Rooms</h2>
|
||||||
|
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Moderation Actions</h2>
|
||||||
|
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Top Rooms</h2>
|
||||||
|
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Messages</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="analytics-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Chat Log</h1>
|
||||||
|
<p>Browse and manage stored chat messages.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||||
|
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="chat-log-filters">
|
||||||
|
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||||
|
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||||
|
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||||
|
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||||
|
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="chat-log-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="chat-log-pagination">
|
||||||
|
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||||
|
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||||
|
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||||
|
</div>
|
||||||
|
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Concurrent Viewers</h2>
|
||||||
|
<p id="stat-viewers" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Stored Messages</h2>
|
||||||
|
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Moderation Actions</h2>
|
||||||
|
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h2>Media Runtime</h2>
|
||||||
|
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||||
|
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Moderation</h1>
|
||||||
|
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="moderation-grid">
|
||||||
|
<section class="panel-card">
|
||||||
|
<h2>New Action</h2>
|
||||||
|
<form id="moderation-form" class="form-stack">
|
||||||
|
<label>Action
|
||||||
|
<select id="mod-action" class="text-input">
|
||||||
|
<option value="mute">mute</option>
|
||||||
|
<option value="ban">ban</option>
|
||||||
|
<option value="delete">delete</option>
|
||||||
|
<option value="timeout">timeout</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Target
|
||||||
|
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||||
|
</label>
|
||||||
|
<label>Moderator
|
||||||
|
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||||
|
</label>
|
||||||
|
<label>Room
|
||||||
|
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<label>Reason
|
||||||
|
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||||
|
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Recent Audit Log</h2>
|
||||||
|
<p class="small-muted">Latest entries</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Moderator</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="moderation-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Playlist</h1>
|
||||||
|
<p>Edit the active concat playlist used by the playout service.</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||||
|
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<label for="playlist-content" class="sr-only">Playlist content</label>
|
||||||
|
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
||||||
|
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Schedule</h1>
|
||||||
|
<p>Read-only view of the currently configured playout order.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="schedule-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<h1>System Configuration</h1>
|
||||||
|
<div class="panel-card split-grid">
|
||||||
|
<section>
|
||||||
|
<h2>Redis Stats</h2>
|
||||||
|
<dl class="kv-list">
|
||||||
|
<div>
|
||||||
|
<dt>Memory Used</dt>
|
||||||
|
<dd id="system-redis-memory">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Connected Clients</dt>
|
||||||
|
<dd id="system-redis-clients">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Redis Enabled</dt>
|
||||||
|
<dd id="system-redis-enabled">no</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Storage</h2>
|
||||||
|
<dl class="kv-list">
|
||||||
|
<div>
|
||||||
|
<dt>Database Driver</dt>
|
||||||
|
<dd id="system-db-driver">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Stored Messages</dt>
|
||||||
|
<dd id="system-stored-messages">0</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Last Refresh</dt>
|
||||||
|
<dd id="system-generated-at">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Playlist Path</dt>
|
||||||
|
<dd id="system-playlist-path">loading...</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Playlist Present</dt>
|
||||||
|
<dd id="system-playlist-present">no</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Service Health</h2>
|
||||||
|
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||||
|
</div>
|
||||||
|
<div id="system-services" class="service-list"></div>
|
||||||
|
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Upload Video</h1>
|
||||||
|
<p>Upload media files into the local media library mounted for playout.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-dropzone" class="upload-dropzone">
|
||||||
|
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||||
|
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||||
|
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||||
|
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||||
|
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||||
|
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||||
|
</div>
|
||||||
|
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="panel-header-row">
|
||||||
|
<h2>Media Library</h2>
|
||||||
|
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Runtime</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="media-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="panel-header-row">
|
||||||
|
<div>
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p>Current room presence derived from Redis presence keys.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Room</th>
|
||||||
|
<th>Online</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large.
Load diff
+58
-66
@@ -1,20 +1,21 @@
|
|||||||
import Hls from "hls.js";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
// Main application state with Alpine.js
|
type HlsClass = (typeof import("hls.js"))["default"];
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
app: () => any;
|
|
||||||
switchRoom: (room: string) => void;
|
switchRoom: (room: string) => void;
|
||||||
sendMessage: () => void;
|
sendMessage: () => void;
|
||||||
togglePrivateDM: () => void;
|
togglePrivateDM: () => void;
|
||||||
|
toggleChatPanel: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hls: Hls | null = null;
|
let hls: InstanceType<HlsClass> | null = null;
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let currentRoom = "main";
|
let currentRoom = "main";
|
||||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
|
let chatVisible = true;
|
||||||
|
|
||||||
function resolveWebSocketBaseUrl() {
|
function resolveWebSocketBaseUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||||
@@ -89,17 +90,21 @@ function addMessage(
|
|||||||
) {
|
) {
|
||||||
const messages = document.getElementById("messages")!;
|
const messages = document.getElementById("messages")!;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = `message flex ${type === "system" ? "justify-center" : sender === username ? "justify-end" : "justify-start"}`;
|
div.className = "message-row";
|
||||||
|
|
||||||
if (type === "system") {
|
if (type === "system") {
|
||||||
div.innerHTML = `<div class="text-xs text-slate-400 bg-slate-800 px-3 py-1 rounded-full">${text}</div>`;
|
div.classList.add("message-system-row");
|
||||||
|
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||||
} else {
|
} else {
|
||||||
|
div.classList.add(
|
||||||
|
sender === username ? "message-self-row" : "message-other-row",
|
||||||
|
);
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="${sender === username ? "bg-blue-600" : "bg-slate-700"} px-4 py-2 rounded-3xl max-w-[75%]">
|
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||||
${sender && sender !== username ? `<div class="text-xs text-blue-300">${sender}</div>` : ""}
|
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||||
<div>${text}</div>
|
<div class="message-text">${text}</div>
|
||||||
<div class="text-[10px] text-slate-400 mt-1">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
|
||||||
</div>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +112,46 @@ function addMessage(
|
|||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initPlayerAndChat() {
|
||||||
|
const video = document.getElementById("player") as HTMLVideoElement;
|
||||||
|
const manifestUrl = resolveManifestUrl();
|
||||||
|
|
||||||
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = manifestUrl;
|
||||||
|
video.addEventListener("loadedmetadata", () => video.play());
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { default: Hls } = await import("hls.js");
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls();
|
||||||
|
hls.loadSource(manifestUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load HLS player", error);
|
||||||
|
addMessage("system", "Unable to initialize video player.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket("main");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChatPanel() {
|
||||||
|
const shell = document.querySelector(".viewer-shell");
|
||||||
|
const toggle = document.getElementById("chat-toggle");
|
||||||
|
if (!shell || !toggle) return;
|
||||||
|
|
||||||
|
chatVisible = !chatVisible;
|
||||||
|
shell.classList.toggle("chat-collapsed", !chatVisible);
|
||||||
|
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
|
||||||
|
toggle.setAttribute("aria-expanded", String(chatVisible));
|
||||||
|
}
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
@@ -141,64 +186,11 @@ function togglePrivateDM() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alpine.js app
|
|
||||||
window.app = () => {
|
|
||||||
return {
|
|
||||||
init() {
|
|
||||||
// Initialize Tailwind
|
|
||||||
// @ts-ignore
|
|
||||||
tailwind.config = {
|
|
||||||
content: [],
|
|
||||||
theme: { extend: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize HLS Player
|
|
||||||
const video = document.getElementById("player") as HTMLVideoElement;
|
|
||||||
const manifestUrl = resolveManifestUrl();
|
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
|
||||||
hls = new Hls();
|
|
||||||
hls.loadSource(manifestUrl);
|
|
||||||
hls.attachMedia(video);
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
|
||||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
||||||
video.src = manifestUrl;
|
|
||||||
video.addEventListener("loadedmetadata", () => video.play());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to default chat room
|
|
||||||
connectWebSocket("main");
|
|
||||||
|
|
||||||
// Simulate now playing
|
|
||||||
setInterval(() => {
|
|
||||||
const titles = [
|
|
||||||
"Midnight Jazz Lounge",
|
|
||||||
"Synthwave Nights",
|
|
||||||
"Classic Cinema",
|
|
||||||
"Ambient Void",
|
|
||||||
];
|
|
||||||
document.getElementById("now-playing")!.textContent =
|
|
||||||
titles[Math.floor(Math.random() * titles.length)];
|
|
||||||
const overlay = document.getElementById("now-playing-overlay")!;
|
|
||||||
overlay.classList.remove("hidden");
|
|
||||||
document.getElementById("overlay-title")!.textContent =
|
|
||||||
titles[Math.floor(Math.random() * titles.length)];
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make functions global for onclick handlers
|
|
||||||
window.switchRoom = switchRoom;
|
window.switchRoom = switchRoom;
|
||||||
window.sendMessage = sendMessage;
|
window.sendMessage = sendMessage;
|
||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
|
window.toggleChatPanel = toggleChatPanel;
|
||||||
|
|
||||||
// Auto initialize when loaded
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// @ts-ignore
|
void initPlayerAndChat();
|
||||||
window.app().init();
|
|
||||||
});
|
});
|
||||||
+910
-278
File diff suppressed because it is too large.
Load diff
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
envDir: "..",
|
envDir: "..",
|
||||||
build: {
|
build: {
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: "index.html",
|
main: "index.html",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ http {
|
|||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
server_name linearvoid.tv localhost 127.0.0.1;
|
server_name linearvoid.tv localhost 127.0.0.1;
|
||||||
|
|
||||||
|
# Resumable admin upload chunks are 8MB by default; allow safe headroom.
|
||||||
|
client_max_body_size 64m;
|
||||||
|
|
||||||
root /srv/frontend;
|
root /srv/frontend;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user