@norskvideo/ctl-dev-kit 0.2.107 → 0.2.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/conventions/DESIGN.family.md +73 -0
  2. package/conventions/check-drift.ts +100 -0
  3. package/conventions/sync-drift.ts +21 -1
  4. package/create-demo/create-demo.ts +191 -0
  5. package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
  6. package/create-demo/template/core/README.md.tmpl +74 -0
  7. package/create-demo/template/core/ctl/install.sh +27 -0
  8. package/create-demo/template/core/ctl/lib.sh +146 -0
  9. package/create-demo/template/core/ctl/mode.sh +19 -0
  10. package/create-demo/template/core/ctl/push.sh +16 -0
  11. package/create-demo/template/core/ctl/site.example.env +30 -0
  12. package/create-demo/template/core/ctl/studio.example.env +12 -0
  13. package/create-demo/template/core/gitignore +18 -0
  14. package/create-demo/template/core/scripts/build.sh +30 -0
  15. package/create-demo/template/core/scripts/test-feed.sh +44 -0
  16. package/create-demo/template/dashboard/dashboard/index.html +13 -0
  17. package/create-demo/template/dashboard/dashboard/package.json +22 -0
  18. package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
  19. package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
  20. package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
  21. package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
  22. package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
  23. package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
  24. package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
  25. package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
  26. package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
  27. package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
  28. package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
  29. package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
  30. package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
  31. package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
  32. package/create-product/cli.ts +39 -1
  33. package/create-product/features/components.ts +10 -1
  34. package/package.json +1 -1
