9 Commits
Author SHA1 Message Date
zwitschi f73928103a fix: linting and go test
CD / deploy (push) Successful in 55s
CI / test (push) Successful in 1m32s
CI / docker (push) Successful in 2m51s
Release / release (push) Successful in 37s
2026-08-10 09:30:29 +02:00
zwitschi b427353672 test: remove unused playSpy variable in player startup test
CD / deploy (push) Successful in 53s
CI / test (push) Failing after 57s
CI / docker (push) Skipped
2026-08-10 00:29:41 +02:00
zwitschi f9aba11b90 feat: add metrics, observability, test fixtures, and scaling playbook artifacts
CD / deploy (push) Successful in 57s
CI / test (push) Failing after 1m29s
CI / docker (push) Skipped
2026-08-10 00:23:50 +02:00
zwitschi 926a663c23 Add comprehensive documentation for Linear Void project
- Created README.md in docs folder outlining project structure and documentation links.
- Added SECURITY.md detailing the security model, threat model, authentication, transport security, network isolation, and incident response procedures.
- Introduced SETUP.md providing a detailed setup guide for local and production environments, including prerequisites, environment setup, and Gitea integration.
- Added STREAMING.md outlining the streaming pipeline, including production flow, source-media contract, playout and HLS packaging, cache contract, player behavior, and monitoring.
- Updated architecture documentation to include links to new SETUP.md and STREAMING.md files for better navigation.
2026-08-08 14:52:19 +02:00
zwitschi 6b9830de05 feat: add comprehensive Streaming Pipeline documentation
CI / test (push) Successful in 1m45s
CI / docker (push) Successful in 23s
CD / deploy (push) Successful in 2m24s
2026-08-08 14:41:10 +02:00
zwitschi 914a518f20 fix: downgrade Go version to 1.25.0 in playout go.mod
CD / deploy (push) Successful in 45s
CI / test (push) Successful in 1m42s
CI / docker (push) Successful in 1m39s
2026-08-07 17:31:58 +02:00
zwitschi 8064e459f2 fix: playout Dockerfile use existing go.mod instead of init
CI / test (push) Successful in 1m43s
CI / docker (push) Failing after 24s
CD / deploy (push) Successful in 1m1s
2026-08-07 16:24:21 +02:00
zwitschi 587561f9fc fix: data race in PushChatHistory WebSocket writes
CD / deploy (push) Successful in 57s
CI / test (push) Successful in 1m34s
CI / docker (push) Failing after 25s
2026-08-07 16:20:14 +02:00
zwitschi 887023a491 fix: remove unused panel variable in main.test.ts
CD / deploy (push) Successful in 50s
CI / test (push) Failing after 1m38s
CI / docker (push) Skipped
2026-08-07 16:12:17 +02:00
44 changed files with 2049 additions and 145 deletions

No files matched your search

