Compare commits
10
Commits
c10d98f3b4
...
1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ac04856b | ||
|
|
69df0e51b8 | ||
|
|
9a06dc389d | ||
|
|
51f3e98816 | ||
|
|
68ba3a25b9 | ||
|
|
7e9308c48a | ||
|
|
21a34a4bd5 | ||
|
|
ee16441b79 | ||
|
|
74133ec589 | ||
|
|
f650a3a94e |
No files matched your search
+27
-8
@@ -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
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
docker logs linearvoid-playout --tail 50
|
echo "Playout failed to become healthy after 6 attempts"
|
||||||
exit 1
|
docker logs linearvoid-playout --tail 50
|
||||||
curl -f http://192.168.88.40:3000/health || exit 1
|
exit 1
|
||||||
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.25"
|
||||||
cache: true
|
cache: true
|
||||||
|
env:
|
||||||
|
GOTOOLCHAIN: local
|
||||||
|
|
||||||
- name: Update Go Dependencies
|
- name: Update Go Dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend/playout && go get -u ./... && go mod tidy
|
cd backend/playout && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
cd ../chat && go get -u ./... && go mod tidy
|
cd ../chat && GOTOOLCHAIN=local go get -u ./... && go mod tidy
|
||||||
|
|
||||||
- name: Commit and push changes
|
- name: Commit and push changes
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "*.*.*" # Trigger on any semantic version tag (e.g., 1.0.0, 2.1.3)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to release (e.g., v1.0.0)"
|
description: "Version to release (e.g., 1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -71,6 +71,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-playout.outputs.tags }}
|
tags: ${{ steps.meta-playout.outputs.tags }}
|
||||||
labels: ${{ steps.meta-playout.outputs.labels }}
|
labels: ${{ steps.meta-playout.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Chat
|
- name: Extract metadata for Chat
|
||||||
id: meta-chat
|
id: meta-chat
|
||||||
@@ -89,6 +91,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-chat.outputs.tags }}
|
tags: ${{ steps.meta-chat.outputs.tags }}
|
||||||
labels: ${{ steps.meta-chat.outputs.labels }}
|
labels: ${{ steps.meta-chat.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
|
||||||
|
|
||||||
- name: Extract metadata for Nginx
|
- name: Extract metadata for Nginx
|
||||||
id: meta-nginx
|
id: meta-nginx
|
||||||
@@ -108,6 +112,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-nginx.outputs.tags }}
|
tags: ${{ steps.meta-nginx.outputs.tags }}
|
||||||
labels: ${{ steps.meta-nginx.outputs.labels }}
|
labels: ${{ steps.meta-nginx.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
|
||||||
|
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
|
||||||
|
|
||||||
- name: Create Release on Gitea
|
- name: Create Release on Gitea
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+9
-2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+25
-22
@@ -2,40 +2,43 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
## Unreleased
|
## [1.0.6] - 2026-08-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006).
|
- `GET /api/admin/chat-log` endpoint with `?room=`, `?limit=`, `?before=`, `?username=`, `?text=` filter/pagination support.
|
||||||
- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section).
|
- `GET /api/admin/chat-log/:id` endpoint returning a single chat log entry as JSON.
|
||||||
- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
- `DELETE /api/admin/chat-log/:id` endpoint returning 204 No Content on success.
|
||||||
- Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts.
|
- `DELETE /api/admin/chat-log/bulk` endpoint accepting `{"ids": [...]}` for bulk deletion (max 500).
|
||||||
- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions.
|
- 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 a dedicated plain TypeScript admin module for tab navigation, media CRUD actions, schedule/system analytics refresh, and moderation workflows without Alpine dependencies.
|
- Added `"lint": "tsc --noEmit"` script to `frontend/package.json`.
|
||||||
- Added resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
- `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion.
|
||||||
- Expanded architecture documentation with scaling strategy, API surface reference, detailed video normalization spec, and Cloudflare configuration reference.
|
- Resumable chunked admin media upload flow with upload-session endpoints and client progress indicators.
|
||||||
- Added `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, and `SECURITY.md` as dedicated operational documentation guides.
|
- Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
|
||||||
|
- Dedicated operational documentation: `API.md`, `DEPLOYMENT.md`, `MONITORING.md`, `SECURITY.md`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback.
|
- Split `frontend/admin/index.html` into a template shell plus 9 per-tab page HTML files loaded dynamically via `loadPageContent` / `switchTab` / `bindPageEvents`.
|
||||||
- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload.
|
|
||||||
- Rebuilt viewer, admin, and admin-login pages around custom semantic class structures with accessibility-focused landmarks, tab semantics, and keyboard-focus behavior.
|
- 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.
|
- Replaced legacy and Tailwind-based stylesheet rules with a custom design-token CSS system covering responsive layouts, forms, tables, and reduced-motion behavior.
|
||||||
- Switched the frontend theme to dark-mode-first token defaults with a light-scheme override path for users who prefer light appearance.
|
- Switched frontend theme to dark-mode-first token defaults with light-scheme override.
|
||||||
- Refactored the viewer page to a single header plus unified content layout with responsive video/chat split, mobile stacking, and chat panel toggle behavior.
|
- 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.
|
||||||
- Code-split `hls.js` via lazy loading in the viewer bootstrap to shrink the main frontend bundle and isolate stream playback logic into a dedicated chunk.
|
- Replaced hardcoded admin sidebar and tab-trigger colors with CSS custom properties.
|
||||||
- Added navigation links in root README and architecture docs to the new API, deployment, monitoring, and security guide files.
|
- 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
|
### Fixed
|
||||||
|
|
||||||
- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup.
|
- 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.
|
||||||
- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage.
|
- Removed broken playlist validation step (`go run src/playlist.go` without go.mod) from CI test job.
|
||||||
- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting.
|
- Converted nginx CI build from custom shell `docker buildx` to `docker/build-push-action@v5` for cache consistency.
|
||||||
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
|
|
||||||
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
- Enforced 2GB upload ceiling plus server-side media file type/size validation for admin uploads.
|
||||||
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks and prevent `413 Content Too Large` responses.
|
- Increased nginx `client_max_body_size` to allow resumable admin upload chunks without 413 errors.
|
||||||
- Removed legacy now-playing viewer UI elements that conflicted with the streamlined single-header layout.
|
- 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
|
||||||
|
|
||||||
|
|||||||
+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!
|
||||||
@@ -38,6 +38,10 @@ See .env.example and SETUP.md for full variable reference.
|
|||||||
|
|
||||||
## Pull-and-Run Deployment
|
## 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:
|
From repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](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)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -224,7 +224,7 @@ Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelin
|
|||||||
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||||
4. CI validates builds and tests automatically.
|
4. CI validates builds and tests automatically.
|
||||||
|
|
||||||
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (v1.0.3, v1.0.4, etc.) and automatically built/pushed to the container registry.
|
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry.
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache gcc musl-dev
|
RUN apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -156,6 +156,24 @@ type AdminUsersResponse struct {
|
|||||||
GeneratedAt string `json:"generatedAt"`
|
GeneratedAt string `json:"generatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatLogEntry struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatLogListResponse struct {
|
||||||
|
Entries []ChatLogEntry `json:"entries"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatLogBulkDeleteRequest struct {
|
||||||
|
IDs []uint `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
type ScheduleEntry struct {
|
type ScheduleEntry struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -1934,6 +1952,162 @@ func handleModerationCommand(msg Message, conn *websocket.Conn) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminChatLog lists chat log entries with optional filtering and pagination
|
||||||
|
func handleAdminChatLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query()
|
||||||
|
room := strings.TrimSpace(query.Get("room"))
|
||||||
|
|
||||||
|
limit := 50
|
||||||
|
if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" {
|
||||||
|
parsedLimit, err := strconv.Atoi(rawLimit)
|
||||||
|
if err != nil || parsedLimit <= 0 {
|
||||||
|
http.Error(w, "invalid limit", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsedLimit > 200 {
|
||||||
|
parsedLimit = 200
|
||||||
|
}
|
||||||
|
limit = parsedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
dbQuery := db.Model(&Message{})
|
||||||
|
|
||||||
|
if room != "" {
|
||||||
|
dbQuery = dbQuery.Where("room = ?", room)
|
||||||
|
}
|
||||||
|
|
||||||
|
if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" {
|
||||||
|
beforeTime, err := time.Parse(time.RFC3339, beforeRaw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbQuery = dbQuery.Where("timestamp < ?", beforeTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if username := strings.TrimSpace(query.Get("username")); username != "" {
|
||||||
|
dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if text := strings.TrimSpace(query.Get("text")); text != "" {
|
||||||
|
dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := dbQuery.Count(&total).Error; err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []Message
|
||||||
|
if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]ChatLogEntry, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
entries[i] = ChatLogEntry{
|
||||||
|
ID: msg.ID,
|
||||||
|
Room: msg.Room,
|
||||||
|
Username: msg.Username,
|
||||||
|
Text: msg.Text,
|
||||||
|
Timestamp: msg.Timestamp,
|
||||||
|
Type: msg.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminChatLogByID returns or deletes a single chat log entry
|
||||||
|
func handleAdminChatLogByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/")
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart == "" || strings.Contains(idPart, "/") {
|
||||||
|
http.Error(w, "invalid chat log id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
http.Error(w, "invalid chat log id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
var msg Message
|
||||||
|
if err := db.First(&msg, id).Error; err != nil {
|
||||||
|
http.Error(w, "chat log entry not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(ChatLogEntry{
|
||||||
|
ID: msg.ID,
|
||||||
|
Room: msg.Room,
|
||||||
|
Username: msg.Username,
|
||||||
|
Text: msg.Text,
|
||||||
|
Timestamp: msg.Timestamp,
|
||||||
|
Type: msg.Type,
|
||||||
|
})
|
||||||
|
case http.MethodDelete:
|
||||||
|
result := db.Delete(&Message{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
http.Error(w, "chat log entry not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminChatLogBulk deletes multiple chat log entries by ID
|
||||||
|
func handleAdminChatLogBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload ChatLogBulkDeleteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload.IDs) == 0 {
|
||||||
|
http.Error(w, "ids array is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload.IDs) > 500 {
|
||||||
|
http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.Delete(&Message{}, payload.IDs)
|
||||||
|
if result.Error != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected})
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
initRedis()
|
initRedis()
|
||||||
initDB()
|
initDB()
|
||||||
@@ -1954,6 +2128,9 @@ func main() {
|
|||||||
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
|
||||||
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
|
||||||
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
||||||
|
http.HandleFunc("/api/admin/chat-log/bulk", requireAdminAuth(handleAdminChatLogBulk))
|
||||||
|
http.HandleFunc("/api/admin/chat-log/", requireAdminAuth(handleAdminChatLogByID))
|
||||||
|
http.HandleFunc("/api/admin/chat-log", requireAdminAuth(handleAdminChatLog))
|
||||||
http.HandleFunc("/metrics", handleMetrics)
|
http.HandleFunc("/metrics", handleMetrics)
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1961,6 +2138,13 @@ func main() {
|
|||||||
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Catch-all: return 404 JSON for all unhandled paths
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
})
|
||||||
|
|
||||||
port := os.Getenv("CHAT_PORT")
|
port := os.Getenv("CHAT_PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "3000"
|
port = "3000"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+78
-187
@@ -4,19 +4,79 @@
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Introduction and Goals](#introduction-and-goals)
|
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||||
- [Companion Documentation](#companion-documentation)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Architecture Constraints](#architecture-constraints)
|
- [Companion Documentation](#companion-documentation)
|
||||||
- [Context and Scope](#context-and-scope)
|
- [Introduction and Goals](#introduction-and-goals)
|
||||||
- [Solution Strategy](#solution-strategy)
|
- [Requirements Overview](#requirements-overview)
|
||||||
- [Building Block View](#building-block-view)
|
- [Quality Goals](#quality-goals)
|
||||||
- [Runtime View](#runtime-view)
|
- [Stakeholders](#stakeholders)
|
||||||
- [Deployment View](#deployment-view)
|
- [Architecture Constraints](#architecture-constraints)
|
||||||
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
- [Context and Scope](#context-and-scope)
|
||||||
- [Architecture Decisions](#architecture-decisions)
|
- [Business Context](#business-context)
|
||||||
- [Quality Requirements](#quality-requirements)
|
- [High-Level Context Diagram (Mermaid)](#high-level-context-diagram-mermaid)
|
||||||
- [Risks and Technical Debts](#risks-and-technical-debts)
|
- [Technical Context](#technical-context)
|
||||||
- [Glossary](#glossary)
|
- [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
|
## Companion Documentation
|
||||||
|
|
||||||
@@ -115,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**:
|
||||||
@@ -204,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.
|
||||||
|
|
||||||
@@ -259,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:**
|
||||||
@@ -739,162 +789,3 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
|
|||||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||||
|
|
||||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
||||||
|
|
||||||
### Whitebox Overall System
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<text explanation\>_
|
|
||||||
|
|
||||||
Contained Building Blocks
|
|
||||||
|
|
||||||
: _\<Description of contained building block (black boxes)\>_
|
|
||||||
|
|
||||||
Important Interfaces
|
|
||||||
|
|
||||||
: _\<Description of important interfaces\>_
|
|
||||||
|
|
||||||
#### \<Name black box 1\>
|
|
||||||
|
|
||||||
_\<Purpose/Responsibility\>_
|
|
||||||
|
|
||||||
_\<Interface(s)\>_
|
|
||||||
|
|
||||||
_\<(Optional) Quality/Performance Characteristics\>_
|
|
||||||
|
|
||||||
_\<(Optional) Directory/File Location\>_
|
|
||||||
|
|
||||||
_\<(Optional) Fulfilled Requirements\>_
|
|
||||||
|
|
||||||
_\<(optional) Open Issues/Problems/Risks\>_
|
|
||||||
|
|
||||||
#### \<Name black box 2\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name black box n\>
|
|
||||||
|
|
||||||
_\<black box template\>_
|
|
||||||
|
|
||||||
#### \<Name interface 1\>
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### \<Name interface m\>
|
|
||||||
|
|
||||||
### Level 2
|
|
||||||
|
|
||||||
#### White Box _\<building block 1\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box _\<building block 2\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### White Box _\<building block m\>_
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
### Level 3
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block x.2\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
#### White Box \<\_building block y.1\_\>
|
|
||||||
|
|
||||||
_\<white box template\>_
|
|
||||||
|
|
||||||
## Runtime View
|
|
||||||
|
|
||||||
### \<Runtime Scenario 1\>
|
|
||||||
|
|
||||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
|
||||||
|
|
||||||
- _\<insert description of the notable aspects of the interactions
|
|
||||||
between the building block instances depicted in this diagram.\>_
|
|
||||||
|
|
||||||
### \<Runtime Scenario 2\>
|
|
||||||
|
|
||||||
### ...
|
|
||||||
|
|
||||||
### \<Runtime Scenario n\>
|
|
||||||
|
|
||||||
## Deployment View
|
|
||||||
|
|
||||||
### Infrastructure Level 1
|
|
||||||
|
|
||||||
**_\<Overview Diagram\>_**
|
|
||||||
|
|
||||||
Motivation
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Quality and/or Performance Features
|
|
||||||
|
|
||||||
: _\<explanation in text form\>_
|
|
||||||
|
|
||||||
Mapping of Building Blocks to Infrastructure
|
|
||||||
|
|
||||||
: _\<description of the mapping\>_
|
|
||||||
|
|
||||||
### Infrastructure Level 2
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 1\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element 2\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
#### _\<Infrastructure Element n\>_
|
|
||||||
|
|
||||||
_\<diagram + explanation\>_
|
|
||||||
|
|
||||||
## Cross-cutting Concepts
|
|
||||||
|
|
||||||
### _\<Concept 1\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
### _\<Concept 2\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
### _\<Concept n\>_
|
|
||||||
|
|
||||||
_\<explanation\>_
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
|
|
||||||
## Quality Requirements
|
|
||||||
|
|
||||||
### Quality Requirements Overview
|
|
||||||
|
|
||||||
### Quality Scenarios
|
|
||||||
|
|
||||||
## Risks and Technical Debts
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| Term | Definition |
|
|
||||||
+======================+===============================================+
|
|
||||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
+3
-308
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="../src/style.css" />
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="admin-body" data-admin-default-moderator="%VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR%" data-admin-default-room="%VITE_PUBLIC_ADMIN_DEFAULT_ROOM%" data-admin-moderation-log-limit="%VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT%">
|
<body class="admin-body">
|
||||||
<div class="admin-shell">
|
<div class="admin-shell">
|
||||||
<aside class="admin-sidebar" aria-label="Primary">
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||||
|
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||||
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||||
@@ -31,313 +32,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
<section id="tab-dashboard" class="tab-panel is-active" role="tabpanel" aria-labelledby="tab-trigger-dashboard">
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<div class="stat-grid">
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Concurrent Viewers</h2>
|
|
||||||
<p id="stat-viewers" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Stored Messages</h2>
|
|
||||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Moderation Actions</h2>
|
|
||||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Media Runtime</h2>
|
|
||||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
|
||||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-upload" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-upload" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Upload Video</h1>
|
|
||||||
<p>Upload media files into the local media library mounted for playout.</p>
|
|
||||||
</div>
|
|
||||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="upload-dropzone" class="upload-dropzone">
|
|
||||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
|
||||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
|
||||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
|
||||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
|
||||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
|
||||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
|
||||||
</div>
|
|
||||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<h2>Media Library</h2>
|
|
||||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>File</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Runtime</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Size</th>
|
|
||||||
<th>Modified</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="media-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-schedule" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-schedule" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Schedule</h1>
|
|
||||||
<p>Read-only view of the currently configured playout order.</p>
|
|
||||||
</div>
|
|
||||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Label</th>
|
|
||||||
<th>Runtime</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Path</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="schedule-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-playlist" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-playlist" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Playlist</h1>
|
|
||||||
<p>Edit the active concat playlist used by the playout service.</p>
|
|
||||||
</div>
|
|
||||||
<div class="button-row">
|
|
||||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
|
||||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<label for="playlist-content" class="sr-only">Playlist content</label>
|
|
||||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false"></textarea>
|
|
||||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-moderation" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-moderation" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Moderation</h1>
|
|
||||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
|
||||||
</div>
|
|
||||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="moderation-grid">
|
|
||||||
<section class="panel-card">
|
|
||||||
<h2>New Action</h2>
|
|
||||||
<form id="moderation-form" class="form-stack">
|
|
||||||
<label>Action
|
|
||||||
<select id="mod-action" class="text-input">
|
|
||||||
<option value="mute">mute</option>
|
|
||||||
<option value="ban">ban</option>
|
|
||||||
<option value="delete">delete</option>
|
|
||||||
<option value="timeout">timeout</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Target
|
|
||||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
|
||||||
</label>
|
|
||||||
<label>Moderator
|
|
||||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
|
||||||
</label>
|
|
||||||
<label>Room
|
|
||||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
|
||||||
</label>
|
|
||||||
<label>Reason
|
|
||||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
|
||||||
</label>
|
|
||||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
|
||||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel-card">
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<h2>Recent Audit Log</h2>
|
|
||||||
<p class="small-muted">Latest entries</p>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>When</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Target</th>
|
|
||||||
<th>Moderator</th>
|
|
||||||
<th>Reason</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="moderation-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-analytics" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-analytics" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Analytics</h1>
|
|
||||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
|
||||||
</div>
|
|
||||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-grid">
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Total Messages</h2>
|
|
||||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Last 24 Hours</h2>
|
|
||||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Distinct Rooms</h2>
|
|
||||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<h2>Moderation Actions</h2>
|
|
||||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<h2>Top Rooms</h2>
|
|
||||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Room</th>
|
|
||||||
<th>Messages</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="analytics-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-system" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-system" hidden>
|
|
||||||
<h1>System Configuration</h1>
|
|
||||||
<div class="panel-card split-grid">
|
|
||||||
<section>
|
|
||||||
<h2>Redis Stats</h2>
|
|
||||||
<dl class="kv-list">
|
|
||||||
<div>
|
|
||||||
<dt>Memory Used</dt>
|
|
||||||
<dd id="system-redis-memory">loading...</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Connected Clients</dt>
|
|
||||||
<dd id="system-redis-clients">0</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Redis Enabled</dt>
|
|
||||||
<dd id="system-redis-enabled">no</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>Storage</h2>
|
|
||||||
<dl class="kv-list">
|
|
||||||
<div>
|
|
||||||
<dt>Database Driver</dt>
|
|
||||||
<dd id="system-db-driver">loading...</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Stored Messages</dt>
|
|
||||||
<dd id="system-stored-messages">0</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Last Refresh</dt>
|
|
||||||
<dd id="system-generated-at">loading...</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Playlist Path</dt>
|
|
||||||
<dd id="system-playlist-path">loading...</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Playlist Present</dt>
|
|
||||||
<dd id="system-playlist-present">no</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<h2>Service Health</h2>
|
|
||||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
|
||||||
</div>
|
|
||||||
<div id="system-services" class="service-list"></div>
|
|
||||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tab-users" class="tab-panel" role="tabpanel" aria-labelledby="tab-trigger-users" hidden>
|
|
||||||
<div class="panel-header-row">
|
|
||||||
<div>
|
|
||||||
<h1>Users</h1>
|
|
||||||
<p>Current room presence derived from Redis presence keys.</p>
|
|
||||||
</div>
|
|
||||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-card">
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Room</th>
|
|
||||||
<th>Online</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="users-table-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
@@ -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>
|
||||||
+405
-80
@@ -47,7 +47,9 @@ const uploadProgressWrap = () =>
|
|||||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||||
|
|
||||||
const uploadProgressBar = () =>
|
const uploadProgressBar = () =>
|
||||||
document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
document.getElementById(
|
||||||
|
"media-upload-progress",
|
||||||
|
) as HTMLProgressElement | null;
|
||||||
|
|
||||||
const uploadProgressText = () =>
|
const uploadProgressText = () =>
|
||||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||||
@@ -135,9 +137,21 @@ const fmtSize = (value?: number) => {
|
|||||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function switchTab(tabName: string) {
|
// Page content cache: maps tab name to loaded HTML string
|
||||||
|
const pageCache: Record<string, string> = {};
|
||||||
|
|
||||||
|
async function loadPageContent(tabName: string): Promise<string> {
|
||||||
|
if (pageCache[tabName]) return pageCache[tabName];
|
||||||
|
|
||||||
|
const response = await fetch(`/admin/${tabName}.html`);
|
||||||
|
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
||||||
|
const html = await response.text();
|
||||||
|
pageCache[tabName] = html;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTab(tabName: string) {
|
||||||
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
|
const triggers = document.querySelectorAll<HTMLButtonElement>(".tab-trigger");
|
||||||
const panels = document.querySelectorAll<HTMLElement>(".tab-panel");
|
|
||||||
|
|
||||||
triggers.forEach((trigger) => {
|
triggers.forEach((trigger) => {
|
||||||
const active = trigger.dataset.tab === tabName;
|
const active = trigger.dataset.tab === tabName;
|
||||||
@@ -145,16 +159,27 @@ function switchTab(tabName: string) {
|
|||||||
trigger.setAttribute("aria-selected", String(active));
|
trigger.setAttribute("aria-selected", String(active));
|
||||||
});
|
});
|
||||||
|
|
||||||
panels.forEach((panel) => {
|
const container = document.getElementById("tab-content-container");
|
||||||
const active = panel.id === `tab-${tabName}`;
|
if (!container) return;
|
||||||
panel.hidden = !active;
|
|
||||||
panel.classList.toggle("is-active", active);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await loadPageContent(tabName);
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tab content", error);
|
||||||
|
container.innerHTML = `<p class="status-error">Failed to load ${tabName} page.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-bind events for the newly loaded page content
|
||||||
|
bindPageEvents(tabName);
|
||||||
|
|
||||||
|
if (tabName === "dashboard") refreshOverview();
|
||||||
if (tabName === "upload") refreshMediaLibrary();
|
if (tabName === "upload") refreshMediaLibrary();
|
||||||
if (tabName === "playlist") refreshPlaylist();
|
if (tabName === "playlist") refreshPlaylist();
|
||||||
if (tabName === "schedule") refreshSchedule();
|
if (tabName === "schedule") refreshSchedule();
|
||||||
if (tabName === "moderation") refreshModerationLogs();
|
if (tabName === "moderation") refreshModerationLogs();
|
||||||
|
if (tabName === "chat-log") refreshChatLog();
|
||||||
if (tabName === "users") refreshUsers();
|
if (tabName === "users") refreshUsers();
|
||||||
if (tabName === "analytics") refreshAnalytics();
|
if (tabName === "analytics") refreshAnalytics();
|
||||||
if (tabName === "system") refreshSystemSummary();
|
if (tabName === "system") refreshSystemSummary();
|
||||||
@@ -446,11 +471,18 @@ async function uploadVideo(file: File) {
|
|||||||
|
|
||||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
const resumeFrom = Math.min(
|
||||||
|
Math.max(alreadyUploaded, 0),
|
||||||
|
effectiveTotalChunks,
|
||||||
|
);
|
||||||
|
|
||||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||||
|
|
||||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
for (
|
||||||
|
let chunkIndex = resumeFrom;
|
||||||
|
chunkIndex < effectiveTotalChunks;
|
||||||
|
chunkIndex += 1
|
||||||
|
) {
|
||||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||||
const chunk = file.slice(start, end);
|
const chunk = file.slice(start, end);
|
||||||
@@ -468,7 +500,11 @@ async function uploadVideo(file: File) {
|
|||||||
throw new Error(await readErrorMessage(chunkResponse));
|
throw new Error(await readErrorMessage(chunkResponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
setUploadProgress(
|
||||||
|
chunkIndex + 1,
|
||||||
|
effectiveTotalChunks,
|
||||||
|
"Uploading chunks",
|
||||||
|
);
|
||||||
setStatus(
|
setStatus(
|
||||||
"media-status",
|
"media-status",
|
||||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||||
@@ -497,7 +533,8 @@ async function uploadVideo(file: File) {
|
|||||||
await refreshSchedule();
|
await refreshSchedule();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload media", error);
|
console.error("Failed to upload media", error);
|
||||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
const details =
|
||||||
|
error instanceof Error ? error.message : "Unknown upload error.";
|
||||||
setStatus(
|
setStatus(
|
||||||
"media-status",
|
"media-status",
|
||||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||||
@@ -637,6 +674,245 @@ async function submitModerationAction(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chat Log ---
|
||||||
|
|
||||||
|
type ChatLogEntry = {
|
||||||
|
id: number;
|
||||||
|
room: string;
|
||||||
|
username: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatLogState = {
|
||||||
|
entries: [] as ChatLogEntry[],
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
before: "",
|
||||||
|
nextBefore: "",
|
||||||
|
selected: new Set<number>(),
|
||||||
|
room: "",
|
||||||
|
username: "",
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getChatLogBefore(): string {
|
||||||
|
if (chatLogState.entries.length === 0) return "";
|
||||||
|
const oldest = chatLogState.entries[chatLogState.entries.length - 1];
|
||||||
|
return oldest.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshChatLog() {
|
||||||
|
setStatus("chat-log-status", "Loading chat log...");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", String(chatLogState.limit));
|
||||||
|
if (chatLogState.before) params.set("before", chatLogState.before);
|
||||||
|
if (chatLogState.room) params.set("room", chatLogState.room);
|
||||||
|
if (chatLogState.username) params.set("username", chatLogState.username);
|
||||||
|
if (chatLogState.text) params.set("text", chatLogState.text);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/chat-log?${params}`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
chatLogState.entries = payload.entries || [];
|
||||||
|
chatLogState.total = payload.total || 0;
|
||||||
|
chatLogState.selected.clear();
|
||||||
|
|
||||||
|
renderChatLogTable();
|
||||||
|
updateChatLogPagination();
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
setStatus(
|
||||||
|
"chat-log-status",
|
||||||
|
`Loaded ${chatLogState.entries.length} entries.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load chat log", error);
|
||||||
|
setStatus("chat-log-status", "Failed to load chat log.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatLogTable() {
|
||||||
|
const tbody = document.getElementById("chat-log-table-body");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
if (chatLogState.entries.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="8">No chat log entries found.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatLogState.entries.forEach((entry) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const checked = chatLogState.selected.has(entry.id) ? "checked" : "";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="checkbox" class="chat-log-checkbox" data-id="${entry.id}" ${checked} /></td>
|
||||||
|
<td>${entry.id}</td>
|
||||||
|
<td>${entry.room}</td>
|
||||||
|
<td>${entry.username}</td>
|
||||||
|
<td>${entry.text}</td>
|
||||||
|
<td>${entry.type}</td>
|
||||||
|
<td>${entry.timestamp}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||||
|
"click",
|
||||||
|
() => deleteChatEntry(entry.id),
|
||||||
|
);
|
||||||
|
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
||||||
|
const cb = e.target as HTMLInputElement;
|
||||||
|
if (cb.checked) {
|
||||||
|
chatLogState.selected.add(entry.id);
|
||||||
|
} else {
|
||||||
|
chatLogState.selected.delete(entry.id);
|
||||||
|
}
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
updateSelectAllCheckbox();
|
||||||
|
});
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectAllCheckbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectAllCheckbox() {
|
||||||
|
const selectAll = document.getElementById(
|
||||||
|
"chat-log-select-all",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (!selectAll) return;
|
||||||
|
const allSelected =
|
||||||
|
chatLogState.entries.length > 0 &&
|
||||||
|
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||||
|
selectAll.checked = allSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteButton() {
|
||||||
|
const btn = document.getElementById(
|
||||||
|
"bulk-delete-chat-log-btn",
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = chatLogState.selected.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChatLogPagination() {
|
||||||
|
const prevBtn = document.getElementById(
|
||||||
|
"chat-log-prev-btn",
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const nextBtn = document.getElementById(
|
||||||
|
"chat-log-next-btn",
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const info = document.getElementById("chat-log-page-info");
|
||||||
|
|
||||||
|
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
||||||
|
if (nextBtn)
|
||||||
|
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||||
|
if (info)
|
||||||
|
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatLogNextPage() {
|
||||||
|
if (chatLogState.entries.length === 0) return;
|
||||||
|
chatLogState.before = getChatLogBefore();
|
||||||
|
refreshChatLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatLogPrevPage() {
|
||||||
|
chatLogState.before = "";
|
||||||
|
refreshChatLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatLogApplyFilter() {
|
||||||
|
chatLogState.room =
|
||||||
|
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
|
||||||
|
?.value || "";
|
||||||
|
chatLogState.username =
|
||||||
|
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
|
||||||
|
?.value || "";
|
||||||
|
chatLogState.text =
|
||||||
|
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
|
||||||
|
?.value || "";
|
||||||
|
chatLogState.before = "";
|
||||||
|
refreshChatLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatLogClearFilter() {
|
||||||
|
const roomEl = document.getElementById(
|
||||||
|
"chat-log-filter-room",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const userEl = document.getElementById(
|
||||||
|
"chat-log-filter-username",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const textEl = document.getElementById(
|
||||||
|
"chat-log-filter-text",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (roomEl) roomEl.value = "";
|
||||||
|
if (userEl) userEl.value = "";
|
||||||
|
if (textEl) textEl.value = "";
|
||||||
|
chatLogState.room = "";
|
||||||
|
chatLogState.username = "";
|
||||||
|
chatLogState.text = "";
|
||||||
|
chatLogState.before = "";
|
||||||
|
refreshChatLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChatEntry(id: number) {
|
||||||
|
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/chat-log/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
setStatus("chat-log-status", `Message #${id} deleted.`);
|
||||||
|
await refreshChatLog();
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete chat entry", error);
|
||||||
|
setStatus("chat-log-status", "Failed to delete message.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteChatLog() {
|
||||||
|
if (chatLogState.selected.size === 0) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setStatus(
|
||||||
|
"chat-log-status",
|
||||||
|
`Deleting ${chatLogState.selected.size} messages...`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const ids = Array.from(chatLogState.selected);
|
||||||
|
const response = await fetch("/api/admin/chat-log/bulk", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
setStatus(
|
||||||
|
"chat-log-status",
|
||||||
|
`${payload.deleted || ids.length} messages deleted.`,
|
||||||
|
);
|
||||||
|
await refreshChatLog();
|
||||||
|
await refreshOverview();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to bulk delete chat entries", error);
|
||||||
|
setStatus("chat-log-status", "Failed to delete messages.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
setStatus("users-status", "Loading active users...");
|
setStatus("users-status", "Loading active users...");
|
||||||
try {
|
try {
|
||||||
@@ -688,84 +964,133 @@ function bindEvents() {
|
|||||||
window.location.href = "/admin/login/";
|
window.location.href = "/admin/login/";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document
|
// bindPageEvents wires up page-specific DOM listeners after HTML is loaded
|
||||||
.getElementById("reload-media-btn")
|
function bindPageEvents(tabName: string) {
|
||||||
?.addEventListener("click", refreshMediaLibrary);
|
switch (tabName) {
|
||||||
document
|
case "upload":
|
||||||
.getElementById("reload-schedule-btn")
|
document
|
||||||
?.addEventListener("click", refreshSchedule);
|
.getElementById("reload-media-btn")
|
||||||
document
|
?.addEventListener("click", refreshMediaLibrary);
|
||||||
.getElementById("reload-playlist-btn")
|
|
||||||
?.addEventListener("click", refreshPlaylist);
|
|
||||||
document
|
|
||||||
.getElementById("save-playlist-btn")
|
|
||||||
?.addEventListener("click", savePlaylist);
|
|
||||||
document
|
|
||||||
.getElementById("reload-moderation-btn")
|
|
||||||
?.addEventListener("click", refreshModerationLogs);
|
|
||||||
document
|
|
||||||
.getElementById("reload-analytics-btn")
|
|
||||||
?.addEventListener("click", refreshAnalytics);
|
|
||||||
document
|
|
||||||
.getElementById("reload-users-btn")
|
|
||||||
?.addEventListener("click", refreshUsers);
|
|
||||||
document
|
|
||||||
.getElementById("reload-system-btn")
|
|
||||||
?.addEventListener("click", refreshSystemSummary);
|
|
||||||
|
|
||||||
const uploadInput = document.getElementById(
|
const uploadInput = document.getElementById(
|
||||||
"video-upload",
|
"video-upload",
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||||
document
|
document
|
||||||
.getElementById("upload-trigger-btn")
|
.getElementById("upload-trigger-btn")
|
||||||
?.addEventListener("click", () => uploadInput?.click());
|
?.addEventListener("click", () => uploadInput?.click());
|
||||||
|
|
||||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
uploadDropzone.classList.add("is-dragover");
|
uploadDropzone.classList.add("is-dragover");
|
||||||
});
|
});
|
||||||
uploadDropzone?.addEventListener("dragleave", () => {
|
uploadDropzone?.addEventListener("dragleave", () => {
|
||||||
uploadDropzone.classList.remove("is-dragover");
|
uploadDropzone.classList.remove("is-dragover");
|
||||||
});
|
});
|
||||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
uploadDropzone.classList.remove("is-dragover");
|
uploadDropzone.classList.remove("is-dragover");
|
||||||
const droppedFiles = event.dataTransfer?.files;
|
const droppedFiles = event.dataTransfer?.files;
|
||||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||||
await uploadVideo(droppedFiles[0]);
|
await uploadVideo(droppedFiles[0]);
|
||||||
});
|
});
|
||||||
|
uploadInput?.addEventListener("change", async (event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) return;
|
||||||
|
await uploadVideo(target.files[0]);
|
||||||
|
target.value = "";
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
uploadInput?.addEventListener("change", async (event) => {
|
case "playlist":
|
||||||
const target = event.target as HTMLInputElement;
|
document
|
||||||
if (!target.files || target.files.length === 0) return;
|
.getElementById("reload-playlist-btn")
|
||||||
await uploadVideo(target.files[0]);
|
?.addEventListener("click", refreshPlaylist);
|
||||||
target.value = "";
|
document
|
||||||
});
|
.getElementById("save-playlist-btn")
|
||||||
|
?.addEventListener("click", savePlaylist);
|
||||||
|
break;
|
||||||
|
|
||||||
document
|
case "schedule":
|
||||||
.getElementById("moderation-form")
|
document
|
||||||
?.addEventListener("submit", submitModerationAction);
|
.getElementById("reload-schedule-btn")
|
||||||
|
?.addEventListener("click", refreshSchedule);
|
||||||
|
break;
|
||||||
|
|
||||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
case "moderation":
|
||||||
adminConfig.defaultModerator;
|
document
|
||||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
.getElementById("reload-moderation-btn")
|
||||||
adminConfig.defaultRoom;
|
?.addEventListener("click", refreshModerationLogs);
|
||||||
|
document
|
||||||
|
.getElementById("moderation-form")
|
||||||
|
?.addEventListener("submit", submitModerationAction);
|
||||||
|
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||||
|
adminConfig.defaultModerator;
|
||||||
|
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||||
|
adminConfig.defaultRoom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "chat-log":
|
||||||
|
document
|
||||||
|
.getElementById("reload-chat-log-btn")
|
||||||
|
?.addEventListener("click", refreshChatLog);
|
||||||
|
document
|
||||||
|
.getElementById("bulk-delete-chat-log-btn")
|
||||||
|
?.addEventListener("click", bulkDeleteChatLog);
|
||||||
|
document
|
||||||
|
.getElementById("chat-log-filter-btn")
|
||||||
|
?.addEventListener("click", chatLogApplyFilter);
|
||||||
|
document
|
||||||
|
.getElementById("chat-log-clear-filter-btn")
|
||||||
|
?.addEventListener("click", chatLogClearFilter);
|
||||||
|
document
|
||||||
|
.getElementById("chat-log-prev-btn")
|
||||||
|
?.addEventListener("click", chatLogPrevPage);
|
||||||
|
document
|
||||||
|
.getElementById("chat-log-next-btn")
|
||||||
|
?.addEventListener("click", chatLogNextPage);
|
||||||
|
document
|
||||||
|
.getElementById("chat-log-select-all")
|
||||||
|
?.addEventListener("change", (e) => {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
chatLogState.entries.forEach((entry) => {
|
||||||
|
if (checked) {
|
||||||
|
chatLogState.selected.add(entry.id);
|
||||||
|
} else {
|
||||||
|
chatLogState.selected.delete(entry.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
renderChatLogTable();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "analytics":
|
||||||
|
document
|
||||||
|
.getElementById("reload-analytics-btn")
|
||||||
|
?.addEventListener("click", refreshAnalytics);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "users":
|
||||||
|
document
|
||||||
|
.getElementById("reload-users-btn")
|
||||||
|
?.addEventListener("click", refreshUsers);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "system":
|
||||||
|
document
|
||||||
|
.getElementById("reload-system-btn")
|
||||||
|
?.addEventListener("click", refreshSystemSummary);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
await Promise.all([
|
// Load dashboard as the initial tab
|
||||||
refreshOverview(),
|
await switchTab("dashboard");
|
||||||
refreshAnalytics(),
|
|
||||||
refreshMediaLibrary(),
|
|
||||||
refreshPlaylist(),
|
|
||||||
refreshSchedule(),
|
|
||||||
refreshModerationLogs(),
|
|
||||||
refreshSystemSummary(),
|
|
||||||
refreshUsers(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|||||||
+70
-81
@@ -6,47 +6,47 @@
|
|||||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||||
|
|
||||||
--bg-0: #0d1417;
|
--bg-0: #000000;
|
||||||
--bg-1: #172227;
|
--bg-1: #0a0a0a;
|
||||||
--bg-2: #0a1012;
|
--bg-2: #000000;
|
||||||
--ink-0: #edf6f3;
|
--ink-0: #ede8e6;
|
||||||
--ink-1: #cadbd6;
|
--ink-1: #b0a8a4;
|
||||||
--ink-soft: #9cb2ab;
|
--ink-soft: #78706c;
|
||||||
|
|
||||||
--panel: #17252b;
|
--panel: #111111;
|
||||||
--panel-2: #1f3138;
|
--panel-2: #1a1a1a;
|
||||||
--line: #30464d;
|
--line: #2a2a2a;
|
||||||
--line-soft: #3d5962;
|
--line-soft: #1f1f1f;
|
||||||
|
|
||||||
--brand: #2ec4b6;
|
--brand: #587792;
|
||||||
--brand-strong: #20a79a;
|
--brand-strong: #4a6780;
|
||||||
--brand-contrast: #061412;
|
--brand-contrast: #f4f8fb;
|
||||||
--ok: #5add94;
|
--ok: #04650d;
|
||||||
--warn: #f2b560;
|
--warn: #d49624;
|
||||||
--danger: #ff7d8f;
|
--danger: #c44a5e;
|
||||||
--danger-soft: #3a1f27;
|
--danger-soft: #1f1216;
|
||||||
|
|
||||||
--focus: #78f2e0;
|
--focus: #2ebfa5;
|
||||||
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.34);
|
--shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.45);
|
--shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
--bg-grad-a: #132129;
|
--bg-grad-a: #000000;
|
||||||
--bg-grad-b: var(--bg-0);
|
--bg-grad-b: #000000;
|
||||||
--bg-grad-c: var(--bg-2);
|
--bg-grad-c: #000000;
|
||||||
|
|
||||||
--live-dot: #ff6b6b;
|
--live-dot: #ff6b6b;
|
||||||
--overlay-bg: rgba(4, 14, 18, 0.84);
|
--overlay-bg: rgba(0, 0, 0, 0.88);
|
||||||
|
|
||||||
--input-bg: #131f24;
|
--input-bg: #0d0d0d;
|
||||||
--input-border: var(--line-soft);
|
--input-border: var(--line-soft);
|
||||||
--placeholder: #90a8a1;
|
--placeholder: #6a6260;
|
||||||
|
|
||||||
--badge-start: #35c8bb;
|
--badge-start: #2ebfa5;
|
||||||
--badge-end: #1f8d85;
|
--badge-end: #1a8c78;
|
||||||
|
|
||||||
--radius-1: 0.6rem;
|
--radius-1: 0;
|
||||||
--radius-2: 1rem;
|
--radius-2: 0;
|
||||||
--radius-3: 1.6rem;
|
--radius-3: 0;
|
||||||
|
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
@@ -63,43 +63,43 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
--bg-0: #f4f6ef;
|
--bg-0: #e8e8e8;
|
||||||
--bg-1: #e3e8db;
|
--bg-1: #dcdcdc;
|
||||||
--bg-2: #eff3ea;
|
--bg-2: #f0f0f0;
|
||||||
--ink-0: #101b18;
|
--ink-0: #0d0d0d;
|
||||||
--ink-1: #2d3a36;
|
--ink-1: #2a2a2a;
|
||||||
--ink-soft: #5a6963;
|
--ink-soft: #555555;
|
||||||
|
|
||||||
--panel: #fffffd;
|
--panel: #ffffff;
|
||||||
--panel-2: #f7fbf2;
|
--panel-2: #f5f5f5;
|
||||||
--line: #c6d1c5;
|
--line: #cccccc;
|
||||||
--line-soft: #d8e2d8;
|
--line-soft: #dddddd;
|
||||||
|
|
||||||
--brand: #005f73;
|
--brand: #587792;
|
||||||
--brand-strong: #00464f;
|
--brand-strong: #4a6780;
|
||||||
--brand-contrast: #f9fffd;
|
--brand-contrast: #f4f8fb;
|
||||||
--ok: #2f7a43;
|
--ok: #04650d;
|
||||||
--warn: #b76a00;
|
--warn: #a86200;
|
||||||
--danger: #a7283f;
|
--danger: #a7283f;
|
||||||
--danger-soft: #fff3f6;
|
--danger-soft: #fff3f6;
|
||||||
|
|
||||||
--focus: #0a9396;
|
--focus: #2ebfa5;
|
||||||
--shadow-1: 0 14px 30px rgba(31, 54, 47, 0.12);
|
--shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-2: 0 22px 44px rgba(20, 39, 35, 0.2);
|
--shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
--bg-grad-a: #dce9d6;
|
--bg-grad-a: #e8e8e8;
|
||||||
--bg-grad-b: var(--bg-0);
|
--bg-grad-b: var(--bg-0);
|
||||||
--bg-grad-c: #eff3ea;
|
--bg-grad-c: #f0f0f0;
|
||||||
|
|
||||||
--live-dot: #cb271a;
|
--live-dot: #cb271a;
|
||||||
--overlay-bg: rgba(0, 16, 19, 0.84);
|
--overlay-bg: rgba(0, 0, 0, 0.88);
|
||||||
|
|
||||||
--input-bg: #fff;
|
--input-bg: #ffffff;
|
||||||
--input-border: #c2d2c1;
|
--input-border: #cccccc;
|
||||||
--placeholder: #74817b;
|
--placeholder: #888888;
|
||||||
|
|
||||||
--badge-start: #0a9396;
|
--badge-start: #2ebfa5;
|
||||||
--badge-end: #005f73;
|
--badge-end: #1a8c78;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.viewer-header {
|
.viewer-header {
|
||||||
padding: var(--space-5);
|
padding: var(--space-3) var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -392,7 +392,7 @@ select {
|
|||||||
.brand-badge {
|
.brand-badge {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -424,7 +424,7 @@ select {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
@@ -447,7 +447,7 @@ select {
|
|||||||
.player {
|
.player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 1.1rem;
|
border-radius: 0;
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
background: #000;
|
background: #000;
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
@@ -467,14 +467,14 @@ select {
|
|||||||
background: var(--overlay-bg);
|
background: var(--overlay-bg);
|
||||||
color: #f0fffb;
|
color: #f0fffb;
|
||||||
padding: 0.44rem 0.72rem;
|
padding: 0.44rem 0.72rem;
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-label {
|
.live-label {
|
||||||
background: #d62828;
|
background: var(--live-dot);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -542,7 +542,7 @@ select {
|
|||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
max-width: 83%;
|
max-width: 83%;
|
||||||
border-radius: 1rem;
|
border-radius: 0;
|
||||||
padding: 0.58rem 0.72rem;
|
padding: 0.58rem 0.72rem;
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
animation: messageIn var(--fast);
|
animation: messageIn var(--fast);
|
||||||
@@ -563,7 +563,7 @@ select {
|
|||||||
color: var(--ink-1);
|
color: var(--ink-1);
|
||||||
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
background: color-mix(in srgb, var(--brand) 22%, var(--panel-2));
|
||||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line));
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
padding: 0.24rem 0.68rem;
|
padding: 0.24rem 0.68rem;
|
||||||
animation: messageIn var(--fast);
|
animation: messageIn var(--fast);
|
||||||
}
|
}
|
||||||
@@ -609,18 +609,7 @@ select {
|
|||||||
.admin-body {
|
.admin-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background:
|
background: #000000;
|
||||||
radial-gradient(
|
|
||||||
circle at 82% 12%,
|
|
||||||
rgba(20, 48, 54, 0.5) 0%,
|
|
||||||
transparent 44%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
circle at 16% 85%,
|
|
||||||
rgba(8, 28, 34, 0.55) 0%,
|
|
||||||
transparent 40%
|
|
||||||
),
|
|
||||||
var(--bg-0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
@@ -637,8 +626,8 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
background: #102a2f;
|
background: var(--bg-2);
|
||||||
color: #ecfff6;
|
color: var(--ink-0);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@@ -666,7 +655,7 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ddf8ee;
|
color: var(--ink-1);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
padding: 0.62rem 0.72rem;
|
padding: 0.62rem 0.72rem;
|
||||||
|
|||||||
Reference in new issue
Block a user