@@ -0,0 +1,74 @@
1
+ # __DEMO__
2
+
3
+ <!-- The customer's handover document. Replace every TODO before handover; the
4
+ CLAUDE.md "Handover" list says what this needs to cover. -->
5
+
6
+ TODO: one paragraph on what this demo does and who it is for.
7
+
8
+ ## What is in it
9
+
10
+ | Path | What it is |
11
+ | ---------- | ------------------------------------------------------------------------------------------ |
12
+ | `workdir/` | The Norsk Studio working directory: the workflow, and what Studio serves and loads with it |
13
+ | `ctl/` | The scripts that install it into norsk-ctl and update it |
14
+ | `scripts/` | Building it, and a test feed |
15
+ <!-- if:dashboard -->
16
+ | `dashboard/` | The operator dashboard |
17
+ <!-- end:dashboard -->
18
+ <!-- if:components -->
19
+ | `components/` | This demo's own Studio components |
20
+ <!-- end:components -->
21
+
22
+ ## Installing
23
+
24
+ It runs under norsk-ctl, on this machine or another one over ssh.
25
+
26
+ 1. Install norsk-ctl on the machine, and have the norsk-studio licence there.
27
+ 2. Copy `ctl/site.example.env` to `ctl/site.env` and fill it in: where it runs, where the working
28
+ dir goes, the licence path, the ports to publish.
29
+ 3. Copy `ctl/studio.example.env` to `ctl/studio.env` and fill in the site's settings.
30
+ 4. Run `ctl/install.sh`. It prints the Studio URL when it is up.
31
+
32
+ Building needs bun on the machine you run the scripts from.
33
+
34
+ ## Using it
35
+
36
+ TODO: the URLs, and what the operator does on each page.
37
+
38
+ - `scripts/test-feed.sh up` sends a test picture into the SRT input; `down` stops it.
39
+
40
+ ## The workflow
41
+
42
+ TODO: each block in `workdir/studio-save-files/__DEMO__/__DEMO__.yaml`, in signal
43
+ order: what it is for and the settings that matter.
44
+
45
+ ## Changing it
46
+
47
+ - Edit the workflow YAML, then `ctl/push.sh`. It rebuilds, copies the working dir over and relaunches.
48
+ - To change it in Studio's editor, `ctl/mode.sh editor`; `ctl/mode.sh document` puts it back to
49
+ running on start. Saving from the editor drops the YAML's comments, so copy changes back by hand.
50
+ - Site settings (ports, passphrases, destinations) live in `ctl/studio.env`, as
51
+ `<COMPONENT_ID>_<FIELD>=value`.
52
+ <!-- if:dashboard -->
53
+ - The dashboard is in `dashboard/`; `src/lib/workflow.ts` names the components it shows.
54
+ <!-- end:dashboard -->
55
+ <!-- if:components -->
56
+ - Custom components are in `components/src/library/`, one directory each.
57
+ <!-- end:components -->
58
+
59
+ ## Limits
60
+
61
+ TODO: what this demo does not do, and what a production build would add.
62
+
63
+ There is no access control beyond norsk-ctl's own sign-in.
64
+
65
+ ## Versions
66
+
67
+ TODO: the norsk-ctl, Studio and Norsk versions this was shown on
68
+ (`norsk-ctl instance describe __DEMO__`).
69
+
70
+ ## Taking it out of norsk-ctl
71
+
72
+ `norsk-ctl instance export __DEMO__ --to <dir>` writes a docker compose bundle that runs
73
+ without norsk-ctl, with a `HANDOVER.md` of its own. Run it under its own project name, and put
74
+ a reverse proxy in front of Studio: the bundle has none.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # First install of the demo into norsk-ctl: here, or on DEMO_HOST over ssh
3
+ # (see ctl/site.env). Builds, stages workdir/ at WORKDIR, and launches it as
4
+ # instance __DEMO__ from the studio-dev template. ctl/push.sh updates it after.
5
+ #
6
+ # ctl/install.sh [--no-build]
7
+ source "$(dirname "$0")/lib.sh"
8
+ load_site
9
+
10
+ if ctl instance exists "$DEMO" >/dev/null 2>&1; then
11
+ die "instance $DEMO already exists: ctl/push.sh updates it"
12
+ fi
13
+ if [[ "${1:-}" != --no-build ]]; then "$ROOT/scripts/build.sh"; fi
14
+
15
+ ensure_template
16
+ sync_workdir
17
+ write_overrides "$(current_mode)"
18
+
19
+ ports=()
20
+ for port in $HOST_PORTS; do ports+=(--host-ports "$port"); done
21
+ ctl instance launch-template "$DEMO" --template studio-dev \
22
+ --param "INSTANCE_NAME=$DEMO" --param GLOBAL_MOQ_PREVIEW_WEBTRANSPORTPORT=auto \
23
+ --working-directory "$WORKDIR" --overrides "$WORKDIR/ctl-overrides.yml" \
24
+ --network-mode "$NETWORK_MODE" "${ports[@]}" >/dev/null
25
+
26
+ wait_ready
27
+ print_urls
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # Shared by install.sh, push.sh and mode.sh: the site's settings, how to reach
3
+ # the machine the demo runs on, and the overrides file norsk-ctl launches
4
+ # Studio with. Sourced, not run.
5
+ set -euo pipefail
6
+
7
+ DEMO=__DEMO__
8
+ CTL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ ROOT="$(dirname "$CTL_DIR")"
10
+
11
+ die() {
12
+ echo "error: $*" >&2
13
+ exit 1
14
+ }
15
+
16
+ # ctl/site.env, with the defaults for anything it leaves unset.
17
+ load_site() {
18
+ [[ -f "$CTL_DIR/site.env" ]] || die "no ctl/site.env: copy ctl/site.example.env to it and fill it in"
19
+ set -a
20
+ # shellcheck source=/dev/null
21
+ source "$CTL_DIR/site.env"
22
+ set +a
23
+ DEMO_HOST="${DEMO_HOST:-}"
24
+ WORKDIR="${WORKDIR:-/srv/$DEMO}"
25
+ NETWORK_MODE="${NETWORK_MODE:-docker}"
26
+ RUN_AS="${RUN_AS-norsk}"
27
+ NORSK_CTL="${NORSK_CTL:-norsk-ctl}"
28
+ HOST_PORTS="${HOST_PORTS:-}"
29
+ LICENSE_FILE="${LICENSE_FILE:-}"
30
+ }
31
+
32
+ quote() {
33
+ local out=() arg
34
+ for arg in "$@"; do out+=("$(printf '%q' "$arg")"); done
35
+ echo "${out[*]}"
36
+ }
37
+
38
+ # Run a shell command line on the target: over ssh when DEMO_HOST is set, here
39
+ # otherwise. stdin passes through either way.
40
+ on_target() {
41
+ if [[ -n "$DEMO_HOST" ]]; then ssh "$DEMO_HOST" "$*"; else bash -c "$*"; fi
42
+ }
43
+
44
+ # "sudo " when the working dir belongs to another user (RUN_AS), else nothing.
45
+ target_sudo() {
46
+ if [[ -n "$RUN_AS" ]]; then echo "sudo "; fi
47
+ }
48
+
49
+ # norsk-ctl on the target, as the user its daemon runs as.
50
+ ctl() {
51
+ local cmd
52
+ cmd="$NORSK_CTL $(quote "$@")"
53
+ if [[ -n "$RUN_AS" ]]; then cmd="sudo -u $(quote "$RUN_AS") -H $cmd"; fi
54
+ on_target "$cmd"
55
+ }
56
+
57
+ current_mode() {
58
+ cat "$CTL_DIR/.mode" 2>/dev/null || echo document
59
+ }
60
+
61
+ yaml_quote() {
62
+ local s="${1//\'/\'\'}"
63
+ printf "'%s'" "$s"
64
+ }
65
+
66
+ # The compose override norsk-ctl merges over the studio service, for a mode:
67
+ # document Studio runs acme.yaml on start (the demo as shown)
68
+ # editor Studio opens on the editor with nothing running (for changing it)
69
+ # NODE_CONFIG names every library, the custom components included, because a
70
+ # norsk-ctl older than the library fix lets this list replace the one it
71
+ # writes. ctl/studio.env lines are Studio environment overrides, <ID>_<FIELD>.
72
+ overrides_yaml() {
73
+ local mode="$1" libs server line
74
+ [[ "$mode" == document || "$mode" == editor ]] || die "unknown mode '$mode': document or editor"
75
+ libs='"@norskvideo/norsk-studio-built-ins","@norskvideo/norsk-studio-beta"'
76
+ if [[ -d "$ROOT/components" ]]; then libs="$libs,\"@$DEMO/library\""; fi
77
+ server="\"library\":[$libs]"
78
+ if [[ "$mode" == document ]]; then server="\"document\":\"$DEMO.yaml\",$server"; fi
79
+ echo "services:"
80
+ echo " studio:"
81
+ echo " environment:"
82
+ echo " NODE_CONFIG: $(yaml_quote "{\"server\":{$server}}")"
83
+ if [[ -f "$CTL_DIR/studio.env" ]]; then
84
+ while IFS= read -r line || [[ -n "$line" ]]; do
85
+ [[ -z "${line// /}" || "$line" == \#* ]] && continue
86
+ echo " ${line%%=*}: $(yaml_quote "${line#*=}")"
87
+ done <"$CTL_DIR/studio.env"
88
+ fi
89
+ }
90
+
91
+ write_overrides() {
92
+ overrides_yaml "$1" | on_target "$(target_sudo)tee $(quote "$WORKDIR/ctl-overrides.yml") >/dev/null"
93
+ }
94
+
95
+ # Copy workdir/ to WORKDIR on the target, owned by RUN_AS. Nothing already
96
+ # there is deleted (Studio keeps its own files in it), except inside the two
97
+ # build outputs, where a stale file would still be served or loaded.
98
+ sync_workdir() {
99
+ local dest="$WORKDIR" rsync=(rsync) opts=(-a) sub
100
+ on_target "$(target_sudo)mkdir -p $(quote "$WORKDIR")"
101
+ if [[ -n "$RUN_AS" ]]; then
102
+ opts+=("--chown=$RUN_AS:$RUN_AS")
103
+ if [[ -n "$DEMO_HOST" ]]; then opts+=(--rsync-path="sudo rsync"); else rsync=(sudo rsync); fi
104
+ fi
105
+ if [[ -n "$DEMO_HOST" ]]; then dest="$DEMO_HOST:$WORKDIR"; fi
106
+ "${rsync[@]}" "${opts[@]}" --exclude logs --exclude .norsk --exclude moq-certs --exclude ctl-overrides.yml \
107
+ "$ROOT/workdir/" "$dest/"
108
+ for sub in "studio-save-files/$DEMO/dashboards" components; do
109
+ if [[ -d "$ROOT/workdir/$sub" ]]; then
110
+ "${rsync[@]}" "${opts[@]}" --delete "$ROOT/workdir/$sub/" "$dest/$sub/"
111
+ fi
112
+ done
113
+ }
114
+
115
+ # The studio-dev product template, added from the licence when it is missing.
116
+ ensure_template() {
117
+ if ctl template list 2>/dev/null | grep -q "name: studio-dev"; then return; fi
118
+ [[ -n "$LICENSE_FILE" ]] || die "norsk-ctl has no studio-dev template: set LICENSE_FILE in ctl/site.env"
119
+ ctl product add --license-file "$LICENSE_FILE" --product norsk-studio >/dev/null
120
+ }
121
+
122
+ describe_field() {
123
+ ctl instance describe "$DEMO" -o json 2>/dev/null | bun -e '
124
+ const text = await Bun.stdin.text();
125
+ let v = JSON.parse(text.slice(text.indexOf("{")));
126
+ for (const key of process.argv[1].split(".")) v = v?.[key];
127
+ console.log(v ?? "");
128
+ ' "$1"
129
+ }
130
+
131
+ wait_ready() {
132
+ local i
133
+ for ((i = 0; i < 90; i++)); do
134
+ if [[ "$(describe_field runtimeScreen.ready 2>/dev/null)" == true ]]; then return 0; fi
135
+ sleep 2
136
+ done
137
+ die "$DEMO did not come up within 3 minutes: see norsk-ctl instance describe $DEMO"
138
+ }
139
+
140
+ print_urls() {
141
+ local studio
142
+ studio="$(describe_field runtimeScreen.href)"
143
+ echo "Studio: $studio"
144
+ if [[ -d "$ROOT/dashboard" ]]; then echo "Dashboard: ${studio}dashboard/$DEMO/"; fi
145
+ echo "Mode: $(current_mode)"
146
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Switch the installed demo between its two modes, and relaunch it:
3
+ # document Studio runs the workflow on start: the demo as it is shown.
4
+ # The editor's Stop button is hidden in this mode.
5
+ # editor Studio opens on the editor with nothing running, to change it.
6
+ #
7
+ # ctl/mode.sh document|editor
8
+ source "$(dirname "$0")/lib.sh"
9
+ load_site
10
+
11
+ mode="${1:-}"
12
+ overrides_yaml "$mode" >/dev/null
13
+ ctl instance exists "$DEMO" >/dev/null 2>&1 || die "no instance $DEMO yet: run ctl/install.sh first"
14
+
15
+ echo "$mode" >"$CTL_DIR/.mode"
16
+ write_overrides "$mode"
17
+ ctl instance relaunch "$DEMO" >/dev/null
18
+ wait_ready
19
+ print_urls
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # Push a change to the installed demo: build, sync workdir/ to WORKDIR, rewrite
3
+ # the overrides from ctl/studio.env, relaunch, and wait until Studio is up.
4
+ #
5
+ # ctl/push.sh [--no-build]
6
+ source "$(dirname "$0")/lib.sh"
7
+ load_site
8
+
9
+ ctl instance exists "$DEMO" >/dev/null 2>&1 || die "no instance $DEMO yet: run ctl/install.sh first"
10
+ if [[ "${1:-}" != --no-build ]]; then "$ROOT/scripts/build.sh"; fi
11
+
12
+ sync_workdir
13
+ write_overrides "$(current_mode)"
14
+ ctl instance relaunch "$DEMO" >/dev/null
15
+ wait_ready
16
+ print_urls
@@ -0,0 +1,30 @@
1
+ # Where and how this demo runs under norsk-ctl. Copy to ctl/site.env (which
2
+ # is gitignored) and fill it in; ctl/install.sh, push.sh and mode.sh read it.
3
+
4
+ # The machine norsk-ctl runs on: user@host over ssh, or empty for this one.
5
+ DEMO_HOST=
6
+
7
+ # Where the working dir is staged on that machine. It must be a path the
8
+ # norsk-ctl user can reach: a directory under someone's home usually is not.
9
+ WORKDIR=/srv/__DEMO__
10
+
11
+ # The user the norsk-ctl daemon runs as: the working dir is owned by it and
12
+ # norsk-ctl is run as it (via sudo). Empty runs both as the ssh/local user.
13
+ RUN_AS=norsk
14
+
15
+ # The norsk-ctl command on that machine.
16
+ NORSK_CTL=norsk-ctl
17
+
18
+ # A path on that machine to the norsk-studio licence, used once to add the
19
+ # studio-dev product template if norsk-ctl does not have it yet.
20
+ LICENSE_FILE=
21
+
22
+ # docker (norsk-ctl's default) or hybrid. hybrid puts media on the host
23
+ # network: needed when a viewer receives WebRTC media (WHEP, WHIP) directly.
24
+ NETWORK_MODE=docker
25
+
26
+ # Ingest ports to publish on the host, space separated, HOST[:CONTAINER][/udp].
27
+ # Everything else is allocated by norsk-ctl; pin a port only when a sender or
28
+ # a firewall outside the box must agree on it. 5001/udp is the SRT input
29
+ # (srt_in in the workflow); change both together.
30
+ HOST_PORTS="5001/udp"
@@ -0,0 +1,12 @@
1
+ # Site settings for the workflow. Copy to ctl/studio.env (which is gitignored).
2
+ #
3
+ # Each line is a Studio environment override, <COMPONENT_ID>_<FIELD>=value:
4
+ # it replaces that field of that component in the workflow YAML at launch, for
5
+ # any field the component declares overridable. This is where ports,
6
+ # passphrases and destinations go, so the YAML carries no site's values and no
7
+ # secrets. ctl/push.sh writes these into the overrides norsk-ctl launches with.
8
+ #
9
+ # A port changed here must be changed in HOST_PORTS (ctl/site.env) too.
10
+
11
+ # SRT_IN_PORT=5001
12
+ # SRT_IN_PASSPHRASE=
@@ -0,0 +1,18 @@
1
+ # The site's settings: copied from the .example files, never committed.
2
+ ctl/site.env
3
+ ctl/studio.env
4
+ ctl/.mode
5
+
6
+ node_modules/
7
+
8
+ # Build output, rebuilt by scripts/build.sh.
9
+ dashboard/dist/
10
+ components/lib/
11
+ components/src/**/_gen/
12
+ workdir/studio-save-files/*/dashboards/
13
+ workdir/components/
14
+
15
+ # What Studio writes into its working dir while it runs.
16
+ workdir/logs/
17
+ workdir/.norsk/
18
+ workdir/moq-certs/
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Build what this demo adds to Studio, into workdir/ where Studio finds it:
3
+ # dashboard/ -> workdir/studio-save-files/__DEMO__/dashboards/ served at /dashboard/__DEMO__/
4
+ # components/ -> workdir/components/@__DEMO__/library/ loaded as a Studio library
5
+ # A demo without either has nothing to build.
6
+ set -euo pipefail
7
+ root="$(cd "$(dirname "$0")/.." && pwd)"
8
+
9
+ install_to() {
10
+ local from="$1" dest="$2"
11
+ rm -rf "$dest"
12
+ mkdir -p "$dest"
13
+ cp -r "$from/." "$dest/"
14
+ echo "installed ${dest#"$root"/}"
15
+ }
16
+
17
+ if [[ -d "$root/dashboard" ]]; then
18
+ # vite 8 needs util.parseEnv, which bun 1.1 does not have.
19
+ if ! bun -e 'if (typeof require("node:util").parseEnv !== "function") process.exit(1)' 2>/dev/null; then
20
+ echo "error: bun $(bun --version) is too old to build the dashboard (vite 8 needs util.parseEnv): run bun upgrade" >&2
21
+ exit 1
22
+ fi
23
+ (cd "$root/dashboard" && bun install >/dev/null && bun run build)
24
+ install_to "$root/dashboard/dist" "$root/workdir/studio-save-files/__DEMO__/dashboards"
25
+ fi
26
+
27
+ if [[ -d "$root/components" ]]; then
28
+ (cd "$root/components" && bun install >/dev/null && bun run build)
29
+ install_to "$root/components/lib/library" "$root/workdir/components/@__DEMO__/library"
30
+ fi
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # A labelled test picture with a tone, sent into the SRT input (srt_in), so the
3
+ # demo has something to show without a camera. Runs ffmpeg in docker.
4
+ #
5
+ # scripts/test-feed.sh up|down
6
+ #
7
+ # FEED_HOST is where the demo runs (default this machine); the port is
8
+ # SRT_IN_PORT from ctl/studio.env, else the workflow's 5001.
9
+ set -euo pipefail
10
+ root="$(cd "$(dirname "$0")/.." && pwd)"
11
+
12
+ # A setting from ctl/studio.env, or nothing: an unset one is not an error.
13
+ studio_env() { grep -E "^$1=" "$root/ctl/studio.env" 2>/dev/null | cut -d= -f2- || true; }
14
+
15
+ host="${FEED_HOST:-127.0.0.1}"
16
+ port="$(studio_env SRT_IN_PORT)"
17
+ port="${port:-5001}"
18
+ passphrase="$(studio_env SRT_IN_PASSPHRASE)"
19
+ image="${FFMPEG_IMAGE:-linuxserver/ffmpeg:8.0.1}"
20
+ name="__DEMO__-test-feed"
21
+
22
+ case "${1:-up}" in
23
+ up)
24
+ url="srt://${host}:${port}?mode=caller&latency=200000"
25
+ if [[ -n "$passphrase" ]]; then url="${url}&passphrase=${passphrase}"; fi
26
+ docker rm -f "$name" >/dev/null 2>&1 || true
27
+ docker run -d --restart unless-stopped --name "$name" --network host "$image" \
28
+ -hide_banner -loglevel warning -re \
29
+ -f lavfi -i "testsrc2=size=1280x720:rate=25" \
30
+ -f lavfi -i "sine=frequency=440:sample_rate=48000" \
31
+ -vf "drawtext=text='__DEMO__ %{localtime\:%T}':fontcolor=white:fontsize=64:x=40:y=40" \
32
+ -c:v libx264 -preset veryfast -tune zerolatency -g 50 -b:v 3M -pix_fmt yuv420p \
33
+ -c:a aac -b:a 128k -ac 2 \
34
+ -f mpegts "$url" >/dev/null
35
+ echo "test feed -> ${host}:${port}"
36
+ ;;
37
+ down)
38
+ docker rm -f "$name" >/dev/null 2>&1 && echo "test feed stopped" || true
39
+ ;;
40
+ *)
41
+ echo "usage: $0 up|down" >&2
42
+ exit 2
43
+ ;;
44
+ esac
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <link rel="icon" href="data:," />
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>__DEMO__</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__DEMO__-dashboard",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "react": "^19.0.0",
13
+ "react-dom": "^19.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^19.0.10",
17
+ "@types/react-dom": "^19.0.3",
18
+ "@vitejs/plugin-react": "^6.0.1",
19
+ "typescript": "^5.7.2",
20
+ "vite": "^8.0.0"
21
+ }
22
+ }
@@ -0,0 +1,61 @@
1
+ import type { EnvResponse, WorkflowComponentDescriptor } from "./api/types";
2
+ import { ImagePreview } from "./components/ImagePreview";
3
+ import { StatusPill } from "./components/StatusPill";
4
+ import { labelFromAlerts, toneFromAlerts, useAlerts } from "./hooks/useAlerts";
5
+ import { useDiscovery } from "./hooks/useDiscovery";
6
+ import { useEnv } from "./hooks/useEnv";
7
+ import { useLiveComponent } from "./hooks/useLiveComponent";
8
+ import { WORKFLOW } from "./lib/workflow";
9
+
10
+ // The starter page: the program preview, and every running component with its
11
+ // health and live state. Replace it with what the operator actually needs; the
12
+ // hooks are the parts to keep.
13
+ export function App() {
14
+ const { env, error } = useEnv();
15
+ const { components, loading } = useDiscovery(env);
16
+
17
+ if (error) return <main className="page">Cannot reach Studio: {error}</main>;
18
+ return (
19
+ <main className="page">
20
+ <header>
21
+ <h1>__DEMO__</h1>
22
+ </header>
23
+ <ImagePreview componentId={WORKFLOW.preview.id} env={env} />
24
+ <section>
25
+ <h2>Components</h2>
26
+ {loading ? (
27
+ <p>Waiting for the workflow to start</p>
28
+ ) : (
29
+ <ul className="components">
30
+ {components.map((c) => (
31
+ <ComponentRow key={c.componentId} component={c} env={env} />
32
+ ))}
33
+ </ul>
34
+ )}
35
+ </section>
36
+ </main>
37
+ );
38
+ }
39
+
40
+ function ComponentRow({ component, env }: { component: WorkflowComponentDescriptor; env: EnvResponse | null }) {
41
+ const { alerts, loaded } = useAlerts(component.componentId, env);
42
+ return (
43
+ <li>
44
+ <span className="component-name">{component.displayName ?? component.componentId}</span>
45
+ <span className="component-type">{component.componentType}</span>
46
+ <StatusPill tone={toneFromAlerts(alerts, loaded)} label={labelFromAlerts(alerts, loaded, { live: "OK" })} />
47
+ {component.capabilities.hasState && <LiveState componentId={component.componentId} env={env} />}
48
+ </li>
49
+ );
50
+ }
51
+
52
+ function LiveState({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
53
+ const { state } = useLiveComponent<unknown>({ componentId, env });
54
+ if (state === null) return null;
55
+ return (
56
+ <details className="component-state">
57
+ <summary>State</summary>
58
+ <pre>{JSON.stringify(state, null, 2)}</pre>
59
+ </details>
60
+ );
61
+ }
@@ -0,0 +1,38 @@
1
+ // What the dashboard reads from Studio. The discovery response is the
2
+ // workflow's `<apiBasePath>/components` endpoint, which returns each running
3
+ // component's type and capabilities only: its config and state arrive over the
4
+ // component's websocket (see useLiveComponent).
5
+
6
+ export interface EnvResponse {
7
+ studioUrlPrefix: string;
8
+ apiBasePath: string;
9
+ wsBasePath: string;
10
+ note?: string;
11
+ }
12
+
13
+ export interface ComponentCapabilities {
14
+ hasHttp: boolean;
15
+ hasWebSocket: boolean;
16
+ hasViews: boolean;
17
+ hasState: boolean;
18
+ }
19
+
20
+ export interface WorkflowComponentDescriptor {
21
+ componentId: string;
22
+ componentType: string;
23
+ displayName?: string;
24
+ capabilities: ComponentCapabilities;
25
+ }
26
+
27
+ export interface ComponentsResponse {
28
+ components: WorkflowComponentDescriptor[];
29
+ totalComponents: number;
30
+ }
31
+
32
+ // `output.preview`'s state. `url` depends on the preview mode: a JPEG URL in
33
+ // image mode, a WHEP endpoint in webrtc mode, a WebTransport base URL in moq.
34
+ export interface PreviewState {
35
+ url?: string;
36
+ previewMode?: string;
37
+ [k: string]: unknown;
38
+ }
@@ -0,0 +1,23 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { EnvResponse, PreviewState } from "../api/types";
3
+ import { useLiveComponent } from "../hooks/useLiveComponent";
4
+
5
+ // The program as a still from an `output.preview` in image mode. Its state
6
+ // carries the first JPEG's URL, and each newer one arrives as a `url-published`
7
+ // event, about once a second. No player and no media ports, so it works in
8
+ // norsk-ctl's docker network mode. For live video, see the CLAUDE.md
9
+ // "Dashboard" notes.
10
+ export function ImagePreview({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
11
+ const [published, setPublished] = useState<string | null>(null);
12
+ const onEvent = useCallback((evt: unknown) => {
13
+ const e = evt as { type?: string; url?: unknown };
14
+ if (e.type === "url-published" && typeof e.url === "string") setPublished(e.url);
15
+ }, []);
16
+ const { state, connected } = useLiveComponent<PreviewState>({ componentId, env, onEvent });
17
+
18
+ const url = published ?? (typeof state?.url === "string" && state.url.length > 0 ? state.url : null);
19
+ if (!url) {
20
+ return <div className="preview preview-empty">{connected ? "Waiting for a picture" : "Connecting"}</div>;
21
+ }
22
+ return <img className="preview" src={url} alt="Program preview" />;
23
+ }
@@ -0,0 +1,65 @@
1
+ // A status pill. Six tones aligned with what Studio's per-component /alerts
2
+ // endpoint reports.
3
+ // A component's tone is derived from its live alerts list:
4
+ //
5
+ // live — no alerts (Studio considers this component healthy)
6
+ // ready — warning-level alerts only (e.g. "no active connections")
7
+ // waiting — expected upstream not yet reached (config-derived)
8
+ // idle — configured but off / disabled (config-derived)
9
+ // error — any error-level alert (e.g. "receiving no data")
10
+ // unknown — Studio's state doesn't expose the signal we'd need
11
+ //
12
+ // Card headers may add a matching border-left accent via `toneAccentColor`.
13
+
14
+ export type StatusTone = "live" | "ready" | "waiting" | "idle" | "error" | "unknown";
15
+
16
+ interface StatusPillProps {
17
+ tone: StatusTone;
18
+ label: string;
19
+ }
20
+
21
+ const PALETTE: Record<StatusTone, { background: string; color: string; border: string }> = {
22
+ live: {
23
+ background: "rgba(34, 197, 94, 0.20)",
24
+ color: "rgb(134, 239, 172)",
25
+ border: "1px solid rgba(34, 197, 94, 0.55)",
26
+ },
27
+ ready: {
28
+ background: "rgba(59, 130, 246, 0.20)",
29
+ color: "rgb(147, 197, 253)",
30
+ border: "1px solid rgba(59, 130, 246, 0.55)",
31
+ },
32
+ waiting: {
33
+ background: "rgba(234, 179, 8, 0.20)",
34
+ color: "rgb(253, 224, 71)",
35
+ border: "1px solid rgba(234, 179, 8, 0.55)",
36
+ },
37
+ idle: {
38
+ background: "rgba(107, 114, 128, 0.20)",
39
+ color: "rgb(209, 213, 219)",
40
+ border: "1px solid rgba(107, 114, 128, 0.55)",
41
+ },
42
+ error: {
43
+ background: "rgba(239, 68, 68, 0.20)",
44
+ color: "rgb(252, 165, 165)",
45
+ border: "1px solid rgba(239, 68, 68, 0.55)",
46
+ },
47
+ unknown: {
48
+ background: "rgba(107, 114, 128, 0.08)",
49
+ color: "rgb(156, 163, 175)",
50
+ border: "1px dashed rgba(107, 114, 128, 0.55)",
51
+ },
52
+ };
53
+
54
+ export function StatusPill({ tone, label }: StatusPillProps) {
55
+ return (
56
+ <span className="status-pill" style={PALETTE[tone]} data-tone={tone}>
57
+ {label}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ // For card border-left accents matching a section's aggregate status.
63
+ export function toneAccentColor(tone: StatusTone): string {
64
+ return PALETTE[tone].border.replace("1px solid ", "").replace("1px dashed ", "");
65
+ }