@norskvideo/ctl-dev-kit 0.2.107 → 0.2.108
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.
- package/create-demo/create-demo.ts +191 -0
- package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
- package/create-demo/template/core/README.md.tmpl +74 -0
- package/create-demo/template/core/ctl/install.sh +27 -0
- package/create-demo/template/core/ctl/lib.sh +146 -0
- package/create-demo/template/core/ctl/mode.sh +19 -0
- package/create-demo/template/core/ctl/push.sh +16 -0
- package/create-demo/template/core/ctl/site.example.env +30 -0
- package/create-demo/template/core/ctl/studio.example.env +12 -0
- package/create-demo/template/core/gitignore +18 -0
- package/create-demo/template/core/scripts/build.sh +30 -0
- package/create-demo/template/core/scripts/test-feed.sh +44 -0
- package/create-demo/template/dashboard/dashboard/index.html +13 -0
- package/create-demo/template/dashboard/dashboard/package.json +22 -0
- package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
- package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
- package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
- package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
- package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
- package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
- package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
- package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
- package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
- package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
- package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
- package/create-product/cli.ts +39 -1
- package/create-product/features/components.ts +10 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { EnvResponse } from "../api/types";
|
|
3
|
+
import type { StatusTone } from "../components/StatusPill";
|
|
4
|
+
|
|
5
|
+
// Studio's per-component alert. Emitted by `setAlert(id, {level, message})`
|
|
6
|
+
// in the runtime; polled from `<apiBasePath>/<componentId>/alerts`. Cleared
|
|
7
|
+
// entries drop out of the endpoint response, so an empty array means the
|
|
8
|
+
// component is currently healthy (from Studio's own POV).
|
|
9
|
+
export interface Alert {
|
|
10
|
+
level: "error" | "warning" | "info";
|
|
11
|
+
message: string;
|
|
12
|
+
startMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
16
|
+
|
|
17
|
+
// Polls Studio's alerts endpoint for a single component. Returns the current
|
|
18
|
+
// alert array plus a `loaded` flag distinguishing "no alerts, healthy" from
|
|
19
|
+
// "haven't fetched yet". Reset on componentId / env change.
|
|
20
|
+
export function useAlerts(componentId: string | null, env: EnvResponse | null): { alerts: Alert[]; loaded: boolean } {
|
|
21
|
+
const [alerts, setAlerts] = useState<Alert[]>([]);
|
|
22
|
+
const [loaded, setLoaded] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!componentId || !env) return;
|
|
26
|
+
setLoaded(false);
|
|
27
|
+
setAlerts([]);
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
const url = `${env.apiBasePath}/${componentId}/alerts`;
|
|
30
|
+
const poll = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url, { credentials: "include" });
|
|
33
|
+
if (!res.ok) return;
|
|
34
|
+
const json = (await res.json()) as Alert[];
|
|
35
|
+
if (cancelled) return;
|
|
36
|
+
setAlerts(Array.isArray(json) ? json : []);
|
|
37
|
+
setLoaded(true);
|
|
38
|
+
} catch {
|
|
39
|
+
// Silent — proxy hiccups, transient errors. Stale alerts stay
|
|
40
|
+
// visible until the next poll succeeds.
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
void poll();
|
|
44
|
+
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
clearInterval(timer);
|
|
48
|
+
};
|
|
49
|
+
}, [componentId, env]);
|
|
50
|
+
|
|
51
|
+
return { alerts, loaded };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Derive a StatusPill tone from an alert list. Errors dominate warnings;
|
|
55
|
+
// warnings dominate the healthy case. When the endpoint hasn't answered
|
|
56
|
+
// yet, callers should fall back to the "unknown" tone via the second arg.
|
|
57
|
+
export function toneFromAlerts(alerts: Alert[], loaded: boolean, fallbackTone: StatusTone = "unknown"): StatusTone {
|
|
58
|
+
if (!loaded) return fallbackTone;
|
|
59
|
+
if (alerts.some((a) => a.level === "error")) return "error";
|
|
60
|
+
if (alerts.some((a) => a.level === "warning")) return "ready";
|
|
61
|
+
return "live";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Rewrite Studio's internal alert messages into copy that doesn't require
|
|
65
|
+
// knowing what a "component" is. Studio phrases everything in terms of the
|
|
66
|
+
// producing/consuming component; we recast for the operator reading the
|
|
67
|
+
// dashboard. Optional `context` lets callers narrow to a role-specific
|
|
68
|
+
// noun ("source", "camera", etc.).
|
|
69
|
+
export function humanizeAlert(
|
|
70
|
+
message: string,
|
|
71
|
+
context?: { subject?: string; kind?: "input" | "output" | "session" },
|
|
72
|
+
): string {
|
|
73
|
+
const subject = context?.subject ?? "This source";
|
|
74
|
+
if (/receiving no data/i.test(message)) {
|
|
75
|
+
return context?.kind === "input" ? "No data arriving from upstream" : `${subject}: nothing arriving yet`;
|
|
76
|
+
}
|
|
77
|
+
if (/sending no data/i.test(message)) {
|
|
78
|
+
return context?.kind === "output" ? "Not producing output yet" : `${subject}: not producing output yet`;
|
|
79
|
+
}
|
|
80
|
+
if (/no active connections/i.test(message)) return "No active connection";
|
|
81
|
+
return message.replace(/^This component /i, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Short label for the pill based on alert state. `livedLabel` and
|
|
85
|
+
// `errorLabel` let callers customise per row-type (e.g. "3 streams" vs
|
|
86
|
+
// "Publishing").
|
|
87
|
+
export function labelFromAlerts(
|
|
88
|
+
alerts: Alert[],
|
|
89
|
+
loaded: boolean,
|
|
90
|
+
labels: { live: string; warning?: string; error?: string; unknown?: string },
|
|
91
|
+
): string {
|
|
92
|
+
if (!loaded) return labels.unknown ?? "Connecting";
|
|
93
|
+
const errorCount = alerts.filter((a) => a.level === "error").length;
|
|
94
|
+
if (errorCount > 0) return labels.error ?? `${errorCount} error${errorCount === 1 ? "" : "s"}`;
|
|
95
|
+
const warningCount = alerts.filter((a) => a.level === "warning").length;
|
|
96
|
+
if (warningCount > 0) return labels.warning ?? "Warning";
|
|
97
|
+
return labels.live;
|
|
98
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentsResponse, EnvResponse, WorkflowComponentDescriptor } from "../api/types";
|
|
3
|
+
|
|
4
|
+
interface UseDiscoveryResult {
|
|
5
|
+
components: WorkflowComponentDescriptor[];
|
|
6
|
+
error: string | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
attempts: number;
|
|
9
|
+
nextRetryMs: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Bounded-exponential-backoff poller of `<apiBasePath>/components`. Studio
|
|
13
|
+
// returns 503 with `{error: "Workflow is not running yet"}` until the
|
|
14
|
+
// workflow boots; we keep polling until the response is 200 with a non-empty
|
|
15
|
+
// `components` array. Once we have it, we exit the loop and the component
|
|
16
|
+
// stays mounted regardless of later disconnects — the per-component WS hooks own
|
|
17
|
+
// their own reconnect.
|
|
18
|
+
const INITIAL_DELAY_MS = 500;
|
|
19
|
+
const MAX_DELAY_MS = 5000;
|
|
20
|
+
|
|
21
|
+
export function useDiscovery(env: EnvResponse | null): UseDiscoveryResult {
|
|
22
|
+
const [components, setComponents] = useState<WorkflowComponentDescriptor[]>([]);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [attempts, setAttempts] = useState(0);
|
|
26
|
+
const [nextRetryMs, setNextRetryMs] = useState<number | null>(null);
|
|
27
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!env) return;
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
let delay = INITIAL_DELAY_MS;
|
|
33
|
+
let attempt = 0;
|
|
34
|
+
|
|
35
|
+
const tick = async () => {
|
|
36
|
+
attempt += 1;
|
|
37
|
+
if (cancelled) return;
|
|
38
|
+
setAttempts(attempt);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${env.apiBasePath}/components`);
|
|
41
|
+
if (res.status === 503) {
|
|
42
|
+
// Workflow not yet running — bounded retry.
|
|
43
|
+
scheduleRetry();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new Error(`HTTP ${res.status}`);
|
|
48
|
+
}
|
|
49
|
+
const body = (await res.json()) as ComponentsResponse;
|
|
50
|
+
if (!body.components || body.components.length === 0) {
|
|
51
|
+
// Empty surface — keep polling.
|
|
52
|
+
scheduleRetry();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (cancelled) return;
|
|
56
|
+
setComponents(body.components);
|
|
57
|
+
setError(null);
|
|
58
|
+
setLoading(false);
|
|
59
|
+
setNextRetryMs(null);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (cancelled) return;
|
|
62
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
63
|
+
scheduleRetry();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const scheduleRetry = () => {
|
|
68
|
+
if (cancelled) return;
|
|
69
|
+
setNextRetryMs(delay);
|
|
70
|
+
timerRef.current = setTimeout(() => {
|
|
71
|
+
delay = Math.min(delay * 2, MAX_DELAY_MS);
|
|
72
|
+
void tick();
|
|
73
|
+
}, delay);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
void tick();
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
if (timerRef.current) {
|
|
81
|
+
clearTimeout(timerRef.current);
|
|
82
|
+
timerRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}, [env]);
|
|
86
|
+
|
|
87
|
+
return { components, error, loading, attempts, nextRetryMs };
|
|
88
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { EnvResponse } from "../api/types";
|
|
3
|
+
|
|
4
|
+
interface UseEnvResult {
|
|
5
|
+
env: EnvResponse | null;
|
|
6
|
+
error: string | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Resolves the dashboard's bound `env` endpoint. Studio serves
|
|
11
|
+
// `/dashboard/<workflowName>/env` and returns `{studioUrlPrefix, apiBasePath, wsBasePath}`.
|
|
12
|
+
// We fetch via a relative URL so the bundle works under arbitrary
|
|
13
|
+
// `studioUrlPrefix` deployments.
|
|
14
|
+
export function useEnv(): UseEnvResult {
|
|
15
|
+
const [env, setEnv] = useState<EnvResponse | null>(null);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
|
|
22
|
+
(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch("./env");
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`env returned HTTP ${res.status}`);
|
|
27
|
+
}
|
|
28
|
+
const body = (await res.json()) as Partial<EnvResponse>;
|
|
29
|
+
if (!body.wsBasePath || !body.apiBasePath) {
|
|
30
|
+
throw new Error("env response missing wsBasePath/apiBasePath");
|
|
31
|
+
}
|
|
32
|
+
if (cancelled) return;
|
|
33
|
+
setEnv({
|
|
34
|
+
studioUrlPrefix: body.studioUrlPrefix ?? "",
|
|
35
|
+
apiBasePath: body.apiBasePath,
|
|
36
|
+
wsBasePath: body.wsBasePath,
|
|
37
|
+
note: body.note,
|
|
38
|
+
});
|
|
39
|
+
setLoading(false);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
return { env, error, loading };
|
|
53
|
+
}
|