Compare commits
4
Commits
c10d98f3b4
..
1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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: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
|
||||||
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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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\>_ |
|
|
||||||
+----------------------+-----------------------------------------------+
|
|
||||||
@@ -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>
|
||||||
+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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
|||||||
+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", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user