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_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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
</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
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user