+4
View File
@@ -19,6 +19,10 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# Benchmark artifacts
artifacts/
scripts/loadtest/artifacts/
# Runtime data # Runtime data
pids pids
*.pid *.pid
+36
View File
@@ -2,6 +2,42 @@
<!-- markdownlint-disable MD024 --> <!-- markdownlint-disable MD024 -->
## [1.0.11] - 2026-08-10
### Added
- **Phase 1 test fixtures**: Media and playlist test fixtures in `backend/playout/testdata/` with ffmpeg generation commands documented in test headers.
- **Phase 4 R2 integration**: `SyncToR2` now sets `CacheControl` + `ContentType` based on file extension (immutable segments, short-lived manifests). Three integration tests added.
- **Phase 2 viewer tests**: Presence updates (join/leave/sorted), private DM flow, message rendering (system/self/other) in `main.test.ts` (27 total tests).
- **Phase 3 admin tests**: Playlist management (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
### Changed
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts` for testing.
## [1.0.10] - 2026-08-09
### Added
- **WebSocket summary helper**: Added `scripts/loadtest/summarize-artillery.ps1` to extract counters, rates, and available percentile stats from Artillery JSON output.
- **Playbook worksheet mapping**: Added artifact-to-worksheet mapping section in `docs/operations/scaling-performance-playbook.md` for WebSocket and Admin API benchmark artifacts.
- **Test fixture generation docs**: Added ffmpeg commands for generating valid and invalid media fixtures as comments in `backend/playout/src/playlist_test.go`. Phase 1 test fixtures now complete.
- **R2 cache header support**: `SyncToR2` now sets `CacheControl` and `ContentType` based on file extension:
- `.m4s`/`.ts`/`.mp4`/`.m4a` → immutable (max-age=31536000)
- `.m3u8` → short-lived (max-age=2s)
- unknown → no cache headers
- **R2 integration tests**: Added `TestSyncToR2_ManifestCacheHeaders` and `TestSyncToR2_NoCacheHeadersForUnknownExt` verifying cache header behavior for segment, manifest, and unknown file types.
### Changed
- **Loadtest runner**: `scripts/loadtest/run-loadtests.ps1` now generates `ws-chat-summary.txt` automatically for `ws` and `all` scenarios.
- **Loadtest docs**: `scripts/loadtest/README.md` now documents `summarize-artillery.ps1` usage and the new `ws-chat-summary.txt` artifact.
- **Planning status wording**: Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while benchmark execution remains pending.
- **Phase 1 complete**: Test fixture creation and ffmpeg documentation marked done.
- **Phase 4 partially complete**: R2 upload + cache header validation done; edge cache hit/miss validation remains manual.
- **Phase 2 frontend tests**: Added presence updates (join/leave rendering), private DM flow, and message rendering tests in `main.test.ts` (27 total tests passing).
- **Phase 3 admin frontend tests**: Added playlist management tests (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
## [1.0.9] - 2026-08-06 ## [1.0.9] - 2026-08-06
### Added ### Added
+7 -7
View File
@@ -7,7 +7,7 @@
[![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions) [![Build Status](https://git.allucanget.biz/zwitschi/linearvoid/actions/workflows/ci.yml/badge.svg)](https://git.allucanget.biz/zwitschi/linearvoid/actions)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz) [![Gitea](https://img.shields.io/badge/Gitea-1.27.1-blue)](https://git.allucanget.biz)
[![Version](https://img.shields.io/badge/version-1.0.10-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases) [![Version](https://img.shields.io/badge/version-1.0.11-blue)](https://git.allucanget.biz/zwitschi/linearvoid/releases)
[![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md) [![Release Notes](https://img.shields.io/badge/release-notes-green)](CHANGELOG.md)
## Architecture ## Architecture
@@ -206,11 +206,11 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features. - [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project. - [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting. - [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
- [API.md](API.md) - Implemented WebSocket and REST API reference. - [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide. - [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting. - [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline. - [SECURITY.md](docs/SECURITY.md) - Security controls, hardening checklist, and incident baseline.
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow. - [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions. - [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007). - [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
@@ -232,4 +232,4 @@ Since this is a linear project, we also rely on a linear development and release
[MIT License](LICENSE) [MIT License](LICENSE)
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions. The project is ready for use. See [`SETUP.md`](docs/SETUP.md) for detailed installation instructions.
+25 -25
View File
@@ -62,32 +62,32 @@ func main() {
// Register routes // Register routes
http.HandleFunc("/ws", hub.HandleConnection) http.HandleFunc("/ws", hub.HandleConnection)
http.HandleFunc("/api/admin/login", handlers.HandleAdminLogin(deps)) http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
http.HandleFunc("/api/admin/logout", handlers.HandleAdminLogout(deps)) http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
http.HandleFunc("/api/admin/session", handlers.HandleAdminSession(deps)) http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
http.HandleFunc("/api/admin/overview", requireAdmin(handlers.HandleAdminOverview(deps))) http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
http.HandleFunc("/api/admin/playlist", requireAdmin(handlers.HandleAdminPlaylist(deps))) http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
http.HandleFunc("/api/admin/moderation/logs", requireAdmin(handlers.HandleAdminModerationLogs(deps))) http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
http.HandleFunc("/api/admin/moderation/actions", requireAdmin(handlers.HandleAdminModerationAction(deps))) http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
http.HandleFunc("/api/admin/analytics", requireAdmin(handlers.HandleAdminAnalytics(deps))) http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
http.HandleFunc("/api/admin/system", requireAdmin(handlers.HandleAdminSystemSummary(deps))) http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
http.HandleFunc("/api/admin/config", requireAdmin(handlers.HandleAdminConfig(deps))) http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
http.HandleFunc("/api/admin/restart", requireAdmin(handlers.HandleAdminRestart(deps))) http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
http.HandleFunc("/api/admin/users", requireAdmin(handlers.HandleAdminUsers(deps))) http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps))))
http.HandleFunc("/api/admin/schedule", requireAdmin(handlers.HandleAdminSchedule(deps))) http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
http.HandleFunc("/api/admin/schedule/overrides", requireAdmin(handlers.HandleAdminScheduleOverrides(deps))) http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
http.HandleFunc("/api/schedule", handlers.HandlePublicSchedule(deps)) http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
http.HandleFunc("/api/admin/media", requireAdmin(handlers.HandleAdminMedia(deps))) http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
http.HandleFunc("/api/admin/media/uploads", requireAdmin(handlers.HandleAdminMediaUploads(deps))) http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
http.HandleFunc("/api/admin/media/uploads/", requireAdmin(handlers.HandleAdminMediaUploads(deps))) http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
http.HandleFunc("/api/admin/media/", requireAdmin(handlers.HandleAdminMediaByID(deps))) http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
http.HandleFunc("/api/admin/chat-log/bulk", requireAdmin(handlers.HandleAdminChatLogBulk(deps))) http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
http.HandleFunc("/api/admin/chat-log/export", requireAdmin(handlers.HandleAdminChatLogExport(deps))) http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
http.HandleFunc("/api/admin/chat-log/", requireAdmin(handlers.HandleAdminChatLogByID(deps))) http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
http.HandleFunc("/api/admin/chat-log", requireAdmin(handlers.HandleAdminChatLog(deps))) http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID)) http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
http.HandleFunc("/health", metrics.HandleHealth()) http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
http.HandleFunc("/", metrics.HandleNotFound()) http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
port := config.ChatPort() port := config.ChatPort()
log.Printf("Chat service starting on :%s", port) log.Printf("Chat service starting on :%s", port)
+150
View File
@@ -3,9 +3,11 @@ package metrics
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -15,6 +17,62 @@ import (
"chat/src/ws" "chat/src/ws"
) )
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
type requestDurationHistogram struct {
mu sync.Mutex
buckets []float64
counts []uint64
sum float64
total uint64
}
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
return &requestDurationHistogram{
buckets: append([]float64(nil), buckets...),
counts: make([]uint64, len(buckets)+1),
}
}
func (h *requestDurationHistogram) observe(durationSeconds float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.total++
h.sum += durationSeconds
for index, bucket := range h.buckets {
if durationSeconds <= bucket {
h.counts[index]++
return
}
}
h.counts[len(h.counts)-1]++
}
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
h.mu.Lock()
defer h.mu.Unlock()
counts = append([]uint64(nil), h.counts...)
return counts, h.sum, h.total
}
// ObserveRequestDuration records a chat HTTP request duration in seconds.
func ObserveRequestDuration(duration time.Duration) {
chatRequestDurationHistogram.observe(duration.Seconds())
}
// WrapRequestTiming records request duration for the provided handler.
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
next(w, r)
}
}
// HandleMetrics returns Prometheus-format metrics for the chat service. // HandleMetrics returns Prometheus-format metrics for the chat service.
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc { func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -36,6 +94,9 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
redisEnabled := 0 redisEnabled := 0
redisUp := 0 redisUp := 0
redisClients := 0 redisClients := 0
redisMemoryUsedBytes := int64(0)
redisMemoryMaxBytes := int64(0)
redisMemoryUsageRatio := 0.0
if redis != nil { if redis != nil {
redisEnabled = 1 redisEnabled = 1
if err := redis.Ping(context.Background()); err == nil { if err := redis.Ping(context.Background()); err == nil {
@@ -50,6 +111,19 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
} }
} }
} }
if info, err := redis.Info(context.Background(), "memory"); err == nil {
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
redisMemoryUsedBytes = value
}
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
redisMemoryMaxBytes = value
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
redisMemoryMaxBytes = value
}
if redisMemoryMaxBytes > 0 {
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
}
}
} }
} }
@@ -99,6 +173,18 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writeLine("linearvoid_chat_redis_connected_clients", redisClients) writeLine("linearvoid_chat_redis_connected_clients", redisClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.") fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writeLine("linearvoid_chat_admin_sessions_active", activeSessions) writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
@@ -106,6 +192,19 @@ func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionSt
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.") 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, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID) fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
counts, sum, total := chatRequestDurationHistogram.snapshot()
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
cumulative := uint64(0)
for index, bucket := range requestDurationBuckets {
cumulative += counts[index]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
}
cumulative += counts[len(counts)-1]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
} }
} }
@@ -125,4 +224,55 @@ func HandleNotFound() http.HandlerFunc {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`) fmt.Fprint(w, `{"error":"not found"}`)
} }
}
func parseRedisMemoryBytes(info, key string) int64 {
for _, line := range strings.Split(info, "\n") {
if !strings.HasPrefix(line, key+":") {
continue
}
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
if value == "" {
return 0
}
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
return raw
}
return parseHumanBytes(value)
}
return 0
}
func parseHumanBytes(value string) int64 {
value = strings.TrimSpace(strings.ToUpper(value))
if value == "" {
return 0
}
multiplier := float64(1)
suffixes := map[string]float64{
"B": 1,
"K": 1024,
"KB": 1024,
"M": 1024 * 1024,
"MB": 1024 * 1024,
"G": 1024 * 1024 * 1024,
"GB": 1024 * 1024 * 1024,
"T": 1024 * 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024,
}
for suffix, factor := range suffixes {
if strings.HasSuffix(value, suffix) {
multiplier = factor
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
break
}
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return int64(parsed * multiplier)
} }
+27 -1
View File
@@ -54,6 +54,10 @@ func TestHandleNotFound(t *testing.T) {
} }
func TestHandleMetrics(t *testing.T) { func TestHandleMetrics(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(100 * time.Millisecond)
ObserveRequestDuration(750 * time.Millisecond)
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("failed to open test DB: %v", err) t.Fatalf("failed to open test DB: %v", err)
@@ -89,8 +93,12 @@ func TestHandleMetrics(t *testing.T) {
"linearvoid_chat_redis_enabled", "linearvoid_chat_redis_enabled",
"linearvoid_chat_redis_up", "linearvoid_chat_redis_up",
"linearvoid_chat_redis_connected_clients", "linearvoid_chat_redis_connected_clients",
"linearvoid_chat_redis_memory_used_bytes",
"linearvoid_chat_redis_memory_max_bytes",
"linearvoid_chat_redis_memory_usage_ratio",
"linearvoid_chat_admin_sessions_active", "linearvoid_chat_admin_sessions_active",
"linearvoid_chat_build_info", "linearvoid_chat_build_info",
"linearvoid_chat_request_duration_seconds",
} }
for _, metric := range requiredMetrics { for _, metric := range requiredMetrics {
@@ -117,6 +125,12 @@ func TestHandleMetrics(t *testing.T) {
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") { if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
t.Error("redis_enabled should be 0 (nil redis)") t.Error("redis_enabled should be 0 (nil redis)")
} }
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
t.Error("request duration histogram should include both observed samples by the 1s bucket")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
t.Error("request duration count should be 2")
}
} }
func TestHandleMetrics_WrongMethod(t *testing.T) { func TestHandleMetrics_WrongMethod(t *testing.T) {
@@ -132,6 +146,9 @@ func TestHandleMetrics_WrongMethod(t *testing.T) {
} }
func TestHandleMetrics_WithRedis(t *testing.T) { func TestHandleMetrics_WithRedis(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(250 * time.Millisecond)
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
@@ -160,6 +177,15 @@ func TestHandleMetrics_WithRedis(t *testing.T) {
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") { if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
t.Error("redis_connected_clients should be 5") t.Error("redis_connected_clients should be 5")
} }
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
t.Error("redis_memory_used_bytes should be 1048576")
}
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
t.Error("request duration count should be 1")
}
} }
// metricsMockRedis is a minimal mock for metrics tests. // metricsMockRedis is a minimal mock for metrics tests.
@@ -185,6 +211,6 @@ func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string
return nil, 0, nil return nil, 0, nil
} }
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) { func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:5\nused_memory_human:1M\n", nil return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
} }
func (m *metricsMockRedis) Close() error { return nil } func (m *metricsMockRedis) Close() error { return nil }
+2
View File
@@ -105,9 +105,11 @@ func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
opp := len(messages) - 1 - i opp := len(messages) - 1 - i
messages[i], messages[opp] = messages[opp], messages[i] messages[i], messages[opp] = messages[opp], messages[i]
} }
h.mu.Lock()
for _, msg := range messages { for _, msg := range messages {
conn.WriteJSON(msg) conn.WriteJSON(msg)
} }
h.mu.Unlock()
} }
// HandleConnection handles a new WebSocket connection. // HandleConnection handles a new WebSocket connection.
+3 -4
View File
@@ -2,11 +2,10 @@ FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
COPY src/ ./src/ COPY src/ ./src/
COPY src/go.mod src/go.sum ./
# Initialize Go module if not present # Download dependencies and build
RUN go mod init playout || true RUN go mod download
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
FROM alpine:3.20 FROM alpine:3.20
+1 -1
View File
@@ -1,6 +1,6 @@
module playout module playout
go 1.26.5 go 1.25.0
require ( require (
github.com/aws/aws-sdk-go-v2 v1.43.4 github.com/aws/aws-sdk-go-v2 v1.43.4
+36 -6
View File
@@ -28,6 +28,9 @@ type VideoFile struct {
var ( var (
r2Client *s3.Client r2Client *s3.Client
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
return client.PutObject(context.TODO(), input)
}
bucket = os.Getenv("R2_BUCKET_NAME") bucket = os.Getenv("R2_BUCKET_NAME")
playoutMode = "starting" // "live", "slate", "degraded" playoutMode = "starting" // "live", "slate", "degraded"
restartCount = 0 restartCount = 0
@@ -39,6 +42,9 @@ var (
const ( const (
maxRestartsPerMinute = 5 maxRestartsPerMinute = 5
slateManifestName = "playlist.m3u8" slateManifestName = "playlist.m3u8"
// Cache-Control header values for R2 uploads.
segmentCacheControl = "public, max-age=31536000, immutable"
manifestCacheControl = "public, max-age=2"
) )
// initR2Client initializes the R2 (S3-compatible) client // initR2Client initializes the R2 (S3-compatible) client
@@ -203,7 +209,7 @@ func validateKeyframeInterval(videoPath string) error {
"-v", "quiet", "-v", "quiet",
"-select_streams", "v:0", "-select_streams", "v:0",
"-show_frames", "-show_frames",
"-show_entries", "frame=key_frame,pkt_pts_time", "-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
"-of", "csv=p=0", "-of", "csv=p=0",
videoPath) videoPath)
output, err := cmd.Output() output, err := cmd.Output()
@@ -223,8 +229,16 @@ func validateKeyframeInterval(videoPath string) error {
if len(parts) < 2 || parts[0] != "1" { if len(parts) < 2 || parts[0] != "1" {
continue continue
} }
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) var pts float64
if err != nil { var found bool
for _, raw := range parts[1:] {
if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
pts = value
found = true
break
}
}
if !found {
continue continue
} }
keyframes = append(keyframes, kfEntry{pts}) keyframes = append(keyframes, kfEntry{pts})
@@ -284,7 +298,11 @@ func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
return videos, nil return videos, nil
} }
// SyncToR2 uploads a file to R2 and returns the key // SyncToR2 uploads a file to R2 and returns the key.
// Cache-Control is set automatically based on file extension:
// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
// - .m3u8 -> short-lived (2s, live manifest)
// - everything else -> no cache header
func SyncToR2(localPath, r2Key string) error { func SyncToR2(localPath, r2Key string) error {
if r2Client == nil { if r2Client == nil {
if err := initR2Client(); err != nil { if err := initR2Client(); err != nil {
@@ -298,11 +316,23 @@ func SyncToR2(localPath, r2Key string) error {
} }
defer file.Close() defer file.Close()
_, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{ input := &s3.PutObjectInput{
Bucket: aws.String(bucket), Bucket: aws.String(bucket),
Key: aws.String(r2Key), Key: aws.String(r2Key),
Body: file, Body: file,
}) }
ext := strings.ToLower(filepath.Ext(r2Key))
switch ext {
case ".m4s", ".ts", ".mp4", ".m4a":
input.CacheControl = aws.String(segmentCacheControl)
input.ContentType = aws.String("video/MP2T")
case ".m3u8":
input.CacheControl = aws.String(manifestCacheControl)
input.ContentType = aws.String("application/vnd.apple.mpegurl")
}
_, err = putObject(r2Client, input)
return err return err
} }
+245 -40
View File
@@ -1,65 +1,97 @@
package main package main
// Test fixture generation commands (run from repository root):
//
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// backend/playout/testdata/valid_720p30.mp4
//
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
//
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// backend/playout/testdata/invalid_24fps.mp4
//
// Test playlist fixtures are plain text files with paths relative to /app/media.
// See backend/playout/testdata/*.txt.
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
) )
func TestLoadPlaylist(t *testing.T) { func ensureFFProbeInPath(t *testing.T) {
tmpDir := t.TempDir() t.Helper()
if _, err := exec.LookPath("ffprobe"); err == nil {
return
}
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
if _, err := os.Stat(windowsFFProbe); err == nil {
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
return
}
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
}
func TestLoadPlaylist(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string file string
wantLen int wantLen int
wantErr bool wantErr bool
first string first string
}{ }{
{ {
name: "simple paths", name: "valid playlist",
content: "/videos/video1.mp4\n/videos/video2.mp4\n", file: "valid_playlist.txt",
wantLen: 2, wantLen: 2,
first: "/videos/video1.mp4", first: "/app/media/demo1.mp4",
}, },
{ {
name: "with file prefix", name: "empty playlist",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n", file: "empty_playlist.txt",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix double quotes",
content: "file \"/videos/video1.mp4\"\n",
wantLen: 1,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "empty file",
content: "",
wantLen: 0, wantLen: 0,
}, },
{ {
name: "only comments", name: "comments only",
content: "# comment\n# another\n", file: "comments_only.txt",
wantLen: 0, wantLen: 0,
}, },
{
name: "mixed valid invalid",
file: "mixed_valid_invalid.txt",
wantLen: 3,
first: "/app/media/demo1.mp4",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(tmpDir, tt.name+".txt") path := filepath.Join("..", "testdata", tt.file)
os.WriteFile(path, []byte(tt.content), 0644)
videos, err := LoadPlaylist(path) videos, err := LoadPlaylist(path)
if tt.wantErr && err == nil { if tt.wantErr && err == nil {
@@ -149,10 +181,60 @@ func TestValidateKeyframeInterval(t *testing.T) {
} }
func TestValidateVideoStrict(t *testing.T) { func TestValidateVideoStrict(t *testing.T) {
// ValidateVideoStrict calls ValidateVideo which calls ffprobe ensureFFProbeInPath(t)
// Test only the result structure when ValidateVideo returns valid=false
// Mock not feasible without full ffprobe mock, skip for now tests := []struct {
t.Skip("requires ffprobe mock or test media fixtures") name string
file string
wantValid bool
wantErr string
}{
{
name: "valid h264 fixture",
file: "valid_720p30.mp4",
wantValid: true,
},
{
name: "invalid h265 fixture",
file: "invalid_h265.mp4",
wantErr: "video codec must be h264",
},
{
name: "invalid framerate fixture",
file: "invalid_24fps.mp4",
wantErr: "framerate must be 30/1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join("..", "testdata", tt.file)
result := ValidateVideoStrict(path)
valid, _ := result["valid"].(bool)
if valid != tt.wantValid {
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
}
errs, _ := result["errors"].([]string)
if tt.wantErr == "" {
if len(errs) != 0 {
t.Fatalf("errors = %v, want none", errs)
}
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
}
return
}
if len(errs) == 0 {
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
}
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
}
})
}
} }
func TestHealthHandler(t *testing.T) { func TestHealthHandler(t *testing.T) {
@@ -348,6 +430,129 @@ func TestValidatePlaylist(t *testing.T) {
} }
func TestSyncToR2(t *testing.T) { func TestSyncToR2(t *testing.T) {
// Requires R2/S3 mock prevClient := r2Client
t.Skip("requires S3 client mock") prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "segment.m4s")
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotBucket, gotKey, gotCacheControl, gotContentType string
var gotBody []byte
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotBucket = aws.ToString(input.Bucket)
gotKey = aws.ToString(input.Key)
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
body, err := io.ReadAll(input.Body)
if err != nil {
return nil, err
}
gotBody = body
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotBucket != "linearvoid-media" {
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
}
if gotKey != "live/segment.m4s" {
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
}
if string(gotBody) != "segment-bytes" {
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
}
if gotCacheControl != segmentCacheControl {
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
}
if gotContentType != "video/MP2T" {
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
}
}
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "playlist.m3u8")
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotCacheControl, gotContentType string
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotCacheControl != manifestCacheControl {
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
}
if gotContentType != "application/vnd.apple.mpegurl" {
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
}
}
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "data.bin")
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotCacheControl, gotContentType string
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotCacheControl != "" {
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
}
if gotContentType != "" {
t.Fatalf("ContentType = %q, want empty", gotContentType)
}
} }
+2
View File
@@ -0,0 +1,2 @@
# Linear Void test playlist
# No media entries here
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
# Valid entries should still parse
file '/app/media/demo1.mp4'
# Invalid or malformed lines are preserved as literal paths by the parser
not-a-file-entry
file '/app/media/demo2.mp4'
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
View File
File renamed without changes.
+15 -1
View File
@@ -8,6 +8,7 @@
- [Production Target (Hetzner)](#production-target-hetzner) - [Production Target (Hetzner)](#production-target-hetzner)
- [Planned Infrastructure](#planned-infrastructure) - [Planned Infrastructure](#planned-infrastructure)
- [Cutover Plan](#cutover-plan) - [Cutover Plan](#cutover-plan)
- [Production Preflight](#production-preflight)
- [Firewall / Security Group](#firewall--security-group) - [Firewall / Security Group](#firewall--security-group)
- [Pipeline Readiness](#pipeline-readiness) - [Pipeline Readiness](#pipeline-readiness)
- [Target Topology](#target-topology) - [Target Topology](#target-topology)
@@ -64,6 +65,19 @@ Linear Void runs in two separate environments:
6. Enable HTTPS, WAF, cache rules on production zone 6. Enable HTTPS, WAF, cache rules on production zone
7. Decommission test lab as public target 7. Decommission test lab as public target
### Production Preflight
Before the cutover in the plan above, treat the following as the minimum readiness gate:
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`.
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
6. Verify rollback is still possible before the final DNS switch.
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
### Firewall / Security Group ### Firewall / Security Group
| Port | Source | Purpose | | Port | Source | Purpose |
@@ -135,7 +149,7 @@ See .env.example and SETUP.md for full variable reference.
## Pull-and-Run Deployment ## Pull-and-Run Deployment
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure). Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release. Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
+23 -21
View File
@@ -1,51 +1,53 @@
# DEVELOPMENT.md — Linear Void Remaining Tasks # DEVELOPMENT.md — Linear Void Remaining Tasks
> Auto-generated 2026-08-07. All completed items moved to `.github/instructions/DONE.md`. ## Test Fixtures (COMPLETE)
## Test Fixtures (BLOCKED — requires ffmpeg) - [x] Create test media fixtures in `backend/playout/testdata/`:
- [ ] Create test media fixtures in `backend/playout/testdata/`:
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file - `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
- `invalid_h265.mp4` — wrong codec - `invalid_h265.mp4` — wrong codec
- `invalid_24fps.mp4` — wrong framerate - `invalid_24fps.mp4` — wrong framerate
- Generate via `ffmpeg` commands documented in test file headers - Generate via `ffmpeg` commands documented in test file headers
- Windows dev path: `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffmpeg.exe`
- [ ] Create test playlist fixtures in `backend/playout/testdata/`: - [x] Create test playlist fixtures in `backend/playout/testdata/`:
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt` - `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
## User Frontend Tests - [x] Document ffmpeg fixture-generation commands in `playlist_test.go` header
- [ ] Video player behavior (play, pause, seek, buffering, error handling) ## User Frontend Tests (PARTIALLY COMPLETE)
- [ ] Chat functionality (send/receive messages, presence, rate limiting, moderation)
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
- [ ] State management and data flow - [ ] State management and data flow
- [ ] Responsive design and layout across different screen sizes - [ ] Responsive design and layout across different screen sizes
- [ ] Accessibility testing (keyboard navigation, screen reader support) - [ ] Accessibility testing (keyboard navigation, screen reader support)
- [ ] Integration with backend APIs (mocking API responses for testing) - [x] Integration with backend APIs (mocking API responses for testing)
- [ ] Test coverage for all critical frontend components and interactions - [ ] Test coverage for all critical frontend components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
## Admin Frontend Tests ## Admin Frontend Tests (PARTIALLY COMPLETE)
- [ ] Playlist management UI (add/remove videos, reorder, normalize) - [x] Playlist management UI (add/remove videos, reorder, normalize) - save/error handling, sortable rendering
- [ ] User moderation tools (mute/ban/report users, view moderation logs) - [ ] User moderation tools (mute/ban/report users, view moderation logs)
- [ ] Metrics and monitoring dashboards (Prometheus/Grafana integration) - [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) - overview + analytics rendering
- [ ] State management and data flow - [ ] State management and data flow
- [ ] Responsive design and layout across different screen sizes - [ ] Responsive design and layout across different screen sizes
- [ ] Accessibility testing (keyboard navigation, screen reader support) - [ ] Accessibility testing (keyboard navigation, screen reader support)
- [ ] Integration with backend APIs (mocking API responses for testing) - [x] Integration with backend APIs (mocking API responses for testing)
- [ ] Test coverage for all critical admin UI components and interactions - [ ] Test coverage for all critical admin UI components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
## Cloudflare R2 Integration Test ## Cloudflare R2 Integration Test (PARTIALLY COMPLETE)
- [ ] Upload test HLS segments and manifests to R2 bucket - [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
- [ ] Verify correct cache headers are applied (immutable segments, TTLs) - [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge - [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
- [ ] Validate cache hit/miss behavior and performance metrics - [ ] Validate cache hit/miss behavior and performance metrics
- [ ] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API - [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
- [ ] Document test cases and expected outcomes - [x] Document test cases and expected outcomes (TestSyncToR2 + TestSyncToR2_ManifestCacheHeaders + TestSyncToR2_NoCacheHeadersForUnknownExt)
- [x] Document required stubs or mocks (putObject variable injection pattern)
## Cloudflare Configuration (manual — Cloudflare dashboard) ## Cloudflare Configuration (manual — Cloudflare dashboard)
@@ -85,9 +87,9 @@
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration - [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline - [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors - [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors (scaffolding complete; benchmark execution pending)
- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries - [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries (scaffolding complete; benchmark execution pending)
- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90% - [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90%
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min - [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min, Redis memory >85%/10min
- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources - [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication - [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
File renamed without changes.
+41
View File
@@ -0,0 +1,41 @@
# Docs Folder
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
## Architecture
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
## API
- [API](API.md) - Implemented WebSocket and REST API reference.
## Deployment
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
## Monitoring
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
## Security
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
## Setup
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
## Streaming
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
## Development
- [Development](DEVELOPMENT.md) - Remaining tasks and test fixtures for development.
## Additional Resources
- [Marketing](marketing/README.md) - Marketing materials and resources.
- [Operations](operations/README.md) - Operational procedures and checklists.
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
View File
File renamed without changes.
View File
File renamed without changes.
+186
View File
@@ -0,0 +1,186 @@
# Streaming Pipeline
This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
## Production flow
```mermaid
flowchart LR
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
B --> C["Playout service and FFmpeg"]
C --> D["Local HLS staging"]
D --> E["R2 object storage"]
E --> F["Cloudflare edge"]
F --> G["hls.js or native HLS"]
```
The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare:
```text
https://media.linearvoid.tv/hls/playlist.m3u8
```
Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
## Source-media contract
Only validated files may enter the production playlist.
| Property | Required profile |
| ---------- | ------------------------------------------------------------------------ |
| Container | MP4 |
| Video | H.264/AVC, `yuv420p` |
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
| Frame rate | Constant 30 fps |
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
Reference normalization:
```bash
ffmpeg -i input.mov \
-c:v libx264 -preset medium -crf 21 \
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
-pix_fmt yuv420p \
-c:a aac -b:a 160k -ar 48000 -ac 2 \
-movflags +faststart \
output-normalized.mp4
```
## Playlist and timeline
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
- The service validates the complete playlist before activating a revision.
- Playlist edits are written atomically and receive a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
- All viewers follow the same live manifest; clients do not calculate the schedule independently.
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
## Playout and HLS packaging
The playout service wraps FFmpeg, tracks the active item and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
Baseline packaging:
| Setting | Value |
| ---------------- | ------------------------------------------------------------------------------------- |
| Protocol | Standard live HLS, not LL-HLS |
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
| Expected latency | 15–35 seconds |
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
```text
hls/r-20260807T170412Z/init-000001.mp4
hls/r-20260807T170412Z/seg-000001234.m4s
```
This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
## Safe publication to R2
Publication order prevents a manifest from referencing an unavailable segment:
1. FFmpeg writes a segment to a temporary local name and closes it.
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
3. It verifies successful upload and records object size/checksum.
4. It renders the next media manifest referencing only confirmed objects.
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
Never upload an actively written segment or publish the manifest first. Use a single publisher instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
## Cache contract
| Objects | Edge policy | Browser policy | Required headers/behavior |
| -------------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
| Live `.m3u8` | Cache eligible; edge TTL 2 seconds | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
Further rules:
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
- Purge manifests when necessary, but never purge immutable segments during normal operation.
- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
Expected validation:
```bash
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
```
The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
## Player behavior
The SPA uses native HLS where supported and hls.js elsewhere.
- Start at the live edge with a conservative buffer; do not seek to playlist start.
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
- Recover transient network/media errors with bounded retries and exponential backoff.
- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
- Surface a clear reconnecting state. Never silently loop stale media.
- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
## Failure and recovery
The service retries an item failure with a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
On process or host restart:
- create a new run/session ID and continue with globally unique segment paths;
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
- never overwrite old segment objects;
- keep the last valid manifest available until the replacement stream is playable;
- emit a discontinuity when timeline continuity cannot be guaranteed.
Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
## Monitoring and acceptance checks
Monitor and alert on:
- manifest age and media-sequence advancement;
- time since last successfully published segment;
- FFmpeg restarts and consecutive item failures;
- live/slate mode;
- R2 upload latency and errors;
- segment 404/5xx rate and manifest/segment request latency;
- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
- local staging disk usage.
A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
## Required configuration
| Variable | Purpose |
| ------------------------------------------ | -------------------------------------------------- |
| `PLAYLIST_PATH` | Active playlist file |
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
| `R2_BUCKET_NAME` | Media bucket |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
| `MEDIA_DOMAIN` | Public R2 custom domain |
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
Production values are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
## Explicitly deferred
Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
+6 -4
View File
@@ -87,10 +87,12 @@
## Companion Documentation ## Companion Documentation
- [API.md](../../API.md) - Implemented WebSocket and REST API reference. - [API.md](../API.md) - Implemented WebSocket and REST API reference.
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook. - [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting. - [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline. - [SECURITY.md](../SECURITY.md) - Security model, controls, and hardening baseline.
- [SETUP.md](../SETUP.md) - Test lab setup, local development, and CI/CD workflow.
- [STREAMING.md](../STREAMING.md) - Streaming architecture, playout, and delivery details.
## Introduction and Goals ## Introduction and Goals
@@ -2,6 +2,14 @@
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows. Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
Recommended execution order:
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
4. Verify monitor targets and alert delivery in UptimeRobot.
5. Capture evidence and fill in the sign-off block.
## Status Legend ## Status Legend
- [ ] Pending - [ ] Pending
@@ -18,6 +26,7 @@ Evidence to capture:
- Screenshot of Pages deployment settings - Screenshot of Pages deployment settings
- Deployment URL and latest deployment timestamp - Deployment URL and latest deployment timestamp
- Confirmed production build output path in the Pages project
## 2) Tiered Cache Enablement and Validation ## 2) Tiered Cache Enablement and Validation
@@ -37,6 +46,7 @@ Evidence to capture:
- Headers from first request (expected MISS) - Headers from first request (expected MISS)
- Headers from repeated request (expected HIT) - Headers from repeated request (expected HIT)
- Screenshot or export showing Smart Tiered Cache enabled
## 3) WAF and Bot Protection ## 3) WAF and Bot Protection
@@ -50,6 +60,7 @@ Evidence to capture:
- WAF ruleset status screenshot - WAF ruleset status screenshot
- Count of blocked/challenged events after cutover - Count of blocked/challenged events after cutover
- Note on any false positives and the final allow/exception rules applied
## 4) Rate Limiting Rules ## 4) Rate Limiting Rules
@@ -67,6 +78,7 @@ Evidence to capture:
- Rule definitions screenshot - Rule definitions screenshot
- Test result notes (challenge seen / no lockout) - Test result notes (challenge seen / no lockout)
- Keep a copy of the test source IP and timestamps used during validation
## 5) HTTPS and WSS Hardening ## 5) HTTPS and WSS Hardening
@@ -89,6 +101,7 @@ Evidence to capture:
- Response headers showing HSTS - Response headers showing HSTS
- Successful browser WebSocket connect in devtools - Successful browser WebSocket connect in devtools
- TLS mode screenshot from Cloudflare showing Full (strict)
## 6) UptimeRobot Monitors ## 6) UptimeRobot Monitors
@@ -101,6 +114,7 @@ Evidence to capture:
- Monitor dashboard screenshot - Monitor dashboard screenshot
- Test alert delivery confirmation - Test alert delivery confirmation
- List of recipients/webhook destinations verified during the alert test
## 7) Sign-Off ## 7) Sign-Off
@@ -109,6 +123,15 @@ Evidence to capture:
- [ ] TODO items updated to done - [ ] TODO items updated to done
- [ ] Changelog entry added for completion batch - [ ] Changelog entry added for completion batch
Completion summary:
- Pages build URL:
- Cache validation result:
- WAF/rate-limit status:
- HTTPS/HSTS status:
- UptimeRobot monitor status:
- Evidence folder or note link:
Sign-off fields: Sign-off fields:
- Owner: - Owner:
+242 -3
View File
@@ -52,6 +52,38 @@ Acceptance:
- Warm-cache median < 4s in all tested regions. - Warm-cache median < 4s in all tested regions.
- Outliers documented with likely cause. - Outliers documented with likely cause.
### TTFB Baseline Worksheet
Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
- US East (Virginia)
- Date/time:
- Test URL: <https://linearvoid.tv>
- WebPageTest result URL:
- Median TTFB:
- p95 TTFB:
- Notes:
- US Central
- Date/time:
- Test URL: <https://linearvoid.tv>
- WebPageTest result URL:
- Median TTFB:
- p95 TTFB:
- Notes:
- US West (California/Oregon)
- Date/time:
- Test URL: <https://linearvoid.tv>
- WebPageTest result URL:
- Median TTFB:
- p95 TTFB:
- Notes:
Suggested capture notes:
- Use repeat view for the warm-cache measurement.
- Keep the same browser profile and test URL across regions.
- Note any CDN cache-rule changes or deploys that occurred between samples.
## 3) WebSocket Load-Test Plan ## 3) WebSocket Load-Test Plan
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients. Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
@@ -80,6 +112,26 @@ Acceptance:
- p95 chat latency <= 1s at target concurrency tier. - p95 chat latency <= 1s at target concurrency tier.
- error rate <= 1% during steady state. - error rate <= 1% during steady state.
### WebSocket Load-Test Worksheet
- Date/time:
- Environment:
- Commit SHA / image tag:
- Test URL:
- Tool used:
- Scenario duration:
- Concurrency tiers reached:
- p50 message latency:
- p95 message latency:
- Error rate:
- Dropped connections:
- CPU/memory notes for chat:
- CPU/memory notes for redis:
- CPU/memory notes for nginx:
- Raw results location:
- Conclusion:
- Next action:
## 4) Admin API Benchmark Plan ## 4) Admin API Benchmark Plan
Goal: measure admin API p95 and identify slow endpoints. Goal: measure admin API p95 and identify slow endpoints.
@@ -108,6 +160,26 @@ Acceptance:
- p95 < 1s for primary admin endpoints. - p95 < 1s for primary admin endpoints.
- slow endpoint list with remediation notes. - slow endpoint list with remediation notes.
### Admin API Benchmark Worksheet
- Date/time:
- Environment:
- Commit SHA / image tag:
- Test URL:
- Tool used:
- Scenario duration:
- Concurrency / virtual users:
- `/api/admin/overview` p95:
- `/api/admin/analytics` p95:
- `/api/admin/chat-log` p95:
- `/api/admin/users` p95:
- Error rate:
- Slowest endpoint:
- Cache or DB notes:
- Raw results location:
- Conclusion:
- Next action:
## 5) Cache Hit Ratio Tracking Plan ## 5) Cache Hit Ratio Tracking Plan
Goal: track Cloudflare cache hit ratio and alert when below threshold. Goal: track Cloudflare cache hit ratio and alert when below threshold.
@@ -119,10 +191,34 @@ Implementation outline:
3. Add Grafana panel with 5m/1h/24h windows. 3. Add Grafana panel with 5m/1h/24h windows.
4. Alert when hit ratio < 90% for 15m. 4. Alert when hit ratio < 90% for 15m.
Reference exporter:
- `scripts/cache-hit-ratio.js`
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
Metric target: Metric target:
- `cache_hit_ratio >= 0.90` - `cache_hit_ratio >= 0.90`
### Cache Hit Ratio Worksheet
- Date/time:
- Environment:
- Commit SHA / image tag:
- Hostname:
- Window minutes:
- Cloudflare zone tag:
- Export command:
- Raw exporter output location:
- Hit requests:
- Total requests:
- Cache hit ratio:
- Threshold met:
- Notes:
## 6) Capacity Trigger Rules ## 6) Capacity Trigger Rules
Define operational triggers: Define operational triggers:
@@ -130,7 +226,7 @@ Define operational triggers:
- Cache miss ratio > 10% for 15m - Cache miss ratio > 10% for 15m
- Chat p95 latency > 1s for 5m - Chat p95 latency > 1s for 5m
- Host CPU > 80% for 10m - Host CPU > 80% for 10m
- Redis memory > 85% sustained - Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
Action mapping: Action mapping:
@@ -138,6 +234,22 @@ Action mapping:
- Level 2: scale service resources (CPU/RAM) - Level 2: scale service resources (CPU/RAM)
- Level 3: activate standby planning path - Level 3: activate standby planning path
### Capacity Trigger Review
- Date/time:
- Environment:
- Commit SHA / image tag:
- Trigger hit:
- Threshold value:
- Observed value:
- Duration above threshold:
- Level selected:
- Immediate action taken:
- Services involved:
- Follow-up owner:
- Raw metrics / alert link:
- Notes:
## 7) Phase 2 Service Separation Plan ## 7) Phase 2 Service Separation Plan
Objective: separate bottlenecked services while keeping simple ops. Objective: separate bottlenecked services while keeping simple ops.
@@ -160,6 +272,24 @@ Exit criteria for Phase 2:
- repeated trigger events over 7 days - repeated trigger events over 7 days
- capacity saturation despite tuning - capacity saturation despite tuning
### Phase 2 Boundary Worksheet
- Date/time:
- Environment:
- Commit SHA / image tag:
- Trigger history summary:
- Repeated trigger count over 7 days:
- Primary bottleneck:
- Proposed split:
- `playout` target host size:
- `chat` target host size:
- `db` target host size:
- Monitoring separation needed:
- Data/state migration notes:
- Implementation owner:
- Review decision:
- Next action:
## 8) Phase 3 Warm Standby Plan ## 8) Phase 3 Warm Standby Plan
Objective: second-region resilience with DNS failover. Objective: second-region resilience with DNS failover.
@@ -178,8 +308,24 @@ Design:
Failover drill requirements: Failover drill requirements:
- periodic simulated cutover ### Phase 3 Warm Standby Worksheet
- measured RTO/RPO logged in incident records
- Date/time:
- Environment:
- Commit SHA / image tag:
- Primary region:
- Standby region:
- DNS failover mode:
- Redis strategy:
- Postgres strategy:
- Health check endpoints:
- Simulated cutover performed:
- Measured RTO:
- Measured RPO:
- Open risks:
- Implementation owner:
- Review decision:
- Notes:
## 9) Data Capture Template ## 9) Data Capture Template
@@ -201,3 +347,96 @@ For each benchmark run, capture:
5. Trigger tuning 5. Trigger tuning
6. Phase 2 boundary decision 6. Phase 2 boundary decision
7. Phase 3 standby design review 7. Phase 3 standby design review
## 11) Benchmark Run Sheet
Use the same worksheet for every baseline or re-test so results stay comparable.
Preconditions:
- Confirm the target environment and commit SHA/image tag.
- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
- Confirm the chat, admin, and playout services are healthy before load testing.
Run template:
| Item | Value |
| -------------------------- | ----- |
| Date/time | |
| Environment | |
| Commit SHA / image tag | |
| Test scenario | |
| Concurrency / sample count | |
| Command or tool | |
| Raw results location | |
| Conclusion | |
| Next action | |
Suggested command mapping:
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
## 12) Scaling Decision Gate
Treat the following as the default review gate after any benchmark batch:
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
3. If CPU, memory, or Redis saturation appears first, treat the service boundary guidance in Phase 2 as the next action.
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
Decision note:
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
## 13) Artifact-to-Worksheet Field Mapping
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
### WebSocket worksheet mapping
Artifact sources:
- `artifacts/<timestamp>/ws-chat-report.json`
- `artifacts/<timestamp>/ws-chat-summary.txt`
Field mapping:
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
- Worksheet `Raw results location` -> artifact folder path for this run
- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `median=...`
Notes:
- The exact `summary.*` metric names in Artillery output can vary by engine/plugins.
- If no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
### Admin API worksheet mapping
Artifact sources:
- `artifacts/<timestamp>/admin-api-summary.json`
- `artifacts/<timestamp>/admin-api-output.txt`
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
Field mapping:
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
- Worksheet `Raw results location` -> artifact folder path for this run
- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
- Worksheet `Slowest endpoint` -> endpoint row with highest `P95Ms`
Notes:
- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.10", "version": "1.0.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+134 -3
View File
@@ -1,16 +1,18 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
// Mock fetch globally // Mock fetch globally
let fetchCalls: { url: string; options?: RequestInit }[] = []; let fetchCalls: { url: string; options?: RequestInit }[] = [];
let mockFetchResponse: any = { ok: true, json: () => Promise.resolve({}) }; let mockFetchResponse: any = { ok: true, payload: {} };
beforeEach(() => { beforeEach(() => {
fetchCalls = []; fetchCalls = [];
globalThis.fetch = vi.fn((url: string, options?: RequestInit) => { globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
const payload = mockFetchResponse.payload ?? {};
return Promise.resolve( return Promise.resolve(
new Response(JSON.stringify(mockFetchResponse.json ? mockFetchResponse.json() : mockFetchResponse), { new Response(JSON.stringify(payload), {
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500, status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}), }),
@@ -140,3 +142,132 @@ describe("API calls on tab load", () => {
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
describe("admin data refresh", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="overview-status"></div>
<span id="stat-viewers"></span>
<span id="stat-stored-messages"></span>
<span id="stat-moderation-actions"></span>
<span id="stat-media-runtime"></span>
<span id="stat-media-count"></span>
<span id="system-stored-messages"></span>
<span id="system-redis-clients"></span>
<span id="system-redis-enabled"></span>
<span id="system-redis-memory"></span>
<span id="system-db-driver"></span>
<span id="system-generated-at"></span>
<div id="analytics-status"></div>
<span id="analytics-total-messages"></span>
<span id="analytics-messages-24h"></span>
<span id="analytics-distinct-rooms"></span>
<span id="analytics-moderation-actions"></span>
<span id="analytics-generated-at"></span>
<table><tbody id="analytics-table-body"></tbody></table>
`;
});
it("renders overview stats from the admin overview API", async () => {
mockFetchResponse = {
ok: true,
payload: {
viewers: 12,
storedMessages: 345,
moderationActions: 9,
mediaTotalRuntime: "01:23:45",
mediaCount: 7,
redisClients: 4,
redisEnabled: true,
redisMemoryUsed: "18 MB",
databaseDriver: "sqlite",
generatedAt: "2026-08-08T12:00:00Z",
},
};
await refreshOverview();
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
});
it("renders analytics rows from the analytics API", async () => {
mockFetchResponse = {
ok: true,
payload: {
totalMessages: 1000,
messagesLast24Hours: 75,
distinctRooms: 3,
moderationActions: 11,
generatedAt: "2026-08-08T12:30:00Z",
topRooms: [
{ room: "main", messageCount: 55 },
{ room: "lobby", messageCount: 20 },
],
},
};
await refreshAnalytics();
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
});
});
describe("playlist management", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="playlist-status"></div>
<textarea id="playlist-content">file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
</textarea>
<ul id="playlist-sortable"></ul>
<div id="playlist-warnings" hidden></div>
`;
});
it("saves playlist via PUT request", async () => {
mockFetchResponse = { ok: true, payload: {} };
await savePlaylist();
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
expect(putCall).toBeDefined();
expect(putCall?.url).toBe("/api/admin/playlist");
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(putCall?.options?.body as string);
expect(body.content).toContain("demo1.mp4");
});
it("sets error status on save failure", async () => {
mockFetchResponse = { ok: false, status: 500, payload: {} };
await savePlaylist();
const status = document.getElementById("playlist-status");
expect(status?.textContent).toContain("Failed");
});
it("renders sortable playlist with indexes and paths", () => {
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(2);
expect(list.innerHTML).toContain("demo1.mp4");
expect(list.innerHTML).toContain("demo2.mp4");
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
expect(list.querySelector("[data-remove]")).toBeTruthy();
});
it("skips comment and blank lines in sortable", () => {
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(1);
expect(list.innerHTML).toContain("demo1.mp4");
});
});
+4 -4
View File
@@ -172,7 +172,7 @@ async function switchTab(tabName: string) {
if (tabName === "system") refreshSystemSummary(); if (tabName === "system") refreshSystemSummary();
} }
async function refreshOverview() { export async function refreshOverview() {
try { try {
const response = await fetch("/api/admin/overview"); const response = await fetch("/api/admin/overview");
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -198,7 +198,7 @@ async function refreshOverview() {
} }
} }
async function refreshAnalytics() { export async function refreshAnalytics() {
setStatus("analytics-status", "Loading analytics..."); setStatus("analytics-status", "Loading analytics...");
try { try {
const response = await fetch("/api/admin/analytics"); const response = await fetch("/api/admin/analytics");
@@ -255,7 +255,7 @@ async function refreshPlaylist() {
} }
} }
function renderPlaylistSortable(content: string) { export function renderPlaylistSortable(content: string) {
const list = document.getElementById("playlist-sortable"); const list = document.getElementById("playlist-sortable");
if (!list) return; if (!list) return;
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
@@ -352,7 +352,7 @@ async function showPlaylistWarnings(content: string) {
} }
} }
async function savePlaylist() { export async function savePlaylist() {
if (state.playlistSaving) return; if (state.playlistSaving) return;
state.playlistSaving = true; state.playlistSaving = true;
setStatus("playlist-status", "Saving playlist..."); setStatus("playlist-status", "Saving playlist...");
+194 -17
View File
@@ -1,18 +1,29 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
const hlsInstance = {
loadSource: vi.fn(),
attachMedia: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};
const HlsMock = Object.assign(
vi.fn().mockImplementation(() => hlsInstance),
{
isSupported: vi.fn(() => true),
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
},
);
// Mock hls.js // Mock hls.js
vi.mock("hls.js", () => ({ vi.mock("hls.js", () => ({
default: vi.fn().mockImplementation(() => ({ default: HlsMock,
loadSource: vi.fn(),
attachMedia: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
})),
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
})); }));
// Mock WebSocket // Mock WebSocket
const websocketInstances: MockWebSocket[] = [];
class MockWebSocket { class MockWebSocket {
url: string; url: string;
readyState = 0; readyState = 0;
@@ -23,7 +34,8 @@ class MockWebSocket {
close = vi.fn(); close = vi.fn();
constructor(url: string) { constructor(url: string) {
this.url = url; this.url = String(url);
websocketInstances.push(this);
} }
} }
@@ -41,6 +53,8 @@ beforeEach(() => {
origWebSocket = (globalThis as any).WebSocket; origWebSocket = (globalThis as any).WebSocket;
origFetch = globalThis.fetch; origFetch = globalThis.fetch;
(globalThis as any).WebSocket = MockWebSocket; (globalThis as any).WebSocket = MockWebSocket;
websocketInstances.length = 0;
resetViewerStateForTests();
// Mock fetch (schedule, etc.) — return empty schedule // Mock fetch (schedule, etc.) — return empty schedule
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
@@ -82,20 +96,47 @@ beforeEach(() => {
// Stub prompt for DM // Stub prompt for DM
(globalThis as any).prompt = vi.fn(); (globalThis as any).prompt = vi.fn();
vi.useRealTimers();
}); });
afterEach(() => { afterEach(() => {
(globalThis as any).WebSocket = origWebSocket; (globalThis as any).WebSocket = origWebSocket;
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
// Must import after DOM setup describe("player startup", () => {
import "../src/main"; it("starts native HLS playback on loadedmetadata", async () => {
const video = document.getElementById("player") as HTMLVideoElement;
vi.spyOn(video, "canPlayType").mockReturnValue("probably");
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
await import("../src/main").then((mod) => mod.initPlayerAndChat());
expect(video.src).toContain("/hls/playlist.m3u8?stream=");
video.dispatchEvent(new Event("loadedmetadata"));
expect(playSpy).toHaveBeenCalled();
});
it("starts hls.js playback when native HLS is unavailable", async () => {
const video = document.getElementById("player") as HTMLVideoElement;
Object.defineProperty(video, "canPlayType", {
value: vi.fn(() => ""),
configurable: true,
});
vi.spyOn(video, "play").mockResolvedValue(undefined as any);
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
expect(HlsMock.isSupported).toHaveBeenCalled();
expect(HlsMock).toHaveBeenCalled();
});
});
describe("chat toggle", () => { describe("chat toggle", () => {
it("toggles chat panel visibility", () => { it("toggles chat panel visibility", () => {
const panel = document.getElementById("chat-panel")!;
const toggle = document.getElementById("chat-toggle")!; const toggle = document.getElementById("chat-toggle")!;
expect(toggle.textContent).toBe("Hide Chat"); expect(toggle.textContent).toBe("Hide Chat");
@@ -113,13 +154,7 @@ describe("sendMessage", () => {
const input = document.getElementById("message-input") as HTMLInputElement; const input = document.getElementById("message-input") as HTMLInputElement;
input.value = "hello!"; input.value = "hello!";
// sendMessage requires ws to be non-null — ws set in initPlayerAndChat
// The DOMContentLoaded triggers initPlayerAndChat which creates ws
// Wait for async init, then set ws manually
// For now just verify input state logic
window.sendMessage(); window.sendMessage();
// ws is null at this point, so message won't send but input won't clear either
// Verify input not cleared (ws check fails before clear)
expect(input.value).toBe("hello!"); expect(input.value).toBe("hello!");
}); });
@@ -159,3 +194,145 @@ describe("togglePrivateDM", () => {
switchRoomSpy.mockRestore(); switchRoomSpy.mockRestore();
}); });
}); });
describe("WebSocket reconnect", () => {
it("reconnects after onclose", async () => {
vi.useFakeTimers();
window.switchRoom("lobby");
expect(websocketInstances).toHaveLength(1);
websocketInstances[0].onclose?.();
await vi.advanceTimersByTimeAsync(3000);
expect(websocketInstances).toHaveLength(2);
expect(websocketInstances[1].url).toContain("room=lobby");
});
});
describe("URL resolution", () => {
it("falls back to same-origin manifest and websocket URLs", () => {
vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
vi.stubEnv("VITE_PUBLIC_WS_URL", "");
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
Object.defineProperty(window, "location", {
value: new URL("https://linearvoid.tv/viewer"),
writable: true,
});
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
});
it("preserves an allowed cross-origin manifest URL", () => {
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
});
it("falls back to same-origin manifest when cross-origin is not allowed", () => {
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
});
it("uses configured websocket URL when present", () => {
vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
});
});
describe("presence updates", () => {
it("adds user to presence list on join system message", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
const presenceList = document.getElementById("presence-list")!;
expect(presenceList.textContent).toContain("alice");
});
it("removes user from presence list on leave system message", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
const presenceList = document.getElementById("presence-list")!;
expect(presenceList.textContent).not.toContain("alice");
});
it("renders multiple users sorted", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
const presenceList = document.getElementById("presence-list")!;
const text = presenceList.textContent ?? "";
const aliceIdx = text.indexOf("alice");
const zoeIdx = text.indexOf("zoe");
expect(aliceIdx).toBeLessThan(zoeIdx);
});
});
describe("private DM flow", () => {
it("creates dm: room name from sorted usernames", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
vi.spyOn(window, "prompt").mockReturnValue("bob");
window.togglePrivateDM();
const roomName = document.getElementById("room-name")!;
// username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
// switchRoom capitalizes first char: "Dm:bob-viewer Room"
expect(roomName.textContent).toContain("bob-viewer");
});
});
describe("message rendering", () => {
it("renders system messages", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
const messages = document.getElementById("messages")!;
expect(messages.innerHTML).toContain("Server restarting");
expect(messages.querySelector(".message-system")).toBeTruthy();
});
it("renders self messages as message-self", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({
data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
});
const messages = document.getElementById("messages")!;
expect(messages.innerHTML).toContain("hello world");
expect(messages.querySelector(".message-self")).toBeTruthy();
});
it("renders other-user messages as message-other", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({
data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
});
const messages = document.getElementById("messages")!;
expect(messages.innerHTML).toContain("alice");
expect(messages.innerHTML).toContain("hey");
expect(messages.querySelector(".message-other")).toBeTruthy();
});
});
+13 -4
View File
@@ -18,7 +18,16 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true; let chatVisible = true;
const onlineUsers = new Set<string>(); const onlineUsers = new Set<string>();
function resolveWebSocketBaseUrl() { export function resetViewerStateForTests() {
hls = null;
ws = null;
currentRoom = "main";
username = "viewer";
chatVisible = true;
onlineUsers.clear();
}
export function resolveWebSocketBaseUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
if (configuredUrl) { if (configuredUrl) {
@@ -29,7 +38,7 @@ function resolveWebSocketBaseUrl() {
return `${protocol}//${window.location.host}/ws`; return `${protocol}//${window.location.host}/ws`;
} }
function resolveManifestUrl() { export 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 cacheBust = `stream=${Date.now()}`;
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin); const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
@@ -139,7 +148,7 @@ async function refreshSchedule() {
} }
} }
async function initPlayerAndChat() { export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
const video = document.getElementById("player") as HTMLVideoElement; const video = document.getElementById("player") as HTMLVideoElement;
const manifestUrl = resolveManifestUrl(); const manifestUrl = resolveManifestUrl();
@@ -148,7 +157,7 @@ async function initPlayerAndChat() {
video.addEventListener("loadedmetadata", () => video.play()); video.addEventListener("loadedmetadata", () => video.play());
} else { } else {
try { try {
const { default: Hls } = await import("hls.js"); const { default: Hls } = await loadHlsModule();
if (Hls.isSupported()) { if (Hls.isSupported()) {
hls = new Hls(); hls = new Hls();
hls.loadSource(manifestUrl); hls.loadSource(manifestUrl);
+27
View File
@@ -34,6 +34,33 @@
"title": "Chat Scrape Health", "title": "Chat Scrape Health",
"type": "stat", "type": "stat",
"targets": [{ "expr": "up{job=\"chat\"}" }] "targets": [{ "expr": "up{job=\"chat\"}" }]
},
{
"title": "Cache Hit Ratio",
"type": "timeseries",
"targets": [
{
"expr": "linearvoid_cache_hit_ratio"
}
]
},
{
"title": "Redis Memory Usage",
"type": "timeseries",
"targets": [
{
"expr": "linearvoid_chat_redis_memory_usage_ratio"
}
]
},
{
"title": "Chat Request Latency p95",
"type": "timeseries",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le))"
}
]
} }
] ]
} }
+21 -3
View File
@@ -38,13 +38,13 @@ groups:
description: "Playout has switched to emergency slate. Check playout logs." description: "Playout has switched to emergency slate. Check playout logs."
- alert: HighChatLatency - alert: HighChatLatency
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01 expr: histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le)) > 1
for: 5m for: 5m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Unusually low chat message rate" summary: "Chat request latency is high"
description: "Message rate below 0.01/s for 5 minutes. Check chat service health." description: "Chat request p95 has been above 1s for 5 minutes. Check chat handlers and dependencies."
- alert: PrometheusWALFailing - alert: PrometheusWALFailing
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0 expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
@@ -64,6 +64,24 @@ groups:
summary: "High CPU usage" summary: "High CPU usage"
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}." description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
- alert: LowCacheHitRatio
expr: linearvoid_cache_hit_ratio < 0.9
for: 15m
labels:
severity: warning
annotations:
summary: "Cloudflare cache hit ratio is below target"
description: "Cache hit ratio has been below 90% for 15 minutes on {{ $labels.hostname }}."
- alert: HighRedisMemoryUsage
expr: linearvoid_chat_redis_memory_usage_ratio > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "Redis memory usage is above target"
description: "Redis memory usage has been above 85% for 10 minutes on {{ $labels.instance }}."
- alert: LowDiskSpace - alert: LowDiskSpace
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10 expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
for: 5m for: 5m
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
if (!apiToken || !zoneTag) {
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
process.exit(1);
}
const end = new Date();
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
const iso = (value) => value.toISOString();
const query = `
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
requests: httpRequestsAdaptiveGroups(
limit: 1000
filter: {
datetime_geq: $start
datetime_lt: $end
clientRequestHTTPHost: $hostname
requestSource: "eyeball"
}
) {
count
dimensions {
cacheStatus
}
}
}
}
}
`;
async function main() {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
query,
variables: {
zoneTag,
hostname,
start: iso(start),
end: iso(end),
},
}),
});
const payload = await response.json();
if (!response.ok || payload.errors) {
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
throw new Error(message);
}
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
let totalRequests = 0;
let hitRequests = 0;
for (const group of groups) {
const count = Number(group?.count || 0);
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
totalRequests += count;
if (status === "HIT") {
hitRequests += count;
}
}
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
console.log(
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
);
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
console.log(
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
);
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
console.log(
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
);
console.log(
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
);
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
console.log(
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env pwsh
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
& node $nodeScript @args
exit $LASTEXITCODE
+90
View File
@@ -0,0 +1,90 @@
# Load Test Scaffolding
These files back the scaling/performance playbook.
## WebSocket chat
Run with an explicit WebSocket URL, for example:
```bash
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
```
Capture output to an artifact file for the worksheet:
```bash
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
```
PowerShell equivalent:
```powershell
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
New-Item -ItemType Directory -Force artifacts | Out-Null
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
```
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
```powershell
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
```
## Admin API
Run with a cookie or session header that can access admin endpoints:
```bash
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
```
Capture machine-readable metrics and a text summary:
```bash
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
```
PowerShell equivalent:
```powershell
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
$env:ADMIN_SESSION_COOKIE = 'session=...'
New-Item -ItemType Directory -Force artifacts | Out-Null
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
```
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
```powershell
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
```
## PowerShell helper
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
- The helper resolves scenario paths from its own script location, so it can be run from any current working directory.
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
```powershell
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
```
```powershell
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
```
```powershell
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
```
## Notes
- Adjust target URLs for test lab or production.
- Keep the same commit SHA or image tag when comparing runs.
- Record results using the worksheet in `docs/operations/scaling-performance-playbook.md`.
- Store raw outputs under an `artifacts/` folder per run date (for example `artifacts/2026-08-09/`).
- The repository `.gitignore` excludes benchmark artifacts by default.
+58
View File
@@ -0,0 +1,58 @@
import http from "k6/http";
import { check, sleep } from "k6";
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
const endpoints = [
{ name: "overview", path: "/api/admin/overview" },
{ name: "analytics", path: "/api/admin/analytics" },
{ name: "chat_log", path: "/api/admin/chat-log" },
{ name: "users", path: "/api/admin/users" },
];
export const options = {
stages: [
{ duration: "30s", target: 10 },
{ duration: "60s", target: 50 },
{ duration: "60s", target: 100 },
{ duration: "30s", target: 0 },
],
thresholds: {
http_req_duration: ["p(95)<1000"],
http_req_failed: ["rate<0.01"],
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
"http_req_duration{endpoint:users}": ["p(95)<1000"],
"http_req_failed{endpoint:overview}": ["rate<0.01"],
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
"http_req_failed{endpoint:users}": ["rate<0.01"],
},
};
export default function () {
const requests = endpoints.map((endpoint) => [
"GET",
`${baseUrl}${endpoint.path}`,
null,
{
...requestParams,
tags: {
endpoint: endpoint.name,
},
},
]);
const responses = http.batch(requests);
responses.forEach((response, index) => {
const endpointPath = endpoints[index].path;
check(response, {
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
});
});
sleep(1);
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env pwsh
param(
[ValidateSet('ws', 'admin', 'all')]
[string]$Scenario = 'all',
[string]$ArtifactRoot = 'artifacts',
[string]$WsUrl = $env:WS_URL,
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
)
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$artifactBase = $ArtifactRoot
} else {
$artifactBase = Join-Path $repoRoot $ArtifactRoot
}
if ($Scenario -in @('ws', 'all')) {
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
throw 'npx is required for ws scenario but was not found in PATH.'
}
}
if ($Scenario -in @('admin', 'all')) {
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
throw 'k6 is required for admin scenario but was not found in PATH.'
}
}
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
$artifactDir = Join-Path $artifactBase $timestamp
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
function Invoke-WebSocketLoadTest {
param(
[string]$TargetWsUrl,
[string]$OutputPath
)
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
}
$env:WS_URL = $TargetWsUrl
& npx artillery run $wsScenarioPath --output $OutputPath
}
function Invoke-AdminApiLoadTest {
param(
[string]$BaseUrl,
[string]$SessionCookie,
[string]$SummaryPath,
[string]$OutputPath
)
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
}
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
}
$env:ADMIN_BASE_URL = $BaseUrl
$env:ADMIN_SESSION_COOKIE = $SessionCookie
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
}
if ($Scenario -in @('ws', 'all')) {
if (-not (Test-Path $wsSummaryScriptPath)) {
throw "Missing summary helper: $wsSummaryScriptPath"
}
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
}
if ($Scenario -in @('admin', 'all')) {
if (-not (Test-Path $adminSummaryScriptPath)) {
throw "Missing summary helper: $adminSummaryScriptPath"
}
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
}
Write-Host "Artifacts written to $artifactDir"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env pwsh
param(
[Parameter(Mandatory = $true)]
[string]$ReportFile
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $ReportFile)) {
throw "Report file not found: $ReportFile"
}
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
$aggregate = $report.aggregate
if (-not $aggregate) {
throw 'Artillery report is missing aggregate data.'
}
function Get-PropertyValue {
param(
[Parameter(Mandatory = $true)]
[object]$Object,
[Parameter(Mandatory = $true)]
[string]$Name
)
if (-not $Object) {
return $null
}
$property = $Object.PSObject.Properties[$Name]
if ($property) {
return $property.Value
}
return $null
}
function Format-StatLine {
param(
[string]$Prefix,
[object]$Stats
)
if (-not $Stats) {
return $null
}
$parts = @()
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
$value = Get-PropertyValue -Object $Stats -Name $name
if ($null -ne $value) {
$parts += "$name=$value"
}
}
if ($parts.Count -eq 0) {
return $null
}
return "$Prefix: $($parts -join ', ')"
}
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
Write-Output '=== Artillery WebSocket Summary ==='
Write-Output "source: $ReportFile"
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
if ($timestamp) {
Write-Output "timestamp: $timestamp"
}
if ($null -ne $created) {
Write-Output "vusers.created: $created"
}
if ($null -ne $completed) {
Write-Output "vusers.completed: $completed"
}
if ($null -ne $failed) {
Write-Output "vusers.failed: $failed"
}
Write-Output "vusers.failure_rate_percent: $errorRate"
if ($counters) {
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
$value = Get-PropertyValue -Object $counters -Name $metric
if ($null -ne $value) {
Write-Output "$metric: $value"
}
}
}
if ($rates) {
foreach ($property in $rates.PSObject.Properties) {
Write-Output "rate.$($property.Name): $($property.Value)"
}
}
if ($summaries) {
foreach ($property in $summaries.PSObject.Properties) {
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
if ($line) {
Write-Output $line
}
}
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env pwsh
param(
[Parameter(Mandatory = $true)]
[string]$SummaryFile
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $SummaryFile)) {
throw "Summary file not found: $SummaryFile"
}
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
$metrics = $summary.metrics
if (-not $metrics) {
throw 'Invalid k6 summary file: missing metrics object.'
}
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
function Get-MetricValue {
param(
[object]$Metric,
[string]$Key
)
if (-not $Metric) {
return $null
}
if (-not $Metric.values) {
return $null
}
$value = $Metric.values.$Key
if ($null -ne $value) {
return [double]$value
}
return $null
}
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
Write-Output "k6 Summary: $SummaryFile"
if ($null -ne $globalDuration) {
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
}
if ($null -ne $globalFailRate) {
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
}
Write-Output ''
$rows = foreach ($endpoint in $endpoints) {
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
$failMetricName = "http_req_failed{endpoint:$endpoint}"
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
[PSCustomObject]@{
Endpoint = $endpoint
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
}
}
$rows | Format-Table -AutoSize | Out-String | Write-Output
+28
View File
@@ -0,0 +1,28 @@
config:
target: "{{ $processEnvironment.WS_URL }}"
phases:
- duration: 60
arrivalRate: 10
name: warmup-10
- duration: 120
arrivalRate: 100
name: sustain-100
- duration: 180
arrivalRate: 500
name: sustain-500
- duration: 240
arrivalRate: 1000
name: sustain-1000
engines:
ws: {}
scenarios:
- engine: ws
name: chat-presence-and-send
flow:
- think: 1
- send:
text: '{"type":"ping","text":"loadtest"}'
- think: 1
- send:
text: '{"type":"ping","text":"loadtest-2"}'
- think: 1