Files
memby/server/internal/api/admin_preview_test.go
T
2026-08-28 23:00:02 +12:00

329 lines
17 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
)
// A canned API for a local React console preview with no gateway behind it.
//
// In one terminal:
//
// cd server && ADMIN_PREVIEW=1 go test ./internal/api -run TestAdminPreview -timeout 0
//
// In another:
//
// cd admin-ui && npm run dev
//
// Vite owns the React shell at http://127.0.0.1:5180/admin/ and proxies its /admin/api/*
// requests here. The preview therefore fakes only data, leaving the same built console
// responsible for every route and asset it will own in deployment. It skips unless
// ADMIN_PREVIEW is set, so an ordinary `go test ./...` never blocks on this server.
func TestAdminPreview(t *testing.T) {
if os.Getenv("ADMIN_PREVIEW") == "" {
t.Skip("set ADMIN_PREVIEW=1 to serve the console locally")
}
address := os.Getenv("ADMIN_PREVIEW_ADDR")
if address == "" {
address = "127.0.0.1:7777"
}
mux := http.NewServeMux()
mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) {
body, ok := adminPreviewData()[r.URL.Path]
if !ok {
body = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
})
fmt.Printf("\ncanned admin API → http://%s; open Vite at http://127.0.0.1:5180/admin/ (ctrl-c to stop)\n\n", address)
if err := http.ListenAndServe(address, mux); err != nil {
t.Fatal(err)
}
}
// Enough of a household for every page to draw itself: a library, two viewers, two
// televisions, a run that succeeded and one that did not.
func adminPreviewData() map[string]any {
now := time.Now().UTC()
stamp := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) }
// One set of each presence state, because the dot is three-valued and a preview that
// only ever showed green would not be a preview of it.
build := func(version string, age time.Duration) map[string]any {
return map[string]any{"version": version,
"firstSeen": stamp(age + 24*time.Hour), "lastSeen": stamp(age)}
}
clients := []any{
map[string]any{"deviceId": "tv-1", "deviceName": "Living room", "username": "matt",
"version": "0.1.74", "lastSeen": stamp(30 * time.Second),
"capabilities": []string{"server_features_v1", "install_permission_v1"},
"versions": []any{build("0.1.74", 30*time.Second), build("0.1.72", 9*24*time.Hour),
build("0.1.68", 40*24*time.Hour)}},
map[string]any{"deviceId": "tv-2", "deviceName": "Bedroom", "username": "sam",
"version": "0.1.71", "lastSeen": stamp(2 * 24 * time.Hour), "capabilities": []string{},
"versions": []any{build("0.1.71", 2*24*time.Hour)}},
map[string]any{"deviceId": "tv-3", "deviceName": "Spare room", "username": "sam",
"version": "0.1.62", "lastSeen": stamp(70 * 24 * time.Hour),
"capabilities": []string{"server_features_v1"},
"versions": []any{build("0.1.62", 70*24*time.Hour)}},
}
features := []any{
map[string]any{"key": "subtitle_download", "label": "Download subtitles",
"description": "Fetch a missing subtitle from the player.", "enabled": true, "source": "default"},
map[string]any{"key": "install_permission_prompt", "label": "Install permission step",
"description": "Show the sideloading permission screen during setup.", "enabled": true, "source": "override"},
map[string]any{"key": "media_requests", "label": "Request it",
"description": "Let viewers ask for a title the library does not have.", "enabled": false, "source": "default"},
}
status := map[string]any{
"serverVersion": "preview",
"library": map[string]any{"total": 18422, "lastSynced": stamp(42 * time.Minute),
"byType": map[string]any{"Movie": 4210, "Series": 612, "Episode": 13600}},
"requestUsers": []any{"matt", "sam", "alex", "ros"},
"clients": clients,
"features": map[string]any{"revision": 12, "safeMode": false, "features": features},
"maintenance": map[string]any{"enabled": false, "message": ""},
"updatePolicy": map[string]any{"enabled": true, "latestVersion": "0.1.74",
"minimumVersion": "", "downloadUrl": "https://example.invalid/memby.apk"},
"playbackPolicy": map[string]any{"prerollEnabled": true, "prerollDurationMs": 6500},
"syncRunning": false,
"syncEvery": "1h",
"radarrReady": true,
"sonarrReady": false,
"mdblist": map[string]any{"enabled": true, "cachedTitles": 3121, "staleTitles": 44,
"apiKeyConfigured": true, "sources": []any{
map[string]any{"key": "imdb", "label": "IMDb", "enabled": true},
map[string]any{"key": "tmdb", "label": "TMDb", "enabled": true},
map[string]any{"key": "tomatoes", "label": "Rotten Tomatoes", "enabled": false},
}},
"forYou": map[string]any{"candidates": 2400, "profiles": 5, "tracearrSessions": 812,
"lastFullImport": stamp(30 * time.Hour)},
"forYouRunning": false,
"runs": []any{
map[string]any{"startedAt": stamp(42 * time.Minute), "kind": "incremental",
"status": "success", "itemsUpserted": 24},
map[string]any{"startedAt": stamp(102 * time.Minute), "kind": "incremental",
"status": "running", "itemsUpserted": 0},
map[string]any{"startedAt": stamp(162 * time.Minute), "kind": "full",
"status": "failed", "itemsUpserted": 0, "error": "dial tcp: connection refused"},
},
}
return map[string]any{
"/admin/api/status": status,
"/admin/api/runtime": adminPreviewRuntime(now),
"/admin/api/runtime/goroutines": adminPreviewGoroutines(now),
"/admin/api/accounts": map[string]any{
"accounts": []any{
map[string]any{"userId": "u-1", "username": "matt", "onboardingCompleted": true,
"preferencesRevision": 12, "devices": []any{clients[0]}},
map[string]any{"userId": "u-2", "username": "sam jones", "onboardingCompleted": false,
"preferencesRevision": 3, "devices": []any{clients[1], clients[2]}},
},
"preferenceCatalogue": []any{},
},
"/admin/api/events": map[string]any{"next": 1, "hasMore": false, "dropped": 0, "events": []any{
map[string]any{"occurredAt": stamp(9 * time.Second), "level": "INFO", "message": "playback started",
"attributes": map[string]any{"component": "playback", "user": "matt", "device": "Living room",
"client": "0.1.74", "title": "Arrival"}},
map[string]any{"occurredAt": stamp(24 * time.Second), "level": "DEBUG", "message": "search",
"attributes": map[string]any{"component": "search", "user": "sam", "query": "arr"}},
map[string]any{"occurredAt": stamp(51 * time.Second), "level": "WARN", "message": "emby slow to answer",
"attributes": map[string]any{"component": "emby", "duration": "4.2s", "path": "/Items"}},
map[string]any{"occurredAt": stamp(2 * time.Minute), "level": "ERROR", "message": "library sync failed",
"attributes": map[string]any{"component": "library", "error": "dial tcp: connection refused"}},
}},
"/admin/api/engagement": map[string]any{"rows": []any{
map[string]any{"rowId": "continue", "title": "Continue watching", "impressions": 1840,
"focuses": 620, "selections": 210, "averageDwellMs": 2400},
map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610,
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
}},
"/admin/api/journeys": map[string]any{
"days": 30, "retentionDays": 90,
"stats": map[string]any{"events": 184, "journeys": 28, "viewers": 2,
"completed": 21, "abandoned": 5, "active": 2, "averageSteps": 6.6,
"averageTimeMs": 1140000, "completionRate": 0.81},
"users": []any{
map[string]any{"userId": "u-1", "username": "matt", "events": 120, "journeys": 18, "lastActiveAt": stamp(9 * time.Minute)},
map[string]any{"userId": "u-2", "username": "sam", "events": 64, "journeys": 10, "lastActiveAt": stamp(3 * time.Hour)},
},
"features": []any{
map[string]any{"feature": "playback", "uses": 31, "lastUsedAt": stamp(9 * time.Minute)},
map[string]any{"feature": "search", "uses": 12, "lastUsedAt": stamp(3 * time.Hour)},
},
"actions": []any{
map[string]any{"category": "content", "action": "open", "events": 44, "journeys": 22},
map[string]any{"category": "playback", "action": "start", "events": 24, "journeys": 18},
},
"paths": []any{
map[string]any{"from": "home", "to": "details", "count": 31},
map[string]any{"from": "details", "to": "playback", "count": 18},
},
},
"/admin/api/requests": map[string]any{"requests": []any{
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 693134, "title": "Dune: Part Two", "year": 2024,
"requestedAt": stamp(2 * time.Hour), "status": "downloading",
"statusLabel": "Downloading", "statusDetail": "About 12 minutes left",
"progress": 64},
map[string]any{"userId": "u-2", "username": "sam", "mediaType": "series",
"foreignId": 371980, "title": "Severance", "year": 2022,
"requestedAt": stamp(30 * time.Hour), "status": "available",
"statusLabel": "Ready to watch", "statusDetail": "In your library",
"embyItemId": "abc123"},
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 1032823, "title": "Memory", "year": 2023,
"requestedAt": stamp(9 * 24 * time.Hour), "status": "requested",
"statusLabel": "Requested", "statusDetail": "Searching for a copy"},
}},
// A prefix among the terms and an unattributed row in the log, because both are
// ordinary here and a preview showing neither would not be a preview of this page.
"/admin/api/searches": map[string]any{
"days": 7, "retentionDays": searchWindowDays,
"termLimit": searchTermLimit, "eventLimit": searchEventLimit,
"totals": map[string]any{"searches": 214, "queries": 96, "viewers": 2},
"terms": []any{
map[string]any{"query": "severance", "searches": 18, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
map[string]any{"query": "dune", "searches": 11, "viewers": 1, "lastAt": stamp(3 * time.Hour)},
map[string]any{"query": "sev", "searches": 9, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
map[string]any{"query": "the bear", "searches": 4, "viewers": 1, "lastAt": stamp(2 * 24 * time.Hour)},
},
"recent": []any{
map[string]any{"occurredAt": stamp(40 * time.Minute), "userId": "u-1",
"username": "matt", "query": "severance"},
map[string]any{"occurredAt": stamp(3 * time.Hour), "userId": "u-2",
"username": "sam", "query": "dune"},
map[string]any{"occurredAt": stamp(26 * time.Hour), "userId": "u-9",
"username": "", "query": "the bear"},
},
},
}
}
// A gateway with a mild, deliberate upward drift in both goroutines and heap, so the
// preview shows what a trend that has earned a "watch" actually looks like — the flat case
// is the one that draws itself.
func adminPreviewRuntime(now time.Time) map[string]any {
const points = 90
samples := make([]any, 0, points)
for index := 0; index < points; index++ {
at := now.Add(-time.Duration(points-1-index) * time.Minute)
samples = append(samples, map[string]any{
"at": at.Format(time.RFC3339),
"goroutines": 62 + index/4 + index%3,
"heapInuse": 33_000_000 + index*90_000,
"sys": 92_274_688,
"numGc": 220 + index,
})
}
trend := func(direction string, perHour, first, latest, low, high float64) map[string]any {
return map[string]any{
"direction": direction, "perHour": perHour, "first": first, "latest": latest,
"min": low, "max": high, "spanSeconds": float64((points - 1) * 60), "points": points,
}
}
return map[string]any{
"at": now.Format(time.RFC3339), "uptimeSeconds": 61_240.0,
"goroutines": 84, "gomaxprocs": 4, "threads": 17, "goVersion": "go1.26",
"memory": map[string]any{
"heapAlloc": 39_000_000, "heapInuse": 41_943_040, "heapIdle": 24_000_000,
"heapReleased": 8_000_000, "stackInuse": 1_800_000, "sys": 223_100_000,
"nextGc": 62_914_560, "numGc": 311, "pauseTotalMs": 812.4, "pauseRecentMs": 1.9,
"memoryLimit": 402_653_184, "configuredLimit": "384MiB",
"heapShare": 0.104, "gcPerHour": 60.0,
},
"workers": []any{
map[string]any{"name": "HTTP listener", "component": "HTTP server",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Emby health probe", "component": "Emby",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Library ingest", "component": "Library sync",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Task scheduler", "component": "Scheduled jobs",
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
map[string]any{"name": "Startup library sync", "component": "Library sync",
"state": "finished", "started": now.Add(-17 * time.Hour).Format(time.RFC3339),
"stopped": now.Add(-17 * time.Hour).Add(4 * time.Minute).Format(time.RFC3339), "starts": 1},
},
"process": map[string]any{"pid": 1, "cpuSeconds": 431.8, "cpuPercent": 3.4,
"cpuKnown": true, "openFiles": 41, "openSockets": 22, "fileLimit": 1048576,
"filesKnown": true},
"samples": samples,
"goroutineTrend": trend("rising", 14, 62, 84, 62, 86),
"heapTrend": trend("rising", 5_400_000, 33_000_000, 41_943_040, 33_000_000, 41_943_040),
"reservedTrend": trend("steady", 0, 92_274_688, 92_274_688, 92_274_688, 92_274_688),
"sampleEverySeconds": 60.0,
"health": map[string]any{
"level": "watch",
"summary": "2 things to look at, in Goroutines and Memory.",
"areas": []any{"Goroutines", "Memory"},
"notes": []any{
map[string]any{"level": "watch", "area": "Memory",
"message": "Heap in use has risen by about 5.1 MB an hour over the last 1.5 hours. If it does not fall after a collection, something is holding on to it."},
map[string]any{"level": "watch", "area": "Goroutines",
"message": "Goroutines have risen by about 14 an hour over the last 1.5 hours, from 62 to 84. Open the breakdown to see which component they belong to."},
},
},
}
}
func adminPreviewGoroutines(now time.Time) map[string]any {
category := func(key, label, description string, count int, states []any) map[string]any {
return map[string]any{"category": key, "label": label, "description": description,
"count": count, "states": states}
}
state := func(name string, count int) any {
return map[string]any{"state": name, "count": count}
}
return map[string]any{
"at": now.Format(time.RFC3339), "total": 84, "collectedInMs": 6.2, "dumpBytes": 98_304,
"categories": []any{
category("running", "Running", "On a processor now, or queued for one.", 3,
[]any{state("running", 2), state("runnable", 1)}),
category("io", "Network / I/O",
"Waiting on a network read or write — Emby, Postgres, Redis, or a television.", 26,
[]any{state("IO wait", 26)}),
category("waiting", "Waiting / idle",
"Parked waiting for work or for a lock. Idle, and costing almost nothing.", 41,
[]any{state("select", 29), state("chan receive", 9), state("semacquire", 3)}),
category("timers", "Timers / scheduled work", "Asleep until a scheduled time.", 8,
[]any{state("sleep", 8)}),
category("runtime", "Go runtime / collection",
"The Go runtime's own housekeeping. This handful is always present.", 6,
[]any{state("GC worker (idle)", 4), state("finalizer wait", 1), state("force gc (idle)", 1)}),
},
"components": []any{
map[string]any{"component": "HTTP server", "count": 24, "longestWaitMinutes": 3},
map[string]any{"component": "Database pool", "count": 18, "longestWaitMinutes": 940},
map[string]any{"component": "Emby", "count": 11, "longestWaitMinutes": 2},
map[string]any{"component": "Gateway API", "count": 9, "longestWaitMinutes": 0},
map[string]any{"component": "Redis", "count": 8, "longestWaitMinutes": 940},
map[string]any{"component": "Library sync", "count": 6, "longestWaitMinutes": 940},
map[string]any{"component": "Go runtime", "count": 6, "longestWaitMinutes": 0},
map[string]any{"component": "Scheduled jobs", "count": 2, "longestWaitMinutes": 940},
},
"groups": []any{
map[string]any{"count": 24, "component": "HTTP server", "category": "io",
"state": "IO wait", "function": "net/http.(*conn).serve",
"file": "/usr/local/go/src/net/http/server.go:2092",
"createdBy": "net/http.(*Server).Serve", "longestWaitMinutes": 3},
map[string]any{"count": 18, "component": "Database pool", "category": "waiting",
"state": "select", "function": "github.com/jackc/puddle/v2.(*Pool).acquire",
"file": "/root/go/pkg/mod/github.com/jackc/puddle/v2/pool.go:481",
"createdBy": "github.com/jackc/pgx/v5/pgxpool.NewWithConfig", "longestWaitMinutes": 940},
map[string]any{"count": 6, "component": "Library sync", "category": "waiting",
"state": "chan receive", "function": "memby/server/internal/library.(*Ingester).Run",
"file": "/app/internal/library/ingest.go:118",
"createdBy": "main.run", "longestWaitMinutes": 940},
},
"groupsTotal": 21,
}
}