feat: add admin login page and authentication API
- 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:
1 parent
1ae0ad121b
commit
6b494a34fe
15 files changed
+1896
-52
No files matched your search
@@ -19,6 +19,12 @@ MEDIA_DOMAIN=media.linearvoid.tv
|
||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
||||
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:
|
||||
# VITE_PUBLIC_APP_URL=http://localhost
|
||||
# VITE_PUBLIC_WS_URL=ws://localhost/ws
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ grafana/
|
||||
# Agent / Tool files
|
||||
.github/instructions/TODO.md
|
||||
.github/instructions/DONE.md
|
||||
# (keep AGENTS.md and other instruction files)
|
||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -58,6 +58,12 @@ This guide covers prerequisites, environment setup, running the application loca
|
||||
VITE_PUBLIC_APP_URL=https://linearvoid.tv
|
||||
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
|
||||
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:
|
||||
# VITE_PUBLIC_APP_URL=http://localhost
|
||||
# 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**:
|
||||
- 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`
|
||||
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Further Reading
|
||||
|
||||
@@ -2,10 +2,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -26,6 +33,8 @@ var (
|
||||
}
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
mutex = sync.Mutex{}
|
||||
authSessions = map[string]time.Time{}
|
||||
authSessionsMu = sync.Mutex{}
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQLite per ADR-006)
|
||||
@@ -55,6 +64,112 @@ type Presence struct {
|
||||
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)
|
||||
func initRedis() {
|
||||
redisAddress := os.Getenv("REDIS_URL")
|
||||
@@ -110,10 +225,752 @@ func initDB() {
|
||||
log.Fatalf("Failed to connect to %s database: %v", driverName, err)
|
||||
}
|
||||
|
||||
databaseDriverName = driverName
|
||||
db.AutoMigrate(&Message{}, &ModerationLog{})
|
||||
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
|
||||
func broadcast(msg Message) {
|
||||
mutex.Lock()
|
||||
@@ -253,6 +1110,19 @@ func main() {
|
||||
initDB()
|
||||
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -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)+`"}`)
|
||||
}
|
||||
|
||||
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() {
|
||||
playlistPath := "/app/playlist/playlist.txt"
|
||||
if len(os.Args) > 1 {
|
||||
@@ -176,16 +216,29 @@ func main() {
|
||||
|
||||
// Run FFmpeg in a loop for continuous playback with R2 sync hook
|
||||
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,
|
||||
"-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",
|
||||
"-hls_time", "6",
|
||||
"-hls_list_size", "24",
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", "init.mp4",
|
||||
"-strftime", "1",
|
||||
"-hls_flags", "delete_segments+append_list+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.ts",
|
||||
"-hls_flags", "delete_segments+independent_segments",
|
||||
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s",
|
||||
"/app/hls/playlist.m3u8")
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
@@ -203,7 +256,7 @@ func main() {
|
||||
|
||||
// syncLatestToR2 uploads new segments and the playlist manifest to R2
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -222,6 +275,10 @@ func syncLatestToR2(hlsDir string) error {
|
||||
if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil {
|
||||
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")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -26,17 +26,27 @@ fi
|
||||
|
||||
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
|
||||
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" \
|
||||
-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 \
|
||||
-hls_time "${HLS_SEGMENT_DURATION}" \
|
||||
-hls_list_size "${HLS_WINDOW_SIZE}" \
|
||||
-hls_segment_type fmp4 \
|
||||
-hls_fmp4_init_filename init.mp4 \
|
||||
-strftime 1 \
|
||||
-hls_flags delete_segments+append_list \
|
||||
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.ts" \
|
||||
-hls_flags delete_segments+independent_segments \
|
||||
-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)..."
|
||||
|
||||
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
|
||||
@@ -36,6 +36,8 @@ services:
|
||||
- "${CHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data/chat:/app:rw
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:rw
|
||||
networks:
|
||||
- linearvoid-net
|
||||
depends_on:
|
||||
|
||||
+702
-32
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</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()">
|
||||
<!-- Sidebar -->
|
||||
<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="text-xl font-bold">Admin</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">
|
||||
<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 = 'playlist'" 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 = 'moderation'" 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 = 'analytics'" 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 = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</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'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</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'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</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'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -41,26 +44,272 @@
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-blue-400 text-sm">Cache Hit Ratio</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div>
|
||||
<div class="text-blue-400 text-sm">Stored Messages</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-amber-400 text-sm">R2 Usage</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div>
|
||||
<div class="text-amber-400 text-sm">Moderation Actions</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'upload'">
|
||||
<div>
|
||||
<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">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*" @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">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*,.mp4,.mov,.mkv,.webm" @change="uploadVideo">
|
||||
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium" :disabled="media.uploading">
|
||||
Drag & Drop or Click to Upload
|
||||
</button>
|
||||
<div 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>
|
||||
</template>
|
||||
@@ -75,15 +324,15 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>Memory Used</span>
|
||||
<span class="font-mono">248 MB</span>
|
||||
<span class="font-mono" x-text="system.redisMemoryUsed"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Connected Clients</span>
|
||||
<span class="font-mono">12</span>
|
||||
<span>Connected Redis Clients</span>
|
||||
<span class="font-mono" x-text="system.redisClients"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Rate Limit Threshold</span>
|
||||
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center">
|
||||
<span>Redis Enabled</span>
|
||||
<span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,41 +340,462 @@
|
||||
<h3 class="font-medium mb-4">Storage</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>SQLite Size</span>
|
||||
<span class="font-mono">142 MB</span>
|
||||
<span>Database Driver</span>
|
||||
<span class="font-mono" x-text="system.databaseDriver"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Stored Messages</span>
|
||||
<span class="font-mono" x-text="stats.storedMessages"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Last Refresh</span>
|
||||
<span class="font-mono" x-text="system.generatedAt"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Playlist Path</span>
|
||||
<span class="font-mono text-right" x-text="system.playlistPath"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Playlist Present</span>
|
||||
<span class="font-mono" x-text="system.playlistExists ? 'yes' : 'no'"></span>
|
||||
</div>
|
||||
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-8 rounded-3xl mt-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Service Health</h2>
|
||||
<button @click="refreshSystemSummary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload health</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<template x-for="service in system.services" :key="service.name">
|
||||
<div class="flex items-start justify-between gap-6 border-b border-slate-700 pb-4">
|
||||
<div>
|
||||
<div class="font-medium" x-text="service.name"></div>
|
||||
<div class="text-xs text-slate-400 mt-1" x-text="service.checked"></div>
|
||||
</div>
|
||||
<div class="text-right max-w-xl">
|
||||
<div class="uppercase tracking-wide text-xs" :class="service.status === 'healthy' ? 'text-emerald-400' : service.status === 'disabled' ? 'text-amber-400' : 'text-rose-400'" x-text="service.status"></div>
|
||||
<div class="text-sm text-slate-300 mt-1 break-all" x-text="service.detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="system.error ? 'text-rose-400' : 'text-slate-400'" x-text="system.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'users'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Users</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button @click="refreshUsers()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload users</button>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">Username</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Room</th>
|
||||
<th class="text-left py-3 font-medium">Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="users.entries.length === 0">
|
||||
<tr>
|
||||
<td colspan="3" class="py-6 text-slate-500">No active users reported.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in users.entries" :key="entry.room + ':' + entry.username">
|
||||
<tr class="border-b border-slate-800">
|
||||
<td class="py-3 pr-4" x-text="entry.username"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.room"></td>
|
||||
<td class="py-3" x-text="entry.online ? 'yes' : 'no'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="users.error ? 'text-rose-400' : 'text-slate-400'" x-text="users.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const adminConfig = (() => {
|
||||
const { dataset } = document.body;
|
||||
const moderationLogLimit = Number.parseInt(dataset.adminModerationLogLimit || '', 10);
|
||||
|
||||
return {
|
||||
defaultModerator: dataset.adminDefaultModerator || 'admin',
|
||||
defaultRoom: dataset.adminDefaultRoom || 'main',
|
||||
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50
|
||||
};
|
||||
})();
|
||||
|
||||
function admin() {
|
||||
return {
|
||||
currentTab: 'dashboard',
|
||||
stats: {
|
||||
viewers: 142,
|
||||
cacheHit: 97,
|
||||
r2Usage: '18.4 GB'
|
||||
viewers: 0,
|
||||
storedMessages: 0,
|
||||
moderationActions: 0
|
||||
},
|
||||
system: {
|
||||
redisEnabled: false,
|
||||
redisClients: 0,
|
||||
redisMemoryUsed: 'loading...',
|
||||
databaseDriver: 'loading...',
|
||||
generatedAt: 'loading...',
|
||||
playlistPath: 'loading...',
|
||||
playlistExists: false,
|
||||
services: [],
|
||||
status: 'Loading system summary...',
|
||||
error: false
|
||||
},
|
||||
analytics: {
|
||||
totalMessages: 0,
|
||||
messagesLast24Hours: 0,
|
||||
distinctRooms: 0,
|
||||
moderationActions: 0,
|
||||
topRooms: [],
|
||||
generatedAt: 'loading...',
|
||||
status: 'Loading analytics...',
|
||||
error: false
|
||||
},
|
||||
schedule: {
|
||||
entries: [],
|
||||
status: 'Loading schedule...',
|
||||
error: false
|
||||
},
|
||||
playlist: {
|
||||
content: '',
|
||||
status: 'Loading playlist...',
|
||||
saving: false,
|
||||
error: false
|
||||
},
|
||||
media: {
|
||||
files: [],
|
||||
generatedAt: 'loading...',
|
||||
uploading: false,
|
||||
status: 'Loading media library...',
|
||||
error: false
|
||||
},
|
||||
moderation: {
|
||||
logs: [],
|
||||
submitting: false,
|
||||
error: false,
|
||||
status: 'Loading moderation logs...',
|
||||
form: {
|
||||
action: 'mute',
|
||||
target: '',
|
||||
moderator: adminConfig.defaultModerator,
|
||||
reason: '',
|
||||
room: adminConfig.defaultRoom
|
||||
}
|
||||
},
|
||||
users: {
|
||||
entries: [],
|
||||
status: 'Loading active users...',
|
||||
error: false
|
||||
},
|
||||
statusMessage: 'Loading live admin overview...',
|
||||
async refreshOverview() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/overview');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const overview = await response.json();
|
||||
this.stats.viewers = overview.viewers;
|
||||
this.stats.storedMessages = overview.storedMessages;
|
||||
this.stats.moderationActions = overview.moderationActions;
|
||||
this.system.redisEnabled = overview.redisEnabled;
|
||||
this.system.redisClients = overview.redisClients;
|
||||
this.system.redisMemoryUsed = overview.redisMemoryUsed;
|
||||
this.system.databaseDriver = overview.databaseDriver;
|
||||
this.system.generatedAt = overview.generatedAt;
|
||||
this.statusMessage = `Live data loaded at ${overview.generatedAt}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin overview', error);
|
||||
this.statusMessage = 'Failed to load live admin data.';
|
||||
this.system.redisMemoryUsed = 'unavailable';
|
||||
this.system.databaseDriver = 'unavailable';
|
||||
this.system.generatedAt = 'unavailable';
|
||||
}
|
||||
},
|
||||
async refreshAnalytics() {
|
||||
this.analytics.status = 'Loading analytics...';
|
||||
this.analytics.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/analytics');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.analytics.totalMessages = payload.totalMessages;
|
||||
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
|
||||
this.analytics.distinctRooms = payload.distinctRooms;
|
||||
this.analytics.moderationActions = payload.moderationActions;
|
||||
this.analytics.topRooms = payload.topRooms || [];
|
||||
this.analytics.generatedAt = payload.generatedAt;
|
||||
this.analytics.status = 'Analytics loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics', error);
|
||||
this.analytics.status = 'Failed to load analytics.';
|
||||
this.analytics.error = true;
|
||||
}
|
||||
},
|
||||
async refreshPlaylist() {
|
||||
this.playlist.status = 'Loading playlist...';
|
||||
this.playlist.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/playlist');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.playlist.content = payload.content;
|
||||
this.playlist.status = 'Playlist loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist', error);
|
||||
this.playlist.status = 'Failed to load playlist.';
|
||||
this.playlist.error = true;
|
||||
}
|
||||
},
|
||||
async refreshSchedule() {
|
||||
this.schedule.status = 'Loading schedule...';
|
||||
this.schedule.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/schedule');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.schedule.entries = payload.entries || [];
|
||||
this.schedule.status = 'Schedule loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load schedule', error);
|
||||
this.schedule.status = 'Failed to load schedule.';
|
||||
this.schedule.error = true;
|
||||
}
|
||||
},
|
||||
async refreshSystemSummary() {
|
||||
this.system.status = 'Loading system summary...';
|
||||
this.system.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/system');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.system.databaseDriver = payload.databaseDriver;
|
||||
this.system.playlistPath = payload.playlistPath;
|
||||
this.system.playlistExists = payload.playlistExists;
|
||||
this.system.redisEnabled = payload.redisEnabled;
|
||||
this.system.redisMemoryUsed = payload.redisMemoryUsed;
|
||||
this.system.services = payload.services || [];
|
||||
this.system.generatedAt = payload.generatedAt;
|
||||
this.system.status = 'System summary loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load system summary', error);
|
||||
this.system.status = 'Failed to load system summary.';
|
||||
this.system.error = true;
|
||||
}
|
||||
},
|
||||
async refreshMediaLibrary() {
|
||||
this.media.status = 'Loading media library...';
|
||||
this.media.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/media');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.media.files = payload.files || [];
|
||||
this.media.generatedAt = payload.generatedAt;
|
||||
this.media.status = 'Media library loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load media library', error);
|
||||
this.media.status = 'Failed to load media library.';
|
||||
this.media.error = true;
|
||||
}
|
||||
},
|
||||
async savePlaylist() {
|
||||
this.playlist.saving = true;
|
||||
this.playlist.status = 'Saving playlist...';
|
||||
this.playlist.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/playlist', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content: this.playlist.content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.playlist.content = payload.content;
|
||||
this.playlist.status = 'Playlist saved.';
|
||||
} catch (error) {
|
||||
console.error('Failed to save playlist', error);
|
||||
this.playlist.status = 'Failed to save playlist.';
|
||||
this.playlist.error = true;
|
||||
} finally {
|
||||
this.playlist.saving = false;
|
||||
}
|
||||
},
|
||||
async refreshModerationLogs() {
|
||||
this.moderation.status = 'Loading moderation logs...';
|
||||
this.moderation.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.moderation.logs = payload.logs || [];
|
||||
this.moderation.status = 'Moderation logs loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load moderation logs', error);
|
||||
this.moderation.status = 'Failed to load moderation logs.';
|
||||
this.moderation.error = true;
|
||||
}
|
||||
},
|
||||
async submitModerationAction() {
|
||||
this.moderation.submitting = true;
|
||||
this.moderation.error = false;
|
||||
this.moderation.status = 'Submitting moderation action...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/moderation/actions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.moderation.form)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.moderation.status = 'Moderation action recorded.';
|
||||
this.moderation.form.target = '';
|
||||
this.moderation.form.reason = '';
|
||||
await this.refreshModerationLogs();
|
||||
await this.refreshOverview();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit moderation action', error);
|
||||
this.moderation.status = 'Failed to submit moderation action.';
|
||||
this.moderation.error = true;
|
||||
} finally {
|
||||
this.moderation.submitting = false;
|
||||
}
|
||||
},
|
||||
async refreshUsers() {
|
||||
this.users.status = 'Loading active users...';
|
||||
this.users.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.users.entries = payload.users || [];
|
||||
this.users.status = payload.redisEnabled ? 'Active users loaded.' : 'Redis disabled; no live presence data available.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load users', error);
|
||||
this.users.status = 'Failed to load active users.';
|
||||
this.users.error = true;
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/admin/logout', { method: 'POST' });
|
||||
} finally {
|
||||
window.location.href = '/admin/login/';
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.refreshOverview();
|
||||
this.refreshAnalytics();
|
||||
this.refreshMediaLibrary();
|
||||
this.refreshPlaylist();
|
||||
this.refreshSchedule();
|
||||
this.refreshModerationLogs();
|
||||
this.refreshSystemSummary();
|
||||
this.refreshUsers();
|
||||
console.log('Admin Interface initialized (ADR-004/ADR-005)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadVideo(event) {
|
||||
async function uploadVideo(event) {
|
||||
const file = event.target.files[0];
|
||||
const status = document.getElementById('upload-status');
|
||||
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`;
|
||||
setTimeout(() => {
|
||||
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
|
||||
}, 1500);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = Alpine.$data(document.querySelector('[x-data="admin()"]'));
|
||||
component.media.uploading = true;
|
||||
component.media.error = false;
|
||||
component.media.status = `Uploading ${file.name}...`;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
component.media.files = payload.files || [];
|
||||
component.media.generatedAt = payload.generatedAt;
|
||||
component.media.status = `${file.name} uploaded.`;
|
||||
document.getElementById('video-upload').value = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to upload media file', error);
|
||||
component.media.status = 'Failed to upload media file.';
|
||||
component.media.error = true;
|
||||
} finally {
|
||||
component.media.uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function runVacuum() {
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<div class="flex h-screen" x-data="app()">
|
||||
<div class="flex h-screen">
|
||||
<!-- Video Player -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
|
||||
<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 class="flex items-center gap-3">
|
||||
<div class="text-emerald-400">● LIVE</div>
|
||||
|
||||
+22
-2
@@ -29,12 +29,32 @@ function resolveWebSocketBaseUrl() {
|
||||
|
||||
function resolveManifestUrl() {
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -6,8 +5,9 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
admin: resolve(__dirname, "admin/index.html"),
|
||||
main: "index.html",
|
||||
admin: "admin/index.html",
|
||||
adminLogin: "admin/login/index.html",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -31,7 +31,31 @@ http {
|
||||
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/ {
|
||||
auth_request /_admin_auth;
|
||||
error_page 401 = @admin_login_redirect;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
@@ -57,10 +81,103 @@ http {
|
||||
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.
|
||||
location /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 "*";
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user