feat: add admin login page and authentication API
CD / deploy (push) Failing after 27s
CI / test (push) Successful in 1m54s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s

- Created a new login page for admin access at frontend/admin/login/index.html.
- Implemented form submission with error handling for login credentials.
- Updated frontend/index.html to mute the video player and removed unnecessary Alpine.js data binding.
- Enhanced manifest URL resolution in frontend/src/main.ts to include cache-busting parameters.
- Modified vite.config.ts to include the new admin login page in the build process.
- Updated nginx.conf to handle admin login routing, authentication, and API proxying for admin actions.
This commit is contained in:
zwitschi committed 2026-08-03 17:19:05 +02:00
1 parent 1ae0ad121b
commit 6b494a34fe
15 files changed
+1896 -52

No files matched your search

+6
View File
@@ -19,6 +19,12 @@ MEDIA_DOMAIN=media.linearvoid.tv
VITE_PUBLIC_APP_URL=https://linearvoid.tv VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ADMIN_SESSION_TTL_HOURS=12
# Optional direct-local fallback when bypassing the public proxy: # Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost # VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws # VITE_PUBLIC_WS_URL=ws://localhost/ws
+1 -1
View File
@@ -82,7 +82,7 @@ grafana/
# Agent / Tool files # Agent / Tool files
.github/instructions/TODO.md .github/instructions/TODO.md
.github/instructions/DONE.md .github/instructions/DONE.md
# (keep AGENTS.md and other instruction files) # (keep AGENTS.md, MEMORY.md, and other instruction files)
# Temporary architecture artifacts # Temporary architecture artifacts
**/*.tmp **/*.tmp
+14
View File
@@ -2,6 +2,20 @@
<!-- markdownlint-disable MD024 --> <!-- markdownlint-disable MD024 -->
## [1.0.3] - 2026-08-03
### Added
- Added Prometheus-format `/metrics` endpoint for chat runtime and admin-session monitoring.
### Changed
- Updated Prometheus scrape targets and Grafana dashboard queries to use live chat metrics.
### Fixed
- Fixed nginx routing so `/metrics` is reachable through localhost and `linearvoid.tv`.
## [1.0.2] - 2026-08-03 ## [1.0.2] - 2026-08-03
### Changed ### Changed
+8 -2
View File
@@ -58,6 +58,12 @@ This guide covers prerequisites, environment setup, running the application loca
VITE_PUBLIC_APP_URL=https://linearvoid.tv VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
ADMIN_SESSION_TTL_HOURS=12
# Optional direct-local fallback when bypassing the public proxy: # Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost # VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws # VITE_PUBLIC_WS_URL=ws://localhost/ws
@@ -188,7 +194,7 @@ This guide covers prerequisites, environment setup, running the application loca
2. **Access the Application**: 2. **Access the Application**:
- Preferred local access with the documented proxy setup: <https://linearvoid.tv> - Preferred local access with the documented proxy setup: <https://linearvoid.tv>
- Admin Interface: <https://linearvoid.tv/admin> - Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
- Chat WebSocket: `wss://linearvoid.tv/ws` - Chat WebSocket: `wss://linearvoid.tv/ws`
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws` - Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
- Health Checks: - Health Checks:
@@ -299,7 +305,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200. - **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard. - **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use. - **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
- **Admin Interface**: Ensure the protected route is configured in nginx and authentication is set up (default is open for local development). - **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist. - **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
## Further Reading ## Further Reading
+870
View File
@@ -2,10 +2,17 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -26,6 +33,8 @@ var (
} }
clients = make(map[*websocket.Conn]bool) clients = make(map[*websocket.Conn]bool)
mutex = sync.Mutex{} mutex = sync.Mutex{}
authSessions = map[string]time.Time{}
authSessionsMu = sync.Mutex{}
) )
// Message represents a chat message (persisted in SQLite per ADR-006) // Message represents a chat message (persisted in SQLite per ADR-006)
@@ -55,6 +64,112 @@ type Presence struct {
Online bool `json:"online"` Online bool `json:"online"`
} }
type AdminOverview struct {
Viewers int `json:"viewers"`
StoredMessages int64 `json:"storedMessages"`
ModerationActions int64 `json:"moderationActions"`
RedisEnabled bool `json:"redisEnabled"`
RedisClients int `json:"redisClients"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
DatabaseDriver string `json:"databaseDriver"`
GeneratedAt string `json:"generatedAt"`
}
type PlaylistPayload struct {
Content string `json:"content"`
}
type ModerationActionPayload struct {
Action string `json:"action"`
Target string `json:"target"`
Moderator string `json:"moderator"`
Reason string `json:"reason"`
Room string `json:"room"`
}
type ModerationLogsResponse struct {
Logs []ModerationLog `json:"logs"`
}
type RoomAnalytics struct {
Room string `json:"room"`
MessageCount int64 `json:"messageCount"`
}
type AdminAnalytics struct {
TotalMessages int64 `json:"totalMessages"`
MessagesLast24Hours int64 `json:"messagesLast24Hours"`
DistinctRooms int64 `json:"distinctRooms"`
ModerationActions int64 `json:"moderationActions"`
TopRooms []RoomAnalytics `json:"topRooms"`
GeneratedAt string `json:"generatedAt"`
}
type ServiceHealth struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
Checked string `json:"checked"`
}
type AdminSystemSummary struct {
DatabaseDriver string `json:"databaseDriver"`
PlaylistPath string `json:"playlistPath"`
PlaylistExists bool `json:"playlistExists"`
RedisEnabled bool `json:"redisEnabled"`
RedisMemoryUsed string `json:"redisMemoryUsed"`
Services []ServiceHealth `json:"services"`
GeneratedAt string `json:"generatedAt"`
}
type ActiveUser struct {
Username string `json:"username"`
Room string `json:"room"`
Online bool `json:"online"`
}
type AdminUsersResponse struct {
Users []ActiveUser `json:"users"`
RedisEnabled bool `json:"redisEnabled"`
GeneratedAt string `json:"generatedAt"`
}
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
}
type AdminScheduleResponse struct {
Entries []ScheduleEntry `json:"entries"`
GeneratedAt string `json:"generatedAt"`
}
type MediaFileEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type AdminMediaResponse struct {
Files []MediaFileEntry `json:"files"`
GeneratedAt string `json:"generatedAt"`
}
type AdminLoginPayload struct {
Username string `json:"username"`
Password string `json:"password"`
}
type AdminSessionResponse struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username,omitempty"`
}
var databaseDriverName = "sqlite"
const adminSessionCookieName = "linearvoid_admin_session"
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007) // initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
func initRedis() { func initRedis() {
redisAddress := os.Getenv("REDIS_URL") redisAddress := os.Getenv("REDIS_URL")
@@ -110,10 +225,752 @@ func initDB() {
log.Fatalf("Failed to connect to %s database: %v", driverName, err) log.Fatalf("Failed to connect to %s database: %v", driverName, err)
} }
databaseDriverName = driverName
db.AutoMigrate(&Message{}, &ModerationLog{}) db.AutoMigrate(&Message{}, &ModerationLog{})
log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName)) log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
} }
func parseRedisInfoValue(info, key string) string {
prefix := key + ":"
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func getAdminOverview() AdminOverview {
overview := AdminOverview{
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
DatabaseDriver: databaseDriverName,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
mutex.Lock()
overview.Viewers = len(clients)
mutex.Unlock()
if db != nil {
db.Model(&Message{}).Count(&overview.StoredMessages)
db.Model(&ModerationLog{}).Count(&overview.ModerationActions)
}
if redisClient != nil {
ctx := context.Background()
if info, err := redisClient.Info(ctx, "clients", "memory").Result(); err == nil {
if value := parseRedisInfoValue(info, "connected_clients"); value != "" {
if count, convErr := strconv.Atoi(value); convErr == nil {
overview.RedisClients = count
}
}
if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
overview.RedisMemoryUsed = value
}
}
}
return overview
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload AdminLoginPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
if payload.Username != getAdminUsername() || payload.Password != getAdminPassword() {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
token, err := generateSessionToken()
if err != nil {
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(getAdminSessionTTL())
authSessionsMu.Lock()
authSessions[token] = expiresAt
authSessionsMu.Unlock()
setAdminSessionCookie(w, r, token, expiresAt)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if cookie, err := r.Cookie(adminSessionCookieName); err == nil {
authSessionsMu.Lock()
delete(authSessions, cookie.Value)
authSessionsMu.Unlock()
}
clearAdminSessionCookie(w, r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
}
func handleAdminSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !validateAdminSession(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()})
}
func handleAdminOverview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(getAdminOverview())
}
func getPlaylistPath() string {
playlistPath := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
if playlistPath == "" {
playlistPath = "/app/playlist/playlist.txt"
}
return playlistPath
}
func getMediaDir() string {
mediaDir := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
if mediaDir == "" {
mediaDir = "/app/media"
}
return mediaDir
}
func getAdminUsername() string {
username := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
if username == "" {
username = "admin"
}
return username
}
func getAdminPassword() string {
password := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if password == "" {
password = "admin"
}
return password
}
func getAdminSessionTTL() time.Duration {
if rawHours := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS")); rawHours != "" {
if hours, err := strconv.Atoi(rawHours); err == nil && hours > 0 {
return time.Duration(hours) * time.Hour
}
}
return 12 * time.Hour
}
func generateSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func sessionCookieSecure(r *http.Request) bool {
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
return strings.EqualFold(forwardedProto, "https")
}
return r.TLS != nil
}
func setAdminSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: sessionCookieSecure(r),
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
})
}
func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: sessionCookieSecure(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
func validateAdminSession(r *http.Request) bool {
cookie, err := r.Cookie(adminSessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
authSessionsMu.Lock()
defer authSessionsMu.Unlock()
expiresAt, ok := authSessions[cookie.Value]
if !ok {
return false
}
if time.Now().After(expiresAt) {
delete(authSessions, cookie.Value)
return false
}
return true
}
func requireAdminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !validateAdminSession(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func parsePlaylistEntries(content string) []ScheduleEntry {
entries := []ScheduleEntry{}
lines := strings.Split(content, "\n")
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
entries = append(entries, ScheduleEntry{
Index: len(entries) + 1,
Path: line,
Label: filepath.Base(line),
})
}
return entries
}
func handleAdminPlaylist(w http.ResponseWriter, r *http.Request) {
playlistPath := getPlaylistPath()
switch r.Method {
case http.MethodGet:
content, err := os.ReadFile(playlistPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PlaylistPayload{Content: string(content)})
case http.MethodPut:
var payload PlaylistPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
if err := os.MkdirAll(filepath.Dir(playlistPath), 0o755); err != nil {
http.Error(w, fmt.Sprintf("failed to ensure playlist directory: %v", err), http.StatusInternalServerError)
return
}
content := payload.Content
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := os.WriteFile(playlistPath, []byte(content), 0o644); err != nil {
http.Error(w, fmt.Sprintf("failed to save playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(PlaylistPayload{Content: content})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
content, err := os.ReadFile(getPlaylistPath())
if err != nil {
http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminScheduleResponse{
Entries: parsePlaylistEntries(string(content)),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
})
}
func handleAdminModerationLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsedLimit, err := strconv.Atoi(rawLimit)
if err != nil || parsedLimit <= 0 {
http.Error(w, "invalid limit", http.StatusBadRequest)
return
}
if parsedLimit > 200 {
parsedLimit = 200
}
limit = parsedLimit
}
var logs []ModerationLog
if err := db.Order("timestamp desc").Limit(limit).Find(&logs).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to load moderation logs: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ModerationLogsResponse{Logs: logs})
}
func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload ModerationActionPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
payload.Action = strings.TrimSpace(payload.Action)
payload.Target = strings.TrimSpace(payload.Target)
payload.Moderator = strings.TrimSpace(payload.Moderator)
payload.Reason = strings.TrimSpace(payload.Reason)
payload.Room = strings.TrimSpace(payload.Room)
if payload.Action == "" || payload.Target == "" || payload.Reason == "" {
http.Error(w, "action, target, and reason are required", http.StatusBadRequest)
return
}
if payload.Moderator == "" {
payload.Moderator = "admin"
}
if payload.Room == "" {
payload.Room = "main"
}
logModerationAction(payload.Action, payload.Target, payload.Moderator, payload.Reason)
broadcast(Message{
Room: payload.Room,
Username: "system",
Type: "system",
Text: fmt.Sprintf("Moderator %s %s %s for: %s", payload.Moderator, payload.Action, payload.Target, payload.Reason),
Timestamp: time.Now(),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}
func getAdminAnalytics() AdminAnalytics {
analytics := AdminAnalytics{
TopRooms: []RoomAnalytics{},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if db == nil {
return analytics
}
db.Model(&Message{}).Count(&analytics.TotalMessages)
db.Model(&Message{}).
Where("timestamp >= ?", time.Now().Add(-24*time.Hour)).
Count(&analytics.MessagesLast24Hours)
db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms)
db.Model(&ModerationLog{}).Count(&analytics.ModerationActions)
var topRooms []RoomAnalytics
db.Model(&Message{}).
Select("room, COUNT(*) as message_count").
Group("room").
Order("message_count DESC").
Limit(5).
Scan(&topRooms)
analytics.TopRooms = topRooms
return analytics
}
func handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(getAdminAnalytics())
}
func checkServiceHealth(name, url string) ServiceHealth {
checked := time.Now().UTC().Format(time.RFC3339)
resp, err := http.Get(url)
if err != nil {
return ServiceHealth{Name: name, Status: "down", Detail: err.Error(), Checked: checked}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
status := "healthy"
if resp.StatusCode >= 400 {
status = "degraded"
}
detail := strings.TrimSpace(string(body))
if detail == "" {
detail = resp.Status
}
return ServiceHealth{Name: name, Status: status, Detail: detail, Checked: checked}
}
func getAdminSystemSummary() AdminSystemSummary {
summary := AdminSystemSummary{
DatabaseDriver: databaseDriverName,
PlaylistPath: getPlaylistPath(),
RedisEnabled: redisClient != nil,
RedisMemoryUsed: "unavailable",
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if _, err := os.Stat(summary.PlaylistPath); err == nil {
summary.PlaylistExists = true
}
if redisClient != nil {
if info, err := redisClient.Info(context.Background(), "memory").Result(); err == nil {
if value := parseRedisInfoValue(info, "used_memory_human"); value != "" {
summary.RedisMemoryUsed = value
}
}
}
summary.Services = []ServiceHealth{
{Name: "chat", Status: "healthy", Detail: "local process", Checked: summary.GeneratedAt},
checkServiceHealth("playout", "http://playout:8081/health"),
}
if redisClient != nil {
if err := redisClient.Ping(context.Background()).Err(); err != nil {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
}
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "disabled", Detail: "client unavailable", Checked: summary.GeneratedAt})
}
if sqlDB, err := db.DB(); err == nil {
if err := sqlDB.Ping(); err != nil {
summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt})
} else {
summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt})
}
}
return summary
}
func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(getAdminSystemSummary())
}
func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
response := AdminUsersResponse{
Users: []ActiveUser{},
RedisEnabled: redisClient != nil,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
if redisClient != nil {
ctx := context.Background()
keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result()
if err != nil {
http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError)
return
}
for _, key := range keys {
parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 {
continue
}
response.Users = append(response.Users, ActiveUser{
Room: parts[1],
Username: parts[2],
Online: true,
})
}
}
sort.Slice(response.Users, func(i, j int) bool {
if response.Users[i].Room == response.Users[j].Room {
return response.Users[i].Username < response.Users[j].Username
}
return response.Users[i].Room < response.Users[j].Room
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func listMediaFiles() ([]MediaFileEntry, error) {
mediaDir := getMediaDir()
entries, err := os.ReadDir(mediaDir)
if err != nil {
return nil, err
}
files := make([]MediaFileEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, MediaFileEntry{
Name: entry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
return files, nil
}
func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
files, err := listMediaFiles()
if err != nil {
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
case http.MethodPost:
if err := r.ParseMultipartForm(256 << 20); err != nil {
http.Error(w, fmt.Sprintf("failed to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, fmt.Sprintf("missing file upload: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
mediaDir := getMediaDir()
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
return
}
targetPath := filepath.Join(mediaDir, filepath.Base(header.Filename))
targetFile, err := os.Create(targetPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
return
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, file); err != nil {
http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError)
return
}
files, err := listMediaFiles()
if err != nil {
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func countConnectedClients() int {
mutex.Lock()
defer mutex.Unlock()
return len(clients)
}
func countActiveAdminSessions() int {
now := time.Now()
authSessionsMu.Lock()
defer authSessionsMu.Unlock()
active := 0
for token, expiresAt := range authSessions {
if now.After(expiresAt) {
delete(authSessions, token)
continue
}
active++
}
return active
}
func writePrometheusMetricLine(w io.Writer, name string, value any) {
fmt.Fprintf(w, "%s %v\n", name, value)
}
func handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var storedMessages int64
if err := db.Model(&Message{}).Count(&storedMessages).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to count messages: %v", err), http.StatusInternalServerError)
return
}
var moderationActions int64
if err := db.Model(&ModerationLog{}).Count(&moderationActions).Error; err != nil {
http.Error(w, fmt.Sprintf("failed to count moderation actions: %v", err), http.StatusInternalServerError)
return
}
redisEnabled := 0
redisUp := 0
redisClients := 0
if redisClient != nil {
redisEnabled = 1
if err := redisClient.Ping(context.Background()).Err(); err == nil {
redisUp = 1
if info, err := redisClient.Info(context.Background(), "clients").Result(); err == nil {
for _, line := range strings.Split(info, "\n") {
if strings.HasPrefix(line, "connected_clients:") {
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
redisClients = value
}
break
}
}
}
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
writePrometheusMetricLine(w, "linearvoid_chat_connected_clients", countConnectedClients())
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages)
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
writePrometheusMetricLine(w, "linearvoid_chat_moderation_actions_total", moderationActions)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_enabled", redisEnabled)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_up", redisUp)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writePrometheusMetricLine(w, "linearvoid_chat_redis_connected_clients", redisClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writePrometheusMetricLine(w, "linearvoid_chat_admin_sessions_active", countActiveAdminSessions())
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\"} 1")
}
// broadcast sends a message to all connected clients in a room and saves to DB // broadcast sends a message to all connected clients in a room and saves to DB
func broadcast(msg Message) { func broadcast(msg Message) {
mutex.Lock() mutex.Lock()
@@ -253,6 +1110,19 @@ func main() {
initDB() initDB()
http.HandleFunc("/ws", handleConnection) http.HandleFunc("/ws", handleConnection)
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/session", handleAdminSession)
http.HandleFunc("/api/admin/overview", requireAdminAuth(handleAdminOverview))
http.HandleFunc("/api/admin/playlist", requireAdminAuth(handleAdminPlaylist))
http.HandleFunc("/api/admin/moderation/logs", requireAdminAuth(handleAdminModerationLogs))
http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction))
http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics))
http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary))
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
http.HandleFunc("/metrics", handleMetrics)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
+62 -5
View File
@@ -143,6 +143,46 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
} }
func resetHLSOutput(hlsDir string) error {
if err := os.MkdirAll(hlsDir, 0o755); err != nil {
return err
}
matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
if err != nil {
return err
}
for _, match := range matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
if err != nil {
return err
}
for _, match := range fmp4Matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
return err
}
initPath := filepath.Join(hlsDir, "init.mp4")
if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func main() { func main() {
playlistPath := "/app/playlist/playlist.txt" playlistPath := "/app/playlist/playlist.txt"
if len(os.Args) > 1 { if len(os.Args) > 1 {
@@ -176,16 +216,29 @@ func main() {
// Run FFmpeg in a loop for continuous playback with R2 sync hook // Run FFmpeg in a loop for continuous playback with R2 sync hook
for { for {
fmt.Printf("[%s] Starting FFmpeg playout with -c copy (immutable segments for Cloudflare caching)...\n", time.Now().Format(time.RFC3339)) if err := resetHLSOutput("/app/hls"); err != nil {
fmt.Println("Failed to reset HLS output:", err)
time.Sleep(5 * time.Second)
continue
}
fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339))
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath, cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
"-c:v", "copy", "-c:a", "copy", "-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
"-f", "hls", "-f", "hls",
"-hls_time", "6", "-hls_time", "6",
"-hls_list_size", "24", "-hls_list_size", "24",
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4",
"-strftime", "1", "-strftime", "1",
"-hls_flags", "delete_segments+append_list+independent_segments", "-hls_flags", "delete_segments+independent_segments",
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.ts", "-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
"/app/hls/playlist.m3u8") "/app/hls/playlist.m3u8")
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@@ -203,7 +256,7 @@ func main() {
// syncLatestToR2 uploads new segments and the playlist manifest to R2 // syncLatestToR2 uploads new segments and the playlist manifest to R2
func syncLatestToR2(hlsDir string) error { func syncLatestToR2(hlsDir string) error {
files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts")) files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
if err != nil { if err != nil {
return err return err
} }
@@ -222,6 +275,10 @@ func syncLatestToR2(hlsDir string) error {
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil { if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
return err return err
} }
initPath := filepath.Join(hlsDir, "init.mp4")
if err := SyncToR2(initPath, "live/init.mp4"); err != nil {
return err
}
fmt.Println("Synced live playlist.m3u8 to R2") fmt.Println("Synced live playlist.m3u8 to R2")
return nil return nil
+14 -4
View File
@@ -26,17 +26,27 @@ fi
echo "Playlist validated successfully. Starting continuous HLS playout..." echo "Playlist validated successfully. Starting continuous HLS playout..."
mkdir -p /app/hls
rm -f /app/hls/playlist.m3u8 /app/hls/init.mp4 /app/hls/segment_*.ts /app/hls/segment_*.m4s
# Run FFmpeg in a loop for continuous playback # Run FFmpeg in a loop for continuous playback
while true; do while true; do
echo "[$(date)] Starting FFmpeg playout with -c copy (stream copy mode)..." echo "[$(date)] Starting FFmpeg playout with browser-safe HLS encoding..."
ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \ ffmpeg -re -f concat -safe 0 -i "$PLAYLIST_PATH" \
-c:v copy -c:a copy \ -map 0:v:0 -map 0:a:0? \
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p" \
-c:v libx264 -preset veryfast -pix_fmt yuv420p \
-profile:v high -level 4.0 \
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-c:a aac -ar 48000 -ac 2 -b:a 128k \
-f hls \ -f hls \
-hls_time "${HLS_SEGMENT_DURATION}" \ -hls_time "${HLS_SEGMENT_DURATION}" \
-hls_list_size "${HLS_WINDOW_SIZE}" \ -hls_list_size "${HLS_WINDOW_SIZE}" \
-hls_segment_type fmp4 \
-hls_fmp4_init_filename init.mp4 \
-strftime 1 \ -strftime 1 \
-hls_flags delete_segments+append_list \ -hls_flags delete_segments+independent_segments \
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.ts" \ -hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.m4s" \
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..." "/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
echo "Playout loop crashed or ended. Restarting in 5 seconds..." echo "Playout loop crashed or ended. Restarting in 5 seconds..."
+2
View File
@@ -30,6 +30,8 @@ services:
- "${CHAT_PORT:-3000}:3000" - "${CHAT_PORT:-3000}:3000"
volumes: volumes:
- ./data/chat:/app:rw - ./data/chat:/app:rw
- ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:rw
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
+2
View File
@@ -36,6 +36,8 @@ services:
- "${CHAT_PORT:-3000}:3000" - "${CHAT_PORT:-3000}:3000"
volumes: volumes:
- ./data/chat:/app:rw - ./data/chat:/app:rw
- ./data/playlist:/app/playlist:rw
- ./data/media:/app/media:rw
networks: networks:
- linearvoid-net - linearvoid-net
depends_on: depends_on:
+702 -32
View File
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="../src/style.css" /> <link rel="stylesheet" href="../src/style.css" />
</head> </head>
<body class="bg-slate-950 text-slate-200"> <body class="bg-slate-950 text-slate-200" data-admin-default-moderator="%VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR%" data-admin-default-room="%VITE_PUBLIC_ADMIN_DEFAULT_ROOM%" data-admin-moderation-log-limit="%VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT%">
<div class="flex h-screen" x-data="admin()"> <div class="flex h-screen" x-data="admin()">
<!-- Sidebar --> <!-- Sidebar -->
<div class="w-64 bg-slate-900 p-6"> <div class="w-64 bg-slate-900 p-6">
@@ -18,15 +18,18 @@
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div> <div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
<div class="text-xl font-bold">Admin</div> <div class="text-xl font-bold">Admin</div>
</div> </div>
<div class="mb-6">
<button @click="logout()" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 px-4 py-2 rounded-xl text-sm text-left">Sign out</button>
</div>
<nav class="space-y-2"> <nav class="space-y-2">
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a> <a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
<a href="#" @click="currentTab = 'upload'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a> <a href="#" @click="currentTab = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
<a href="#" @click="currentTab = 'playlist'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a> <a href="#" @click="currentTab = 'playlist'; refreshPlaylist()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
<a href="#" @click="currentTab = 'schedule'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a> <a href="#" @click="currentTab = 'schedule'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
<a href="#" @click="currentTab = 'moderation'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a> <a href="#" @click="currentTab = 'moderation'; refreshModerationLogs()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
<a href="#" @click="currentTab = 'users'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a> <a href="#" @click="currentTab = 'users'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
<a href="#" @click="currentTab = 'analytics'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a> <a href="#" @click="currentTab = 'analytics'; refreshAnalytics()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
<a href="#" @click="currentTab = 'system'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a> <a href="#" @click="currentTab = 'system'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
</nav> </nav>
</div> </div>
@@ -41,26 +44,272 @@
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div> <div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
</div> </div>
<div class="bg-slate-800 p-6 rounded-3xl"> <div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-blue-400 text-sm">Cache Hit Ratio</div> <div class="text-blue-400 text-sm">Stored Messages</div>
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div> <div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
</div> </div>
<div class="bg-slate-800 p-6 rounded-3xl"> <div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-amber-400 text-sm">R2 Usage</div> <div class="text-amber-400 text-sm">Moderation Actions</div>
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div> <div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
</div> </div>
</div> </div>
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
</div> </div>
</template> </template>
<template x-if="currentTab === 'upload'"> <template x-if="currentTab === 'upload'">
<div> <div>
<h1 class="text-3xl font-bold mb-8">Upload Video</h1> <div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Upload Video</h1>
<p class="text-sm text-slate-400 mt-2">Upload media files into the local media library mounted for playout.</p>
</div>
<button @click="refreshMediaLibrary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload library</button>
</div>
<div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center"> <div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center">
<input type="file" id="video-upload" class="hidden" accept="video/*" @change="uploadVideo"> <input type="file" id="video-upload" class="hidden" accept="video/*,.mp4,.mov,.mkv,.webm" @change="uploadVideo">
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium"> <button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium" :disabled="media.uploading">
Drag & Drop or Click to Upload Drag & Drop or Click to Upload
</button> </button>
<div id="upload-status" class="mt-6 text-sm"></div> <div class="mt-6 text-sm" :class="media.error ? 'text-rose-400' : 'text-slate-400'" x-text="media.status"></div>
</div>
<div class="bg-slate-800 rounded-3xl p-6 mt-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Media Library</h2>
<div class="text-xs text-slate-400" x-text="media.generatedAt"></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">File</th>
<th class="text-left py-3 pr-4 font-medium">Size</th>
<th class="text-left py-3 font-medium">Modified</th>
</tr>
</thead>
<tbody>
<template x-if="media.files.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
</tr>
</template>
<template x-for="file in media.files" :key="file.name">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="file.name"></td>
<td class="py-3 pr-4" x-text="file.size"></td>
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template x-if="currentTab === 'schedule'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Schedule</h1>
<p class="text-sm text-slate-400 mt-2">Read-only view of the currently configured playout order.</p>
</div>
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">#</th>
<th class="text-left py-3 pr-4 font-medium">Label</th>
<th class="text-left py-3 font-medium">Path</th>
</tr>
</thead>
<tbody>
<template x-if="schedule.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
</tr>
</template>
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.index"></td>
<td class="py-3 pr-4" x-text="entry.label"></td>
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="schedule.error ? 'text-rose-400' : 'text-slate-400'" x-text="schedule.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'playlist'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Playlist</h1>
<p class="text-sm text-slate-400 mt-2">Edit the active concat playlist used by the playout service.</p>
</div>
<div class="flex gap-3">
<button @click="refreshPlaylist()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload</button>
<button @click="savePlaylist()" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-xl text-sm font-medium" :disabled="playlist.saving">
<span x-text="playlist.saving ? 'Saving...' : 'Save playlist'"></span>
</button>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<textarea x-model="playlist.content" class="w-full min-h-[24rem] bg-slate-950 border border-slate-700 rounded-2xl p-4 font-mono text-sm focus:outline-none focus:border-blue-500" spellcheck="false"></textarea>
<div class="mt-4 text-sm" :class="playlist.error ? 'text-rose-400' : 'text-slate-400'" x-text="playlist.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'moderation'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Moderation</h1>
<p class="text-sm text-slate-400 mt-2">Create moderation actions and review the recent immutable audit log.</p>
</div>
<button @click="refreshModerationLogs()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload logs</button>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[24rem,1fr] gap-6">
<div class="bg-slate-800 rounded-3xl p-6">
<h2 class="text-lg font-semibold mb-4">New Action</h2>
<div class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Action</span>
<select x-model="moderation.form.action" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500">
<option value="mute">mute</option>
<option value="ban">ban</option>
<option value="delete">delete</option>
<option value="timeout">timeout</option>
</select>
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Target</span>
<input x-model="moderation.form.target" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="username or message id">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Moderator</span>
<input x-model="moderation.form.moderator" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="admin">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Room</span>
<input x-model="moderation.form.room" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="main">
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Reason</span>
<textarea x-model="moderation.form.reason" class="w-full min-h-28 bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="Explain why this action is needed"></textarea>
</label>
<button @click="submitModerationAction()" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium" :disabled="moderation.submitting">
<span x-text="moderation.submitting ? 'Submitting...' : 'Submit action'"></span>
</button>
<div class="text-sm" :class="moderation.error ? 'text-rose-400' : 'text-slate-400'" x-text="moderation.status"></div>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6 overflow-hidden">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Recent Audit Log</h2>
<div class="text-xs text-slate-400">Latest 50 actions</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">When</th>
<th class="text-left py-3 pr-4 font-medium">Action</th>
<th class="text-left py-3 pr-4 font-medium">Target</th>
<th class="text-left py-3 pr-4 font-medium">Moderator</th>
<th class="text-left py-3 font-medium">Reason</th>
</tr>
</thead>
<tbody>
<template x-if="moderation.logs.length === 0">
<tr>
<td colspan="5" class="py-6 text-slate-500">No moderation actions logged yet.</td>
</tr>
</template>
<template x-for="entry in moderation.logs" :key="entry.id">
<tr class="border-b border-slate-800 align-top">
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.timestamp"></td>
<td class="py-3 pr-4" x-text="entry.action"></td>
<td class="py-3 pr-4" x-text="entry.target"></td>
<td class="py-3 pr-4" x-text="entry.moderator"></td>
<td class="py-3" x-text="entry.reason"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<template x-if="currentTab === 'analytics'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
<p class="text-sm text-slate-400 mt-2">Basic activity counters from chat history and moderation audit logs.</p>
</div>
<button @click="refreshAnalytics()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload analytics</button>
</div>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Total Messages</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.totalMessages"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Last 24 Hours</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.messagesLast24Hours"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Distinct Rooms</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.distinctRooms"></div>
</div>
<div class="bg-slate-800 p-6 rounded-3xl">
<div class="text-slate-400 text-sm">Moderation Actions</div>
<div class="text-4xl font-bold mt-2" x-text="analytics.moderationActions"></div>
</div>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Top Rooms</h2>
<div class="text-xs text-slate-400" x-text="analytics.generatedAt"></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">Room</th>
<th class="text-left py-3 font-medium">Messages</th>
</tr>
</thead>
<tbody>
<template x-if="analytics.topRooms.length === 0">
<tr>
<td colspan="2" class="py-6 text-slate-500">No room analytics available yet.</td>
</tr>
</template>
<template x-for="room in analytics.topRooms" :key="room.room">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="room.room"></td>
<td class="py-3" x-text="room.messageCount"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="analytics.error ? 'text-rose-400' : 'text-slate-400'" x-text="analytics.status"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -75,15 +324,15 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span>Memory Used</span> <span>Memory Used</span>
<span class="font-mono">248 MB</span> <span class="font-mono" x-text="system.redisMemoryUsed"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Connected Clients</span> <span>Connected Redis Clients</span>
<span class="font-mono">12</span> <span class="font-mono" x-text="system.redisClients"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Rate Limit Threshold</span> <span>Redis Enabled</span>
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center"> <span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -91,41 +340,462 @@
<h3 class="font-medium mb-4">Storage</h3> <h3 class="font-medium mb-4">Storage</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span>SQLite Size</span> <span>Database Driver</span>
<span class="font-mono">142 MB</span> <span class="font-mono" x-text="system.databaseDriver"></span>
</div>
<div class="flex justify-between">
<span>Stored Messages</span>
<span class="font-mono" x-text="stats.storedMessages"></span>
</div>
<div class="flex justify-between">
<span>Last Refresh</span>
<span class="font-mono" x-text="system.generatedAt"></span>
</div>
<div class="flex justify-between">
<span>Playlist Path</span>
<span class="font-mono text-right" x-text="system.playlistPath"></span>
</div>
<div class="flex justify-between">
<span>Playlist Present</span>
<span class="font-mono" x-text="system.playlistExists ? 'yes' : 'no'"></span>
</div> </div>
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button> <button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-slate-800 p-8 rounded-3xl mt-6">
<div class="flex items-center justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Service Health</h2>
<button @click="refreshSystemSummary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload health</button>
</div>
<div class="space-y-4">
<template x-for="service in system.services" :key="service.name">
<div class="flex items-start justify-between gap-6 border-b border-slate-700 pb-4">
<div>
<div class="font-medium" x-text="service.name"></div>
<div class="text-xs text-slate-400 mt-1" x-text="service.checked"></div>
</div>
<div class="text-right max-w-xl">
<div class="uppercase tracking-wide text-xs" :class="service.status === 'healthy' ? 'text-emerald-400' : service.status === 'disabled' ? 'text-amber-400' : 'text-rose-400'" x-text="service.status"></div>
<div class="text-sm text-slate-300 mt-1 break-all" x-text="service.detail"></div>
</div>
</div>
</template>
</div>
<div class="mt-4 text-sm" :class="system.error ? 'text-rose-400' : 'text-slate-400'" x-text="system.status"></div>
</div>
</div>
</template>
<template x-if="currentTab === 'users'">
<div>
<div class="flex items-start justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-bold">Users</h1>
<p class="text-sm text-slate-400 mt-2">Current room presence derived from Redis presence keys.</p>
</div>
<button @click="refreshUsers()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload users</button>
</div>
<div class="bg-slate-800 rounded-3xl p-6">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 border-b border-slate-700">
<tr>
<th class="text-left py-3 pr-4 font-medium">Username</th>
<th class="text-left py-3 pr-4 font-medium">Room</th>
<th class="text-left py-3 font-medium">Online</th>
</tr>
</thead>
<tbody>
<template x-if="users.entries.length === 0">
<tr>
<td colspan="3" class="py-6 text-slate-500">No active users reported.</td>
</tr>
</template>
<template x-for="entry in users.entries" :key="entry.room + ':' + entry.username">
<tr class="border-b border-slate-800">
<td class="py-3 pr-4" x-text="entry.username"></td>
<td class="py-3 pr-4" x-text="entry.room"></td>
<td class="py-3" x-text="entry.online ? 'yes' : 'no'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 text-sm" :class="users.error ? 'text-rose-400' : 'text-slate-400'" x-text="users.status"></div>
</div>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<script> <script>
const adminConfig = (() => {
const { dataset } = document.body;
const moderationLogLimit = Number.parseInt(dataset.adminModerationLogLimit || '', 10);
return {
defaultModerator: dataset.adminDefaultModerator || 'admin',
defaultRoom: dataset.adminDefaultRoom || 'main',
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50
};
})();
function admin() { function admin() {
return { return {
currentTab: 'dashboard', currentTab: 'dashboard',
stats: { stats: {
viewers: 142, viewers: 0,
cacheHit: 97, storedMessages: 0,
r2Usage: '18.4 GB' moderationActions: 0
},
system: {
redisEnabled: false,
redisClients: 0,
redisMemoryUsed: 'loading...',
databaseDriver: 'loading...',
generatedAt: 'loading...',
playlistPath: 'loading...',
playlistExists: false,
services: [],
status: 'Loading system summary...',
error: false
},
analytics: {
totalMessages: 0,
messagesLast24Hours: 0,
distinctRooms: 0,
moderationActions: 0,
topRooms: [],
generatedAt: 'loading...',
status: 'Loading analytics...',
error: false
},
schedule: {
entries: [],
status: 'Loading schedule...',
error: false
},
playlist: {
content: '',
status: 'Loading playlist...',
saving: false,
error: false
},
media: {
files: [],
generatedAt: 'loading...',
uploading: false,
status: 'Loading media library...',
error: false
},
moderation: {
logs: [],
submitting: false,
error: false,
status: 'Loading moderation logs...',
form: {
action: 'mute',
target: '',
moderator: adminConfig.defaultModerator,
reason: '',
room: adminConfig.defaultRoom
}
},
users: {
entries: [],
status: 'Loading active users...',
error: false
},
statusMessage: 'Loading live admin overview...',
async refreshOverview() {
try {
const response = await fetch('/api/admin/overview');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const overview = await response.json();
this.stats.viewers = overview.viewers;
this.stats.storedMessages = overview.storedMessages;
this.stats.moderationActions = overview.moderationActions;
this.system.redisEnabled = overview.redisEnabled;
this.system.redisClients = overview.redisClients;
this.system.redisMemoryUsed = overview.redisMemoryUsed;
this.system.databaseDriver = overview.databaseDriver;
this.system.generatedAt = overview.generatedAt;
this.statusMessage = `Live data loaded at ${overview.generatedAt}`;
} catch (error) {
console.error('Failed to load admin overview', error);
this.statusMessage = 'Failed to load live admin data.';
this.system.redisMemoryUsed = 'unavailable';
this.system.databaseDriver = 'unavailable';
this.system.generatedAt = 'unavailable';
}
},
async refreshAnalytics() {
this.analytics.status = 'Loading analytics...';
this.analytics.error = false;
try {
const response = await fetch('/api/admin/analytics');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.analytics.totalMessages = payload.totalMessages;
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
this.analytics.distinctRooms = payload.distinctRooms;
this.analytics.moderationActions = payload.moderationActions;
this.analytics.topRooms = payload.topRooms || [];
this.analytics.generatedAt = payload.generatedAt;
this.analytics.status = 'Analytics loaded.';
} catch (error) {
console.error('Failed to load analytics', error);
this.analytics.status = 'Failed to load analytics.';
this.analytics.error = true;
}
},
async refreshPlaylist() {
this.playlist.status = 'Loading playlist...';
this.playlist.error = false;
try {
const response = await fetch('/api/admin/playlist');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.playlist.content = payload.content;
this.playlist.status = 'Playlist loaded.';
} catch (error) {
console.error('Failed to load playlist', error);
this.playlist.status = 'Failed to load playlist.';
this.playlist.error = true;
}
},
async refreshSchedule() {
this.schedule.status = 'Loading schedule...';
this.schedule.error = false;
try {
const response = await fetch('/api/admin/schedule');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.schedule.entries = payload.entries || [];
this.schedule.status = 'Schedule loaded.';
} catch (error) {
console.error('Failed to load schedule', error);
this.schedule.status = 'Failed to load schedule.';
this.schedule.error = true;
}
},
async refreshSystemSummary() {
this.system.status = 'Loading system summary...';
this.system.error = false;
try {
const response = await fetch('/api/admin/system');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.system.databaseDriver = payload.databaseDriver;
this.system.playlistPath = payload.playlistPath;
this.system.playlistExists = payload.playlistExists;
this.system.redisEnabled = payload.redisEnabled;
this.system.redisMemoryUsed = payload.redisMemoryUsed;
this.system.services = payload.services || [];
this.system.generatedAt = payload.generatedAt;
this.system.status = 'System summary loaded.';
} catch (error) {
console.error('Failed to load system summary', error);
this.system.status = 'Failed to load system summary.';
this.system.error = true;
}
},
async refreshMediaLibrary() {
this.media.status = 'Loading media library...';
this.media.error = false;
try {
const response = await fetch('/api/admin/media');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.media.files = payload.files || [];
this.media.generatedAt = payload.generatedAt;
this.media.status = 'Media library loaded.';
} catch (error) {
console.error('Failed to load media library', error);
this.media.status = 'Failed to load media library.';
this.media.error = true;
}
},
async savePlaylist() {
this.playlist.saving = true;
this.playlist.status = 'Saving playlist...';
this.playlist.error = false;
try {
const response = await fetch('/api/admin/playlist', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: this.playlist.content })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.playlist.content = payload.content;
this.playlist.status = 'Playlist saved.';
} catch (error) {
console.error('Failed to save playlist', error);
this.playlist.status = 'Failed to save playlist.';
this.playlist.error = true;
} finally {
this.playlist.saving = false;
}
},
async refreshModerationLogs() {
this.moderation.status = 'Loading moderation logs...';
this.moderation.error = false;
try {
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.moderation.logs = payload.logs || [];
this.moderation.status = 'Moderation logs loaded.';
} catch (error) {
console.error('Failed to load moderation logs', error);
this.moderation.status = 'Failed to load moderation logs.';
this.moderation.error = true;
}
},
async submitModerationAction() {
this.moderation.submitting = true;
this.moderation.error = false;
this.moderation.status = 'Submitting moderation action...';
try {
const response = await fetch('/api/admin/moderation/actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.moderation.form)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
this.moderation.status = 'Moderation action recorded.';
this.moderation.form.target = '';
this.moderation.form.reason = '';
await this.refreshModerationLogs();
await this.refreshOverview();
} catch (error) {
console.error('Failed to submit moderation action', error);
this.moderation.status = 'Failed to submit moderation action.';
this.moderation.error = true;
} finally {
this.moderation.submitting = false;
}
},
async refreshUsers() {
this.users.status = 'Loading active users...';
this.users.error = false;
try {
const response = await fetch('/api/admin/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
this.users.entries = payload.users || [];
this.users.status = payload.redisEnabled ? 'Active users loaded.' : 'Redis disabled; no live presence data available.';
} catch (error) {
console.error('Failed to load users', error);
this.users.status = 'Failed to load active users.';
this.users.error = true;
}
},
async logout() {
try {
await fetch('/api/admin/logout', { method: 'POST' });
} finally {
window.location.href = '/admin/login/';
}
}, },
init() { init() {
this.refreshOverview();
this.refreshAnalytics();
this.refreshMediaLibrary();
this.refreshPlaylist();
this.refreshSchedule();
this.refreshModerationLogs();
this.refreshSystemSummary();
this.refreshUsers();
console.log('Admin Interface initialized (ADR-004/ADR-005)'); console.log('Admin Interface initialized (ADR-004/ADR-005)');
} }
} }
} }
function uploadVideo(event) { async function uploadVideo(event) {
const file = event.target.files[0]; const file = event.target.files[0];
const status = document.getElementById('upload-status'); if (!file) {
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`; return;
setTimeout(() => { }
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
}, 1500); const component = Alpine.$data(document.querySelector('[x-data="admin()"]'));
component.media.uploading = true;
component.media.error = false;
component.media.status = `Uploading ${file.name}...`;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/admin/media', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
component.media.files = payload.files || [];
component.media.generatedAt = payload.generatedAt;
component.media.status = `${file.name} uploaded.`;
document.getElementById('video-upload').value = '';
} catch (error) {
console.error('Failed to upload media file', error);
component.media.status = 'Failed to upload media file.';
component.media.error = true;
} finally {
component.media.uploading = false;
}
} }
function runVacuum() { function runVacuum() {
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../../src/style.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
<div class="mb-8">
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
</div>
<form id="login-form" class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Username</span>
<input id="username" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="username" required />
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Password</span>
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required />
</label>
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
</form>
</main>
<script>
const form = document.getElementById('login-form');
const username = document.getElementById('username');
const password = document.getElementById('password');
const submit = document.getElementById('submit');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
submit.disabled = true;
status.textContent = 'Signing in...';
status.className = 'text-sm text-slate-400 min-h-6';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
window.location.href = '/admin/';
} catch (error) {
console.error('Failed to sign in', error);
status.textContent = 'Login failed. Check your credentials.';
status.className = 'text-sm text-rose-400 min-h-6';
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>
+2 -2
View File
@@ -11,7 +11,7 @@
</head> </head>
<body class="bg-slate-950 text-slate-200"> <body class="bg-slate-950 text-slate-200">
<div class="flex h-screen" x-data="app()"> <div class="flex h-screen">
<!-- Video Player --> <!-- Video Player -->
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900"> <div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
@@ -29,7 +29,7 @@
</div> </div>
<div class="flex-1 flex items-center justify-center p-8 bg-black relative"> <div class="flex-1 flex items-center justify-center p-8 bg-black relative">
<video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay></video> <video id="player" class="max-h-full rounded-xl shadow-2xl" controls autoplay muted playsinline></video>
<div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden"> <div id="now-playing-overlay" class="absolute bottom-8 left-8 bg-black/70 backdrop-blur-md px-6 py-3 rounded-2xl text-sm hidden">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="text-emerald-400">● LIVE</div> <div class="text-emerald-400">● LIVE</div>
+22 -2
View File
@@ -29,12 +29,32 @@ function resolveWebSocketBaseUrl() {
function resolveManifestUrl() { function resolveManifestUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
const cacheBust = `stream=${Date.now()}`;
const sameOriginManifestUrl = new URL(
"/hls/playlist.m3u8",
window.location.origin,
);
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
const allowCrossOriginManifest =
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
"true";
if (configuredUrl) { if (configuredUrl) {
return configuredUrl; const manifestUrl = new URL(configuredUrl);
manifestUrl.searchParams.set("stream", cacheBust);
if (
!allowCrossOriginManifest &&
manifestUrl.origin !== window.location.origin
) {
return sameOriginManifestUrl.toString();
}
return manifestUrl.toString();
} }
return new URL("/hls/playlist.m3u8", window.location.origin).toString(); return sameOriginManifestUrl.toString();
} }
function connectWebSocket(room: string) { function connectWebSocket(room: string) {
+3 -3
View File
@@ -1,4 +1,3 @@
import { resolve } from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
@@ -6,8 +5,9 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
main: resolve(__dirname, "index.html"), main: "index.html",
admin: resolve(__dirname, "admin/index.html"), admin: "admin/index.html",
adminLogin: "admin/login/index.html",
}, },
}, },
}, },
+118 -1
View File
@@ -31,7 +31,31 @@ http {
return 301 /admin/; return 301 /admin/;
} }
location = /admin/login {
return 301 /admin/login/;
}
location = /_admin_auth {
internal;
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream/api/admin/session;
proxy_http_version 1.1;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /admin/login/ {
try_files $uri $uri/ /admin/login/index.html;
}
location /admin/ { location /admin/ {
auth_request /_admin_auth;
error_page 401 = @admin_login_redirect;
try_files $uri $uri/ /admin/index.html; try_files $uri $uri/ /admin/index.html;
} }
@@ -57,10 +81,103 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location = /api/admin/login {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location = /api/admin/logout {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location = /api/admin/session {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location /api/admin/ {
auth_request /_admin_auth;
error_page 401 = @admin_api_unauthorized;
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location /api/ {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location = /metrics {
set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location @admin_api_unauthorized {
add_header Content-Type application/json;
return 401 '{"error":"unauthorized"}';
}
location @admin_login_redirect {
return 302 /admin/login/;
}
location = /hls/playlist.m3u8 {
alias /srv/hls/playlist.m3u8;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Access-Control-Allow-Origin "*";
}
location = /hls/init.mp4 {
alias /srv/hls/init.mp4;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
location ~* ^/hls/(.+\.(ts|m4s))$ {
alias /srv/hls/$1;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
location /hls/ { location /hls/ {
alias /srv/hls/; alias /srv/hls/;
add_header Cache-Control "public, max-age=3600"; add_header Cache-Control "public, max-age=60";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }