4 Commits
Author SHA1 Message Date
zwitschi 21a34a4bd5 Release version 1.0.5
CI / test (push) Successful in 10m42s
CI / docker (push) Successful in 4m43s
CI / release (push) Skipped
Chat Message Admin Improvements: 3 API endpoints, 9 page HTML split, chat-log UI with delete/confirm/pagination/filter/bulk delete

CI/CD Improvements: Go cache fix, lint script, Vite env cleanup, chat start_period 30s, nginx health check, log dump on fail
2026-08-05 11:44:46 +02:00
zwitschi ee16441b79 docs: update agent instructions 2026-08-05 11:29:52 +02:00
zwitschi 74133ec589 feat: add chat log admin management and refactor admin panel to lazy-load tab content 2026-08-05 11:29:11 +02:00
zwitschi f650a3a94e docs: enhance architecture documentation with detailed sections on requirements, quality goals, context, and scaling strategy 2026-08-05 10:20:33 +02:00
20 changed files with 1026 additions and 573 deletions

No files matched your search

+24 -5
View File
@@ -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:8080/; then
echo "Nginx health check failed"
docker logs linearvoid-nginx --tail 50
exit 1
fi
# Retry playout health check since it may still be initializing # Retry playout health check since it may still be initializing
echo "Checking playout health..."
for i in $(seq 1 6); do for i in $(seq 1 6); do
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout is healthy" echo "Playout is healthy"
exit 0 break
fi fi
echo "Playout not ready yet (attempt $i/6), waiting 5s..." echo "Playout not ready yet (attempt $i/6), waiting 5s..."
sleep 5 sleep 5
done done
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout failed to become healthy after 6 attempts" echo "Playout failed to become healthy after 6 attempts"
docker logs linearvoid-playout --tail 50 docker logs linearvoid-playout --tail 50
exit 1 exit 1
curl -f http://192.168.88.40:3000/health || exit 1 fi
docker exec linearvoid-playout curl -sf http://localhost:8081/health || exit 1
echo "All services healthy"
- name: Cleanup old images - name: Cleanup old images
env: env:
+3
View File
@@ -36,6 +36,9 @@ jobs:
with: with:
go-version: "1.22" go-version: "1.22"
cache: true cache: true
cache-dependency-path: |
backend/playout/go.sum
backend/chat/go.sum
- name: Validate Playlist Normalization - name: Validate Playlist Normalization
run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete" run: cd backend/playout && go run src/playlist.go playlist.txt || echo "Validation script not yet complete"
+9 -2
View File
@@ -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.
+20 -22
View File
@@ -2,40 +2,38 @@
<!-- markdownlint-disable MD024 --> <!-- markdownlint-disable MD024 -->
## Unreleased ## [1.0.5] - 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. - Bumped chat healthcheck `start_period` from 10s to 30s in `docker-compose.prod.yml`.
- 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. - CD workflow now verifies nginx (port 8080) + chat (3000) + playout (8081); dumps container logs for all failing services.
- Added navigation links in root README and architecture docs to the new API, deployment, monitoring, and security guide files. - 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 `backend/playout/go.sum` and `backend/chat/go.sum`.
- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage.
- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting.
- Removed Tailwind and Alpine runtime/build dependencies from the frontend package configuration and eliminated Tailwind directives/warnings during production build.
- 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
+1 -1
View File
@@ -7,7 +7,7 @@
[![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions) [![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz) [![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz)
[![Version](https://img.shields.io/badge/version-1.0.3-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases) [![Version](https://img.shields.io/badge/version-1.0.5-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases)
[![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md) [![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md)
## Architecture ## Architecture
+184
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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\>_ |
+----------------------+-----------------------------------------------+
+45
View File
@@ -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>
+46
View File
@@ -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>
+21
View File
@@ -0,0 +1,21 @@
<h1>Dashboard</h1>
<div class="stat-grid">
<article class="stat-card">
<h2>Concurrent Viewers</h2>
<p id="stat-viewers" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Stored Messages</h2>
<p id="stat-stored-messages" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Moderation Actions</h2>
<p id="stat-moderation-actions" class="stat-value">0</p>
</article>
<article class="stat-card">
<h2>Media Runtime</h2>
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
<p id="stat-media-count" class="stat-caption">0 files</p>
</article>
</div>
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
+3 -308
View File
@@ -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>
+58
View File
@@ -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>
+16
View File
@@ -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>
+26
View File
@@ -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>
+54
View File
@@ -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>
+41
View File
@@ -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>
+23
View File
@@ -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>
+3 -2
View File
@@ -1,12 +1,13 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.5",
"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",
+370 -45
View File
@@ -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,31 +964,15 @@ function bindEvents() {
window.location.href = "/admin/login/"; window.location.href = "/admin/login/";
} }
}); });
}
// bindPageEvents wires up page-specific DOM listeners after HTML is loaded
function bindPageEvents(tabName: string) {
switch (tabName) {
case "upload":
document document
.getElementById("reload-media-btn") .getElementById("reload-media-btn")
?.addEventListener("click", refreshMediaLibrary); ?.addEventListener("click", refreshMediaLibrary);
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document
.getElementById("reload-analytics-btn")
?.addEventListener("click", refreshAnalytics);
document
.getElementById("reload-users-btn")
?.addEventListener("click", refreshUsers);
document
.getElementById("reload-system-btn")
?.addEventListener("click", refreshSystemSummary);
const uploadInput = document.getElementById( const uploadInput = document.getElementById(
"video-upload", "video-upload",
@@ -736,36 +996,101 @@ function bindEvents() {
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) => { uploadInput?.addEventListener("change", async (event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return; if (!target.files || target.files.length === 0) return;
await uploadVideo(target.files[0]); await uploadVideo(target.files[0]);
target.value = ""; target.value = "";
}); });
break;
case "playlist":
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
break;
case "schedule":
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
break;
case "moderation":
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document document
.getElementById("moderation-form") .getElementById("moderation-form")
?.addEventListener("submit", submitModerationAction); ?.addEventListener("submit", submitModerationAction);
(document.getElementById("mod-moderator") as HTMLInputElement).value = (document.getElementById("mod-moderator") as HTMLInputElement).value =
adminConfig.defaultModerator; adminConfig.defaultModerator;
(document.getElementById("mod-room") as HTMLInputElement).value = (document.getElementById("mod-room") as HTMLInputElement).value =
adminConfig.defaultRoom; 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", () => {