Lets do a debugging & polish session on https://forge.wilddragon.net/zgaetano/wild-dragon , its currently deployed to Truenas. http://10.0.0.25:47434/

SRT / Recorder / Ingest page is still buggy, preview is missing - files still don't seem to ingest properly. please test and verify - fix if needed. Monitors also seems broken.

Next I want another pass on all buttons, confirm everything works - the settings page is completly broken at the moment

Next I'd like to start to tackle growing file editing and the premier plugin with proxy editing and high res- lets wire that up and start prototyping (i'd love somthing to test when I get back). If we need to use a SMB share we can do that for the growing files and then have it move to S3 when it finishes.

Any inital questions? I'm about to leave


## claude.ai Context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


Lets do a debugging & polish session on https://forge.wilddragon.net/zgaetano/wild-dragon , its currently deployed to Truenas. http://10.0.0.25:47434/   SRT / Recorder / Ingest page is still buggy, pr…


Debug ingest, monitors, buttons and prototype file editing


1	- [User stack & infrastructure](user_stack.md) — Wild Dragon LLC: ERPNext, Forgejo, TrueNAS, 3CX, Voxtelesys
2	- [Autonomous execution preference](feedback_autonomy.md) — make the call, don't ask, document assumptions
3	- [Voxtelesys Integration project](project_voxtelesys_integration.md) — Frappe app state, architecture, planned work
4	- [MeshCentral install location](reference_meshcentral.md) — VM 10.0.0.235, install at /home/zgaetano/node_modules/meshcentral, mobile template paths
5	- [Wild Dragon MAM platform](project_wild_dragon.md) — Self-hosted MAM on zampp1+zampp2; main is deploy target; bmd-card.js + codec catalog pushed, recorders.html UI rewrite still pending
6	- [Cloudflare WAF blocks large MCP uploads](feedback_cloudflare_waf.md) — Block is on anthropic.com, not Forgejo; size-triggered; push from host or chunked b64 via MeshCentral
7

- [User stack & infrastructure](user_stack.md) — Wild Dragon LLC: ERPNext, Forgejo, TrueNAS, 3CX, Voxtelesys
- [Autonomous execution preference](feedback_autonomy.md) — make the call, don't ask, document assumptions
- [Voxtelesys Integration project](project_voxtelesys_integration.md) — Frappe app state, architecture, planned work
- [MeshCentral install location](reference_meshcentral.md) — VM 10.0.0.235, install at /home/zgaetano/node_modules/meshcentral, mobile template paths
- [Wild Dragon MAM platform](project_wild_dragon.md) — Self-hosted MAM on zampp1+zampp2; main is deploy target; bmd-card.js + codec catalog pushed, recorders.html UI rewrite still pending
- [Cloudflare WAF blocks large MCP uploads](feedback_cloudflare_waf.md) — Block is on anthropic.com, not Forgejo; size-triggered; push from host or chunked b64 via MeshCentral


1	---
2	name: Cloudflare WAF blocks large MCP uploads
3	description: Forgejo MCP and other MCP HTTP tools fail with a Cloudflare "Sorry, you have been blocked" page on anthropic.com when uploading large or pattern-heavy payloads.
4	type: feedback
5	originSessionId: 544a289a-0493-4194-9fbd-112ed250e221
6	---
7	When Forgejo MCP (`forgejo_upload_file`) or similar HTTP-backed MCP tools return a Cloudflare block page, **the blocked domain is `anthropic.com`, not the destination** (forge.wilddragon.net). The Cloudflare WAF is in front of Anthropic's MCP egress, not the user's Forgejo instance.
8
9	**Why:** The block triggers on:
10	1. Large request bodies (multi-KB file uploads via JSON-encoded tool params).
11	2. Specific content patterns — observed: Python CIDR-style regex like `r"^172\.(1[6-9]|2\d|3[01])\."` and any regex with digit-range alternations in tool arguments.
12
13	Rewriting the regex with plain integer parsing did NOT bypass the block on a ~7 KB upload, suggesting size alone was sufficient.
14
15	**How to apply:**
16	- For files >~3 KB that need to reach Forgejo, prefer one of these instead of `forgejo_upload_file`:
17	  - Push from the destination host via `git push` (requires `~/.git-credentials` or SSH key on the box — neither was configured on zampp1 as of 2026-05-21).
18	  - Ask the user to push from their local machine.
19	  - Chunked gzip+base64 via MeshCentral `run_command` works but is fragile (4096 char limit per command, easy to lose track of chunks during compaction).
20	- Tiny operations (delete a file, edit a couple lines) via Forgejo MCP work fine.
21	- Don't burn cycles trying to "outsmart" the WAF by rewriting content — the size threshold appears to apply regardless of what's inside.
22

---
name: Cloudflare WAF blocks large MCP uploads
description: Forgejo MCP and other MCP HTTP tools fail with a Cloudflare "Sorry, you have been blocked" page on anthropic.com when uploading large or pattern-heavy payloads.
type: feedback
originSessionId: 544a289a-0493-4194-9fbd-112ed250e221
---
When Forgejo MCP (`forgejo_upload_file`) or similar HTTP-backed MCP tools return a Cloudflare block page, **the blocked domain is `anthropic.com`, not the destination** (forge.wilddragon.net). The Cloudflare WAF is in front of Anthropic's MCP egress, not the user's Forgejo instance.

**Why:** The block triggers on:
1. Large request bodies (multi-KB file uploads via JSON-encoded tool params).
2. Specific content patterns — observed: Python CIDR-style regex like `r"^172\.(1[6-9]|2\d|3[01])\."` and any regex with digit-range alternations in tool arguments.

Rewriting the regex with plain integer parsing did NOT bypass the block on a ~7 KB upload, suggesting size alone was sufficient.

**How to apply:**
- For files >~3 KB that need to reach Forgejo, prefer one of these instead of `forgejo_upload_file`:
  - Push from the destination host via `git push` (requires `~/.git-credentials` or SSH key on the box — neither was configured on zampp1 as of 2026-05-21).
  - Ask the user to push from their local machine.
  - Chunked gzip+base64 via MeshCentral `run_command` works but is fragile (4096 char limit per command, easy to lose track of chunks during compaction).
- Tiny operations (delete a file, edit a couple lines) via Forgejo MCP work fine.
- Don't burn cycles trying to "outsmart" the WAF by rewriting content — the size threshold appears to apply regardless of what's inside.


1	---
2	name: Wild Dragon MAM platform
3	description: Self-hosted media asset management replacing Grass Valley AMPP FramelightX. Deployed on zampp1 (primary) and zampp2 (worker w/ DeckLink Duo 2). Repo at forge.wilddragon.net/zgaetano/wild-dragon.
4	type: project
5	originSessionId: 544a289a-0493-4194-9fbd-112ed250e221
6	---
7	Self-hosted MAM platform under active development. Stack: Node.js/Express, vanilla HTML/CSS/JS frontend, PostgreSQL 16, Redis 7 + BullMQ, S3-compatible (RustFS), FFmpeg, Blackmagic DeckLink SDK, Docker Compose. Repo: `forge.wilddragon.net/zgaetano/wild-dragon` (HTTPS only, no SSH).
8
9	**Why:** Replacing Grass Valley AMPP FramelightX with an in-house alternative. Test deployment is the production deployment — the user authorizes pushing directly to `main` and deploying to zampp1 + zampp2 for continued testing.
10
11	**How to apply:** Treat zampp1/zampp2 as authorized targets for MeshCentral / live deploy. Push to `main` directly when changes are validated. The repo's `deploy/onboard-node.sh` is the canonical worker provisioning path.
12
13	## Cluster topology (as of 2026-05-22)
14	- **zampp1** — primary; runs `mam-api`, `db`, `redis`, `web-ui`, `worker`, `capture` containers via `docker-compose.yml`. Postgres (5432) and Redis (6379) are now published on the host for worker-node connectivity.
15	- **zampp2** — worker ONLY; runs `node-agent` (host-network, `restart: unless-stopped`) via `docker-compose.worker.yml`. Does NOT run the primary stack. Has a **DeckLink Duo 2** (4 BNC ports at `/dev/blackmagic/io0..io3`).
16	- zampp2's `.env` points DATABASE_URL/REDIS_URL at `172.18.91.216` (zampp1). Using `.env` (not `.env.worker`); project name is `wild-dragon` (not the `wild-dragon-worker` that onboard-node.sh would create).
17	- Workers heartbeat to `POST /api/v1/cluster/heartbeat`; list at `GET /api/v1/cluster/`.
18	- Cluster registry: unique index on `cluster_nodes.hostname` (migration 007); `pickIp()` in `routes/cluster.js` now only flags `172.17.x` as docker bridge (NOT the full 172.16/12 block — real LAN 172.18.91.x was being falsely flagged).
19
20	## Codec selection (recorders)
21	Per-recorder columns added in migration 008 (`recording_*` and `proxy_*` for video bitrate, framerate, audio codec/bitrate/channels, container). `services/capture/src/capture-manager.js` exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT` catalogs. `routes/recorders.js` `RECORDER_FIELDS` whitelist passes settings as env vars to the capture sidecar via `bootstrapAutoStart()`.
22
23	## DeckLink port picker
24	`services/web-ui/public/js/bmd-card.js` (window.BMDCards) renders an SVG diagram of the card with click-to-select ports. Models registered: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. Backed by `GET /api/v1/cluster/devices/blackmagic` which flattens every node's DeckLink capabilities. **The `recorders.html` rewrite that consumes this was lost** in a context-compaction event — file on zampp1 still matches the old (pre-tabs, pre-SVG) version.
25
26	## LAN topology gotcha
27	The user's LAN is **172.18.91.0/24** — inside RFC1918 *and* inside Docker's reserved 172.16.0.0/12 range. zampp1=172.18.91.216, zampp2=172.18.91.217. `pickIp()` in `routes/cluster.js` was fixed (2026-05-22) to only flag 172.17.x as docker bridge, not all of 172.16/12. The smoke test in `deploy/test-cluster.sh` already had this fix.
28
29	## Pending work (as of 2026-05-22)
30	1. **`recorders.html` UI rewrite** — tabbed codec settings (Video/Audio/Container) for both master & proxy, node selector + BMD card SVG picker for SDI source. Renderer + endpoint are in `main`; the HTML that wires them up was lost to compaction and never reached Forgejo.
31	2. **GUI polish pass with flyonui MCP** — explicitly the LAST priority.
32
33	## DeckLink fix (issue #10, closed) — infra layer
34	- `recorders.js` now binds `/dev/blackmagic` for SDI local sidecars
35	- `resolveNodeTarget()` routes SDI sidecars to the correct remote node-agent
36	- `node-agent` v1.2.0: `POST /sidecar/start`, `DELETE /sidecar/:id`, `GET /sidecar/:id/status`
37	- `docker-compose.worker.yml`: node-agent gets `/var/run/docker.sock` + `LIVE_DIR`
38	- Sidecar port scheme for remote nodes: `7438 + deviceIndex` (device 0 → 7438, device 1 → 7439)
39	- All deployed to zampp1+zampp2 on 2026-05-22. FFmpeg now opens the card and reads audio.
40
41	## DeckLink end-to-end working (commit 30b4def, 2026-05-22)
42	- `services/capture/patch_decklink.py` now applies `services/capture/decklink-sdk16.patch` (~346 lines) via `git apply -R` instead of regex stunts
43	- The patch is `git diff release/7.1 origin/master -- 'libavdevice/decklink_{enc,dec,common}{.cpp,.h}'` — upstream's own SDK 16 migration that renames every IDeckLink* interface to its `_v14_2_1` versioned form consistently
44	- Verified zampp2 SDI1 recorder: 423 frames in 14 sec @ 29.97 fps, ProRes HQ 91 Mbps clean
45	- **Why the old patch failed:** Fix 1 renamed the allocator class to `_v14_2_1`, but Fixes 2+3 stubbed out the matching `SetVideoInputFrameMemoryAllocator` call and replaced `videoFrame->GetBytes()` with a QueryInterface-around-IDeckLinkVideoBuffer dance — mixing two different API generations. Compiled, didn't crash, silently dropped every video frame
46	- **How to apply:** When SDK or FFmpeg revs, regenerate the patch with `git diff <old> <new> -- libavdevice/decklink_*` against an FFmpeg tree built with `--enable-decklink` and drop into `services/capture/decklink-sdk16.patch`
47	- **Bisection trick:** Build the BMD-provided Linux Capture sample from `services/capture/sdk/extracted/Blackmagic DeckLink SDK 16.0/Linux/Samples/Capture` to ground-truth whether a frame-zero problem is signal vs FFmpeg
48	- DeckLink Duo Mini active ports are 0 and 1 (Duo (1) and Duo (2)); ports 2-3 are inactive on this card
49
50	## zampp1 vs zampp2 layout (as of 2026-05-22)
51	- Repos checked out at `/opt/wild-dragon` on both
52	- zampp2 hostname is `zampp2`; zampp1 hostname is **`zampp`** (mam-api sets `NODE_HOSTNAME=zampp1` so `cluster_nodes.hostname` is `zampp1`)
53	- zampp2 runs ONLY node-agent now (primary stack torn down 2026-05-22)
54	- DeckLink Duo Mini device paths are `/dev/blackmagic/io0..io3` (NOT `dv0..dv3`); only port io1 (device index 1) shows any signal during testing
55	- mam-api is published on **port 47432** on zampp2's `.env` (historical — zampp2 no longer runs mam-api)
56	- Git creds installed at `/root/.git-credentials` on both zampp1 and zampp2
57
58	## Pushed commits (already on `main`)
59	- `3b4af6e` node-agent: prefer host LAN IP, NODE_IP override
60	- `0efef0d` cluster route: pickIp() + /devices/blackmagic endpoint
61	- `a39c983` migration 007: dedupe hostnames + unique index
62	- `049beb8` migration 008: expanded codec columns
63	- `40a66ba` worker compose: network_mode: host for node-agent
64	- `f4a83ee` capture-manager: dynamic ffmpeg args
65	- `485af25` capture index.js: bootstrap reads codec env vars
66	- `4c65753` recorders route: full codec field whitelist
67	- `0ebb3cf` onboard-node: auto-detect host LAN IP
68	- `d39f86d` web-ui: bmd-card.js
69	- `97628bb` chore: remove cloudflare rate-limit probe (.touchtest)
70	- `8aa3783` deploy: test-cluster.sh + .touchtest cleanup
71	- `4a3a672` cluster: stable hostname for mam-api (NODE_HOSTNAME env), jq-based smoke test
72	- `486e3c2` fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
73	- `30b4def` fix(decklink): proper SDK 16 patch via git apply -R
74	- `00f3f29` feat(cluster): expose db/redis ports for worker-node connectivity
75	- `37767f9` fix(cluster): pickIp() only treats 172.17.x as docker bridge
76
77	## Git creds on zampp1
78	`/root/.git-credentials` holds the Forgejo PAT for user `zgaetano`. `credential.helper=store` is set in `/root/.gitconfig`. Future pushes from zampp1 just need `HOME=/root` exported (the MeshCentral agent's default $HOME is `/usr/local/mesh_services/Mesh Dragon/MeshDragon`). Rotate the PAT by overwriting that one file.
79

---
name: Wild Dragon MAM platform
description: Self-hosted media asset management replacing Grass Valley AMPP FramelightX. Deployed on zampp1 (primary) and zampp2 (worker w/ DeckLink Duo 2). Repo at forge.wilddragon.net/zgaetano/wild-dragon.
type: project
originSessionId: 544a289a-0493-4194-9fbd-112ed250e221
---
Self-hosted MAM platform under active development. Stack: Node.js/Express, vanilla HTML/CSS/JS frontend, PostgreSQL 16, Redis 7 + BullMQ, S3-compatible (RustFS), FFmpeg, Blackmagic DeckLink SDK, Docker Compose. Repo: `forge.wilddragon.net/zgaetano/wild-dragon` (HTTPS only, no SSH).

**Why:** Replacing Grass Valley AMPP FramelightX with an in-house alternative. Test deployment is the production deployment — the user authorizes pushing directly to `main` and deploying to zampp1 + zampp2 for continued testing.

**How to apply:** Treat zampp1/zampp2 as authorized targets for MeshCentral / live deploy. Push to `main` directly when changes are validated. The repo's `deploy/onboard-node.sh` is the canonical worker provisioning path.

## Cluster topology (as of 2026-05-22)
- **zampp1** — primary; runs `mam-api`, `db`, `redis`, `web-ui`, `worker`, `capture` containers via `docker-compose.yml`. Postgres (5432) and Redis (6379) are now published on the host for worker-node connectivity.
- **zampp2** — worker ONLY; runs `node-agent` (host-network, `restart: unless-stopped`) via `docker-compose.worker.yml`. Does NOT run the primary stack. Has a **DeckLink Duo 2** (4 BNC ports at `/dev/blackmagic/io0..io3`).
- zampp2's `.env` points DATABASE_URL/REDIS_URL at `172.18.91.216` (zampp1). Using `.env` (not `.env.worker`); project name is `wild-dragon` (not the `wild-dragon-worker` that onboard-node.sh would create).
- Workers heartbeat to `POST /api/v1/cluster/heartbeat`; list at `GET /api/v1/cluster/`.
- Cluster registry: unique index on `cluster_nodes.hostname` (migration 007); `pickIp()` in `routes/cluster.js` now only flags `172.17.x` as docker bridge (NOT the full 172.16/12 block — real LAN 172.18.91.x was being falsely flagged).

## Codec selection (recorders)
Per-recorder columns added in migration 008 (`recording_*` and `proxy_*` for video bitrate, framerate, audio codec/bitrate/channels, container). `services/capture/src/capture-manager.js` exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT` catalogs. `routes/recorders.js` `RECORDER_FIELDS` whitelist passes settings as env vars to the capture sidecar via `bootstrapAutoStart()`.

## DeckLink port picker
`services/web-ui/public/js/bmd-card.js` (window.BMDCards) renders an SVG diagram of the card with click-to-select ports. Models registered: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. Backed by `GET /api/v1/cluster/devices/blackmagic` which flattens every node's DeckLink capabilities. **The `recorders.html` rewrite that consumes this was lost** in a context-compaction event — file on zampp1 still matches the old (pre-tabs, pre-SVG) version.

## LAN topology gotcha
The user's LAN is **172.18.91.0/24** — inside RFC1918 *and* inside Docker's reserved 172.16.0.0/12 range. zampp1=172.18.91.216, zampp2=172.18.91.217. `pickIp()` in `routes/cluster.js` was fixed (2026-05-22) to only flag 172.17.x as docker bridge, not all of 172.16/12. The smoke test in `deploy/test-cluster.sh` already had this fix.

## Pending work (as of 2026-05-22)
1. **`recorders.html` UI rewrite** — tabbed codec settings (Video/Audio/Container) for both master & proxy, node selector + BMD card SVG picker for SDI source. Renderer + endpoint are in `main`; the HTML that wires them up was lost to compaction and never reached Forgejo.
2. **GUI polish pass with flyonui MCP** — explicitly the LAST priority.

## DeckLink fix (issue #10, closed) — infra layer
- `recorders.js` now binds `/dev/blackmagic` for SDI local sidecars
- `resolveNodeTarget()` routes SDI sidecars to the correct remote node-agent
- `node-agent` v1.2.0: `POST /sidecar/start`, `DELETE /sidecar/:id`, `GET /sidecar/:id/status`
- `docker-compose.worker.yml`: node-agent gets `/var/run/docker.sock` + `LIVE_DIR`
- Sidecar port scheme for remote nodes: `7438 + deviceIndex` (device 0 → 7438, device 1 → 7439)
- All deployed to zampp1+zampp2 on 2026-05-22. FFmpeg now opens the card and reads audio.

## DeckLink end-to-end working (commit 30b4def, 2026-05-22)
- `services/capture/patch_decklink.py` now applies `services/capture/decklink-sdk16.patch` (~346 lines) via `git apply -R` instead of regex stunts
- The patch is `git diff release/7.1 origin/master -- 'libavdevice/decklink_{enc,dec,common}{.cpp,.h}'` — upstream's own SDK 16 migration that renames every IDeckLink* interface to its `_v14_2_1` versioned form consistently
- Verified zampp2 SDI1 recorder: 423 frames in 14 sec @ 29.97 fps, ProRes HQ 91 Mbps clean
- **Why the old patch failed:** Fix 1 renamed the allocator class to `_v14_2_1`, but Fixes 2+3 stubbed out the matching `SetVideoInputFrameMemoryAllocator` call and replaced `videoFrame->GetBytes()` with a QueryInterface-around-IDeckLinkVideoBuffer dance — mixing two different API generations. Compiled, didn't crash, silently dropped every video frame
- **How to apply:** When SDK or FFmpeg revs, regenerate the patch with `git diff <old> <new> -- libavdevice/decklink_*` against an FFmpeg tree built with `--enable-decklink` and drop into `services/capture/decklink-sdk16.patch`
- **Bisection trick:** Build the BMD-provided Linux Capture sample from `services/capture/sdk/extracted/Blackmagic DeckLink SDK 16.0/Linux/Samples/Capture` to ground-truth whether a frame-zero problem is signal vs FFmpeg
- DeckLink Duo Mini active ports are 0 and 1 (Duo (1) and Duo (2)); ports 2-3 are inactive on this card

## zampp1 vs zampp2 layout (as of 2026-05-22)
- Repos checked out at `/opt/wild-dragon` on both
- zampp2 hostname is `zampp2`; zampp1 hostname is **`zampp`** (mam-api sets `NODE_HOSTNAME=zampp1` so `cluster_nodes.hostname` is `zampp1`)
- zampp2 runs ONLY node-agent now (primary stack torn down 2026-05-22)
- DeckLink Duo Mini device paths are `/dev/blackmagic/io0..io3` (NOT `dv0..dv3`); only port io1 (device index 1) shows any signal during testing
- mam-api is published on **port 47432** on zampp2's `.env` (historical — zampp2 no longer runs mam-api)
- Git creds installed at `/root/.git-credentials` on both zampp1 and zampp2

## Pushed commits (already on `main`)
- `3b4af6e` node-agent: prefer host LAN IP, NODE_IP override
- `0efef0d` cluster route: pickIp() + /devices/blackmagic endpoint
- `a39c983` migration 007: dedupe hostnames + unique index
- `049beb8` migration 008: expanded codec columns
- `40a66ba` worker compose: network_mode: host for node-agent
- `f4a83ee` capture-manager: dynamic ffmpeg args
- `485af25` capture index.js: bootstrap reads codec env vars
- `4c65753` recorders route: full codec field whitelist
- `0ebb3cf` onboard-node: auto-detect host LAN IP
- `d39f86d` web-ui: bmd-card.js
- `97628bb` chore: remove cloudflare rate-limit probe (.touchtest)
- `8aa3783` deploy: test-cluster.sh + .touchtest cleanup
- `4a3a672` cluster: stable hostname for mam-api (NODE_HOSTNAME env), jq-based smoke test
- `486e3c2` fix(decklink): mount /dev/blackmagic in sidecar + remote node routing via node-agent
- `30b4def` fix(decklink): proper SDK 16 patch via git apply -R
- `00f3f29` feat(cluster): expose db/redis ports for worker-node connectivity
- `37767f9` fix(cluster): pickIp() only treats 172.17.x as docker bridge

## Git creds on zampp1
`/root/.git-credentials` holds the Forgejo PAT for user `zgaetano`. `credential.helper=store` is set in `/root/.gitconfig`. Future pushes from zampp1 just need `HOME=/root` exported (the MeshCentral agent's default $HOME is `/usr/local/mesh_services/Mesh Dragon/MeshDragon`). Rotate the PAT by overwriting that one file.


<system-reminder>This memory is 10 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.</system-reminder>
1	---
2	name: Autonomous execution preference
3	description: User prefers Claude to make reasonable calls and continue rather than pause for clarifying questions, especially during long-running sessions
4	type: feedback
5	originSessionId: 2352cf59-de5a-4440-8480-69099e3a4cbb
6	---
7	When given a multi-step build task, do not stop for clarifying questions — make the reasonable call and continue. The user will redirect if needed.
8
9	**Why:** User explicitly stated this mid-session: "I am going to bed so you will have to act alone." They value forward progress over confirmation, even on architectural choices.
10
11	**How to apply:**
12	- Pick sensible defaults and call them out in the final summary
13	- If a decision is genuinely irreversible (deleting work, pushing to prod), still pause — autonomy ≠ recklessness
14	- Document assumptions in code comments or README so the user can audit on wake-up
15	- Don't ask "do you want me to continue?" — just continue
16

---
name: Autonomous execution preference
description: User prefers Claude to make reasonable calls and continue rather than pause for clarifying questions, especially during long-running sessions
type: feedback
originSessionId: 2352cf59-de5a-4440-8480-69099e3a4cbb
---
When given a multi-step build task, do not stop for clarifying questions — make the reasonable call and continue. The user will redirect if needed.

**Why:** User explicitly stated this mid-session: "I am going to bed so you will have to act alone." They value forward progress over confirmation, even on architectural choices.

**How to apply:**
- Pick sensible defaults and call them out in the final summary
- If a decision is genuinely irreversible (deleting work, pushing to prod), still pause — autonomy ≠ recklessness
- Document assumptions in code comments or README so the user can audit on wake-up
- Don't ask "do you want me to continue?" — just continue


<system-reminder>This memory is 10 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.</system-reminder>
1	---
2	name: User stack & infrastructure
3	description: Self-hosted infra Zachary runs at Wild Dragon LLC — ERPNext, Forgejo, TrueNAS, 3CX, Voxtelesys
4	type: user
5	originSessionId: 2352cf59-de5a-4440-8480-69099e3a4cbb
6	---
7	Zachary Gaetano runs Wild Dragon LLC (broadcast/media tech). His self-hosted stack:
8
9	- **ERPNext** at `erp.broadcastmgmt.cloud` (Frappe v15 / Helpdesk app) — accessible via MCP tools
10	- **Forgejo** at `forge.wilddragon.net`, username `zgaetano` — primary git host; accessible via MCP tools
11	- **TrueNAS SCALE** is the underlying platform (also available via MCP)
12	- **3CX PBX** with **Voxtelesys** as the SIP trunk carrier
13	- Many broadcast-focused repos: Grass Valley AMPP tools, Dragon Wind uploader, DragonRelay (Moonlight relay), Wild Dragon MAM, datarhei/restreamer forks, etc.
14	- Email: zgaetano@wilddragon.net; company email: it@broadcastmgmt.com
15
16	Strong technical operator — multi-project portfolio, comfortable with Python/Frappe/Docker/SDI infrastructure. Prefers terse, action-oriented work. Frame answers in terms of practical implementation rather than tutorial-style hand-holding.
17

---
name: User stack & infrastructure
description: Self-hosted infra Zachary runs at Wild Dragon LLC — ERPNext, Forgejo, TrueNAS, 3CX, Voxtelesys
type: user
originSessionId: 2352cf59-de5a-4440-8480-69099e3a4cbb
---
Zachary Gaetano runs Wild Dragon LLC (broadcast/media tech). His self-hosted stack:

- **ERPNext** at `erp.broadcastmgmt.cloud` (Frappe v15 / Helpdesk app) — accessible via MCP tools
- **Forgejo** at `forge.wilddragon.net`, username `zgaetano` — primary git host; accessible via MCP tools
- **TrueNAS SCALE** is the underlying platform (also available via MCP)
- **3CX PBX** with **Voxtelesys** as the SIP trunk carrier
- Many broadcast-focused repos: Grass Valley AMPP tools, Dragon Wind uploader, DragonRelay (Moonlight relay), Wild Dragon MAM, datarhei/restreamer forks, etc.
- Email: zgaetano@wilddragon.net; company email: it@broadcastmgmt.com

Strong technical operator — multi-project portfolio, comfortable with Python/Frappe/Docker/SDI infrastructure. Prefers terse, action-oriented work. Frame answers in terms of practical implementation rather than tutorial-style hand-holding.


Going to dive in autonomously per your stated preference. A few assumptions I'll lock in so you can override before you leave if any are wrong:

1. **SMB share** for growing files: I'll create a dedicated dataset on TrueNAS (`/mnt/<pool>/wild-dragon/growing/`) with SMB share `mam-growing` — workers write here, mam-api swaps the file to S3 (RustFS) on EOF detection.
2. **Premier plugin**: CEP extension (broader version support than UXP) targeting CC 2024+. Proxy-switching panel with three modes: *Live (growing)*, *Proxy*, *High-res*. Talks to mam-api over WebSocket for state.
3. **Push to `main`** for fixes once verified (per project memory). Prototype Premier panel will go in a new `services/premier-panel/` directory.
4. The "broken" Monitors and Settings — I'll snapshot the deployed UI first, then diagnose against source.

Starting reconnaissance now: cloning the repo, walking the deployed UI with puppeteer, and checking TrueNAS pools for the SMB landing zone in parallel.


TodoWrite

Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Fix Recorder/Ingest page: preview + ingest pipeline

Fix Monitors page

Fix Settings page (completely broken)

Button audit pass across all pages

Provision SMB growing-files share on TrueNAS

Implement growing-file → S3 promotion pipeline

Prototype Premier CEP panel with proxy switching

Deploy to zampp1 + push to main


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Fix Recorder/Ingest page: preview + ingest pipeline

Fix Monitors page

Fix Settings page (completely broken)

Button audit pass across all pages

Provision SMB growing-files share on TrueNAS

Implement growing-file → S3 promotion pipeline

Prototype Premier CEP panel with proxy switching

Deploy to zampp1 + push to main


Bash

mkdir -p /tmp/wd && cd /tmp/wd && git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git 2>&1 | tail -20

Clone wild-dragon repo


Cloning into 'wild-dragon'...
fatal: unable to access 'https://forge.wilddragon.net/zgaetano/wild-dragon.git/': server certificate verification failed. CAfile: none CRLfile: none
Shell cwd was reset to /home/node


Dragonflight · Wild Dragon Broadcast

Dragonflight · Wild Dragon Broadcast

Dragonflight · Wild Dragon Broadcast


cd /tmp/wd && git -c http.sslVerify=false clone https://forge.wilddragon.net/zgaetano/wild-dragon.git 2>&1 | tail -10

Clone with TLS verify off


Cloning into 'wild-dragon'...
Shell cwd was reset to /home/node


Error: result (142,743 characters) exceeds maximum allowed tokens. Output has been saved to /home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489874229.txt.
Format: JSON with schema: {result: {status: string, format: string, base64: string, url: string}}
- For targeted queries (find a value, filter by field): use jq on the file directly.
- For analysis or summarization that requires reading the full content: first probe the structure (e.g., jq 'type, length, keys?' /home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489874229.txt), then extract slices with jq or python — Read's line-based offset/limit will not chunk this file.
- If the Agent tool is available, do this inside a subagent so the full output stays out of your main context. Give it the instruction above verbatim, and be explicit about what it must return — e.g. "/home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489874229.txt is JSON with schema: {result: {status: string, format: string, base64: string, url: string}}; probe the structure with jq (type/length/keys), then extract and read the content in full with jq or python, then summarize and quote any key findings verbatim." A vague "summarize this" may lose detail.


cd /tmp/wd/wild-dragon && git log --oneline -20 && echo "---" && ls -la && echo "---" && git branch -a

Inspect cloned repo


3fc8fbc add per-seat/per-stream/per-month strikethrough hero to Tokens page
ceceedf probe fallback: basic TCP/UDP connectivity when capture service is offline
4864db0 probe: fallback to basic TCP/UDP connectivity check when capture service is offline
8b57a9a expand codec list, add MXF container, remove proxy settings (fixed profile)
fa787bb fix hover-to-play: remove status filter so any asset triggers stream fetch
0aa0922 remove hardcoded recorders badge
1abf226 feat: hover-to-play video preview on library asset cards
4afd0c7 feat: add /cluster/containers endpoint via Docker socket
6f2de45 feat: wire real video playback via GET /assets/:id/stream
e3c3d60 index: add hls.js for live stream HLS playback
81324c8 shell: add Field component (used by modal-new-recorder, was missing from global scope)
bec58ab screens-asset: fix thumbGrad crash, parseDuration NaN, guard missing ACTIVITY
451bed8 screens-admin: wire all buttons — invite user, export CSV, cluster refresh, container logs/restart, node drain/remove
d00e1c6 screens-ingest: wire delete button on RecorderRow
ddb4cf0 feat: add POST /jobs/:id/retry endpoint for re-queuing failed BullMQ jobs
fea0f29 fix: wire Jobs Retry (POST /jobs/:id/retry) and Delete (DELETE /jobs/:id) buttons
506ee2d fix: wire New Project button — modal + POST /projects + state refresh
88689a4 fix: wire Library Upload button to navigate to Upload screen
dc269be fix: make Settings S3 form functional — load from API, save & test
665ab52 feat: live status polling in RecorderRow, immediate refresh on mount
---
total 63
drwxr-xr-x 6 node node   13 May 22 22:44 .
drwxr-xr-x 3 node node    3 May 22 22:44 ..
-rw-r--r-- 1 node node  577 May 22 22:44 .env.example
drwxr-xr-x 8 node node   13 May 22 22:44 .git
-rw-r--r-- 1 node node  490 May 22 22:44 .gitignore
-rw-r--r-- 1 node node 1773 May 22 22:44 README.md
drwxr-xr-x 2 node node    5 May 22 22:44 deploy
-rw-r--r-- 1 node node 1115 May 22 22:44 docker-compose.gpu.yml
-rw-r--r-- 1 node node 4145 May 22 22:44 docker-compose.worker.yml
-rw-r--r-- 1 node node 3384 May 22 22:44 docker-compose.yml
drwxr-xr-x 3 node node    3 May 22 22:44 docs
drwxr-xr-x 9 node node    9 May 22 22:44 services
-rw-r--r-- 1 node node 1092 May 22 22:44 setup-repo.sh
---
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/fix/library-and-signal-indicator
  remotes/origin/fix/srt-rtmp-thumbnail
  remotes/origin/main
Shell cwd was reset to /home/node


F=/home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489874229.txt && python3 -c "
import json
d=json.load(open('$F'))
b=d['result']['base64']
import base64
open('/tmp/mam-home.png','wb').write(base64.b64decode(b))
print('wrote /tmp/mam-home.png', len(b))
"

Decode home screenshot


wrote /tmp/mam-home.png 142644


JSON.stringify({title: document.title, links: Array.from(document.querySelectorAll('a')).map(a => ({href: a.getAttribute('href'), text: a.innerText.trim().slice(0,40)})).filter(l=>l.href).slice(0,40), navText: document.body.innerText.slice(0,800)})


JS evaluation failed: Protocol error (Runtime.callFunctionOn): Given expression does not evaluate to a function


(()=>JSON.stringify({title: document.title, links: Array.from(document.querySelectorAll('a')).map(a => ({href: a.getAttribute('href'), text: a.innerText.trim().slice(0,40)})).filter(l=>l.href).slice(0,40), navText: document.body.innerText.slice(0,800)}))()


cd /tmp/wd/wild-dragon && find services -maxdepth 2 -type d

List services subdirs


services
services/capture
services/capture/src
services/node-agent
services/mam-api
services/mam-api/src
services/worker
services/worker/src
services/editor
services/editor/apps
services/editor/packages
services/editor/infra
services/editor/scripts
services/editor/.serena
services/premiere-plugin
services/premiere-plugin/css
services/premiere-plugin/js
services/premiere-plugin/CSXS
services/premiere-plugin/jsx
services/web-ui
services/web-ui/public
services/web-ui/src
Shell cwd was reset to /home/node


Fix Monitors page

Fix Settings page (completely broken)

Button audit pass across all pages

Deploy to zampp1 + push to main


Home renders cleanly — 7 assets, 12 failed jobs, 1 cluster node up. Good news: a `services/premiere-plugin/` scaffold already exists, so I'll build on that rather than start fresh. Walking the broken pages now.


_primitives-smoke.html
api-tokens.html
app.jsx
capture.html
cluster.html
containers.html
css
data.jsx
edit.html
editor.html
favicon.ico
fonts
home.html
icons.jsx
img
index.html
jobs.html
js
login.html
modal-new-recorder.jsx
player.html
projects.html
recorders.html
screens-admin.jsx
screens-asset.jsx
screens-editor.jsx
screens-home.jsx
screens-ingest.jsx
screens-jobs.jsx
screens-library.jsx
screens-projects.jsx
settings.html
shell.jsx
styles-asset.css
styles-fixes.css
styles-modal.css
styles-rest.css
styles-screens.css
styles.css
tokens.html
tweaks-panel.jsx
upload.html
users.html
visuals.jsx
---src---
css
Shell cwd was reset to /home/node


cd /tmp/wd/wild-dragon && ls services/premiere-plugin/ && echo "---CSXS---" && ls services/premiere-plugin/CSXS/ && echo "---jsx---" && ls services/premiere-plugin/jsx/ && echo "---js---" && ls services/premiere-plugin/js/

Inspect premiere-plugin scaffold


CSXS
PLUGIN_FILES.txt
QUICK_START.md
README.md
css
index.html
install-windows.ps1
js
jsx
---CSXS---
manifest.xml
---jsx---
premiere.jsx
---js---
CSInterface.js
main.js
Shell cwd was reset to /home/node


cd /tmp/wd/wild-dragon && ls services/web-ui/public/ && echo "---src---" && ls services/web-ui/src/ 2>/dev/null

Inspect web-ui structure


Error: result (142,761 characters) exceeds maximum allowed tokens. Output has been saved to /home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489905635.txt.
Format: JSON with schema: {result: {status: string, format: string, base64: string, url: string}}
- For targeted queries (find a value, filter by field): use jq on the file directly.
- For analysis or summarization that requires reading the full content: first probe the structure (e.g., jq 'type, length, keys?' /home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489905635.txt), then extract slices with jq or python — Read's line-based offset/limit will not chunk this file.
- If the Agent tool is available, do this inside a subagent so the full output stays out of your main context. Give it the instruction above verbatim, and be explicit about what it must return — e.g. "/home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489905635.txt is JSON with schema: {result: {status: string, format: string, base64: string, url: string}}; probe the structure with jq (type/length/keys), then extract and read the content in full with jq or python, then summarize and quote any key findings verbatim." A vague "summarize this" may lose detail.


() => ({title: document.title, url: location.href, hash: location.hash, text: document.body.innerText.slice(0, 2000), btnCount: document.querySelectorAll('button').length})


F=/home/node/.claude/projects/-home-node/9bc4deb8-dba9-4ac2-a16c-f5004f903927/tool-results/mcp-claude_ai_mcp-server-puppeteer_screenshot-1779489905635.txt && python3 -c "
import json,base64
d=json.load(open('$F'))
open('/tmp/mam-recorders.png','wb').write(base64.b64decode(d['result']['base64']))
print('ok')
"

Decode recorders screenshot


Dragonflight · Wild Dragon Broadcast

D
Dragonflight
v1.2.0
Home
Library
Projects
Ingest
Upload
Recorders
Capture
Monitors
Jobs
3
Editor
DEV
ADMIN
Users
Tokens
Containers
Cluster
Settings
ZG
Zach Gaetano
admin · broadcast
Home
⌘K
cluster healthy
Dragonflight

7 assets

LIBRARY
7
Total assets
LIVE FEEDS
0
1 recorders configured
JOBS
0 / 57 done
12 failed
CLUSTER NODES
1 / 1 online
Last heartbeat <30s
Recently added
All assets
Test1_20260522_224002 in qa-smoke
3m ago
WildDragon Sync Test.mp4 in qa-smoke
18m ago
Test_20260522_221337 in qa-smoke
21m ago
Test_20260522_221056 in qa-smoke
30m ago
Test_20260522_214925 in qa-smoke
33m ago
WildDragon Sync Test.mp4 in qa-smoke
1h ago
Job queue
View all
Thumbnail
·
—
done
Proxy
·
—
done
Thumbnail
·
—
done
Proxy
·
—
done
Thumbnail
·
—
done
Cluster
View all
9b2b0b88-0129-4729-b537-9b2936e96079
CPU 4%
105.1GB RAM

Dragonflight · Wild Dragon Broadcast

Dragonflight · Wild Dragon Broadcast


ok


Failed to click 'a[href*="recorder"], a[href*="Recorder"]': Waiting for selector "a[href*="recorder"], a[href*="Recorder"]" failed: timeout 5000ms exceeds.


1	<!doctype html>
2
3
4	<meta charset="utf-8" />
5	<meta name="viewport" content="width=device-width, initial-scale=1" />
6	<title>Dragonflight · Wild Dragon Broadcast</title>
7	<link rel="preconnect" href="https://fonts.googleapis.com" />
8	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9	<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" />
10	<link rel="stylesheet" href="styles.css" />
11	<link rel="stylesheet" href="styles-screens.css" />
12	<link rel="stylesheet" href="styles-asset.css" />
13	<link rel="stylesheet" href="styles-rest.css" />
14	<link rel="stylesheet" href="styles-modal.css" />
15	<link rel="stylesheet" href="styles-fixes.css" />
16
17
18

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

<!doctype html>

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dragonflight · Wild Dragon Broadcast</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="styles-screens.css" />
<link rel="stylesheet" href="styles-asset.css" />
<link rel="stylesheet" href="styles-rest.css" />
<link rel="stylesheet" href="styles-modal.css" />
<link rel="stylesheet" href="styles-fixes.css" />


() => { const sb = document.querySelector('aside') || document.querySelector('nav') || document.body; const links = Array.from(document.querySelectorAll('a,button')).map(el=>({tag:el.tagName, href:el.getAttribute('href')||'', text:(el.innerText||'').trim().slice(0,30), classes:el.className.slice(0,50)})).filter(l=>l.text); return JSON.stringify(links).slice(0,3000); }


All assets

View all

View all

All assets

View all

View all

All assets

View all

View all


1	// app.jsx — main shell
2
3	const ACCENT = '#5B7CFA';
4
5	function App() {
6	  const [route, setRoute] = React.useState('home');
7	  const [openAsset, setOpenAsset] = React.useState(null);
8	  const [openProject, setOpenProject] = React.useState(null);
9	  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
10	  const [dataReady, setDataReady] = React.useState(false);
11
12	  React.useEffect(() => {
13	    document.documentElement.style.setProperty('--accent', ACCENT);
14	    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
15	    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
16	    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
17	    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
18	  }, []);
19
20	  React.useEffect(() => {
21	    window.ZAMPP_API.loadData()
22	      .then(() => setDataReady(true))
23	      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
24	  }, []);
25
26	  const navigate = (id) => { setOpenAsset(null); setRoute(id); };
27
28	  const crumbs = React.useMemo(() => {
29	    if (openAsset) return [
30	      { label: 'Library', to: 'library' },
31	      { label: openAsset.project || 'Library', to: 'library' },
32	      { label: openAsset.name },
33	    ];
34	    if (openProject) return [
35	      { label: 'Projects', to: 'projects' },
36	      { label: openProject.name },
37	    ];
38	    const labels = {
39	      home: ['Home'], library: ['Library'], projects: ['Projects'],
40	      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
41	      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
42	      jobs: ['Jobs'], editor: ['Editor'],
43	      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
44	      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
45	      settings: ['Admin', 'Settings'],
46	    };
47	    return (labels[route] || ['Home']).map(label => ({ label }));
48	  }, [route, openAsset, openProject]);
49
50	  if (!dataReady) {
51	    return (
52	      <>
53
54

55

56
Loading Dragonflight…

57

58	      </>
59	    );
60	  }
61
62	  let content;
63	  if (openAsset) {
64	    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
65	  } else {
66	    switch (route) {
67	      case 'home':       content = <Home navigate={navigate} />; break;
68	      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
69	      case 'projects':  content = <Projects navigate={navigate} onOpenProject={(p) => { setOpenProject(p); setRoute('library'); }} />; break;
70	      case 'upload':    content = <Upload navigate={navigate} />; break;
71	      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
72	      case 'capture':   content = <Capture navigate={navigate} />; break;
73	      case 'monitors':  content = <Monitors navigate={navigate} />; break;
74	      case 'jobs':      content = <Jobs navigate={navigate} />; break;
75	      case 'editor':    content = <Editor />; break;
76	      case 'users':     content = <Users />; break;
77	      case 'tokens':    content = <Tokens />; break;
78	      case 'containers':content = <Containers />; break;
79	      case 'cluster':   content = <Cluster />; break;
80	      case 'settings':  content = <Settings />; break;
81	      default:          content = <Home navigate={navigate} />;
82	    }
83	  }
84
85	  return (
86

87	      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />
88

89	        {!openAsset && <Topbar crumbs={crumbs} onNavigate={navigate} />}
90	        {content}
91

92	      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
93

94	  );
95	}
96
97	function hexToRgba(hex, a) {
98	  const h = hex.replace('#', '');
99	  const r = parseInt(h.slice(0, 2), 16);
100	  const g = parseInt(h.slice(2, 4), 16);
101	  const b = parseInt(h.slice(4, 6), 16);
102	  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
103	}
104	function lighten(hex, amt) {
105	  const h = hex.replace('#', '');
106	  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
107	  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
108	  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
109	  return 'rgb(' + r + ',' + g + ',' + b + ')';
110	}
111
112	const root = ReactDOM.createRoot(document.getElementById('root'));
113	root.render(<App />);
114

// app.jsx — main shell

const ACCENT = '#5B7CFA';

function App() {
  const [route, setRoute] = React.useState('home');
  const [openAsset, setOpenAsset] = React.useState(null);
  const [openProject, setOpenProject] = React.useState(null);
  const [showNewRecorder, setShowNewRecorder] = React.useState(false);
  const [dataReady, setDataReady] = React.useState(false);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', ACCENT);
    document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
    document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
    document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
    document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
  }, []);

  React.useEffect(() => {
    window.ZAMPP_API.loadData()
      .then(() => setDataReady(true))
      .catch(err => { console.error('[Dragonflight] load failed:', err); setDataReady(true); });
  }, []);

  const navigate = (id) => { setOpenAsset(null); setRoute(id); };

  const crumbs = React.useMemo(() => {
    if (openAsset) return [
      { label: 'Library', to: 'library' },
      { label: openAsset.project || 'Library', to: 'library' },
      { label: openAsset.name },
    ];
    if (openProject) return [
      { label: 'Projects', to: 'projects' },
      { label: openProject.name },
    ];
    const labels = {
      home: ['Home'], library: ['Library'], projects: ['Projects'],
      upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
      capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
      jobs: ['Jobs'], editor: ['Editor'],
      users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
      containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
      settings: ['Admin', 'Settings'],
    };
    return (labels[route] || ['Home']).map(label => ({ label }));
  }, [route, openAsset, openProject]);

  if (!dataReady) {
    return (
      <>

Loading Dragonflight…

      </>
    );
  }

  let content;
  if (openAsset) {
    content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
  } else {
    switch (route) {
      case 'home':       content = <Home navigate={navigate} />; break;
      case 'library':   content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} />; break;
      case 'projects':  content = <Projects navigate={navigate} onOpenProject={(p) => { setOpenProject(p); setRoute('library'); }} />; break;
      case 'upload':    content = <Upload navigate={navigate} />; break;
      case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
      case 'capture':   content = <Capture navigate={navigate} />; break;
      case 'monitors':  content = <Monitors navigate={navigate} />; break;
      case 'jobs':      content = <Jobs navigate={navigate} />; break;
      case 'editor':    content = <Editor />; break;
      case 'users':     content = <Users />; break;
      case 'tokens':    content = <Tokens />; break;
      case 'containers':content = <Containers />; break;
      case 'cluster':   content = <Cluster />; break;
      case 'settings':  content = <Settings />; break;
      default:          content = <Home navigate={navigate} />;
    }
  }

  return (

      <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />

        {!openAsset && <Topbar crumbs={crumbs} onNavigate={navigate} />}
        {content}

      {showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}

  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
  const h = hex.replace('#', '');
  const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
  const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
  const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
  return 'rgb(' + r + ',' + g + ',' + b + ')';
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);


() => { const all = Array.from(document.querySelectorAll('aside *, .sidebar *, nav *')); const clickable = all.filter(el => el.onclick || el.tagName==='BUTTON' || el.tagName==='A' || el.getAttribute('role')==='button' || el.classList?.contains('nav-item') || (el.innerText && el.children.length===0)).slice(0, 30).map(el => ({tag:el.tagName, text:(el.innerText||'').trim().slice(0,30), cls:el.className?.slice?.(0,40)})); return JSON.stringify(clickable); }


D

Dragonflight

Home

Home

Library

Library

Projects

Projects

Ingest

Ingest

Upload

Upload

Recorders

Recorders

Capture

Capture

Monitors

Monitors

Jobs
3

Jobs

Editor
DEV

Editor

DEV

ADMIN

Users

Users

Tokens

Tokens

D

Dragonflight

Home

Home

Library

Library

Projects

Projects

Ingest

Ingest

Upload

Upload

Recorders

Recorders

Capture

Capture

Monitors

Monitors

Jobs
3

Jobs

Editor
DEV

Editor

DEV

ADMIN

Users

Users

Tokens

Tokens

D

Dragonflight

Home

Home

Library

Library

Projects

Projects

Ingest

Ingest

Upload

Upload

Recorders

Recorders

Capture

Capture

Monitors

Monitors

Jobs
3

Jobs

Editor
DEV

Editor

DEV

ADMIN

Users

Users

Tokens

Tokens


1	// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
2
3	/* ===== Upload helpers ===== */
4	const _SIMPLE_MAX = 50 * 1024 * 1024;  // 50 MB → simple upload
5	const _PART_SIZE  = 10 * 1024 * 1024;  // 10 MB chunks for multipart
6
7	function _xhrPost(url, formData, onProgress) {
8	  return new Promise((resolve, reject) => {
9	    const xhr = new XMLHttpRequest();
10	    xhr.withCredentials = true;
11	    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
12	    xhr.onload = () => {
13	      if (xhr.status >= 200 && xhr.status < 300) {
14	        try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
15	      } else {
16	        let msg = xhr.status + ' ' + xhr.statusText;
17	        try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
18	        reject(new Error(msg));
19	      }
20	    };
21	    xhr.onerror = () => reject(new Error('Network error'));
22	    xhr.open('POST', url);
23	    xhr.send(formData);
24	  });
25	}
26
27	async function _uploadFile(file, projectId, onProgress) {
28	  const mime = file.type || 'application/octet-stream';
29
30	  if (file.size <= _SIMPLE_MAX) {
31	    const fd = new FormData();
32	    fd.append('file', file, file.name);
33	    fd.append('filename', file.name);
34	    fd.append('projectId', projectId);
35	    fd.append('contentType', mime);
36	    return _xhrPost('/api/v1/upload/simple', fd,
37	      (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
38	  }
39
40	  // — Multipart —
41	  const init = await window.ZAMPP_API.fetch('/upload/init', {
42	    method: 'POST',
43	    body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
44	  });
45	  const { assetId, uploadId, key } = init;
46	  const totalParts = Math.ceil(file.size / _PART_SIZE);
47	  const parts = [];
48
49	  for (let i = 0; i < totalParts; i++) {
50	    const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
51	    const fd = new FormData();
52	    fd.append('file', chunk, file.name);
53	    fd.append('uploadId', uploadId);
54	    fd.append('key', key);
55	    fd.append('partNumber', String(i + 1));
56	    const partRes = await _xhrPost('/api/v1/upload/part', fd,
57	      (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
58	    parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
59	  }
60
61	  await window.ZAMPP_API.fetch('/upload/complete', {
62	    method: 'POST',
63	    body: JSON.stringify({ uploadId, key, assetId, parts }),
64	  });
65	  return { id: assetId };
66	}
67
68	/* ===== Upload ===== */
69	function Upload({ navigate }) {
70	  const { PROJECTS } = window.ZAMPP_DATA;
71	  const [files, setFiles] = React.useState([]);
72	  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
73
74	  const updateFile = React.useCallback((id, patch) => {
75	    setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
76	  }, []);
77
78	  const startUpload = React.useCallback((entry, pid) => {
79	    _uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
80	      .then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
81	      .catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
82	  }, [updateFile]);
83
84	  const handleDrop = React.useCallback((e) => {
85	    e.preventDefault();
86	    const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
87	    const pid = projectId || PROJECTS[0]?.id || '';
88	    const newEntries = dropped.map((f, i) => ({
89	      id: Date.now() + i,
90	      name: f.name,
91	      size: window.ZAMPP_API.fmtSize(f.size),
92	      file: f,
93	      progress: 0,
94	      status: 'uploading',
95	      error: null,
96	    }));
97	    setFiles(prev => [...prev, ...newEntries]);
98	    newEntries.forEach(entry => startUpload(entry, pid));
99	  }, [projectId, startUpload]);
100
101	  return (
102

103

104
Upload

105	        Drop video, audio, or stills — we proxy and index automatically.
106

107

108

109

110	            Project
111	            <select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
112	              style={{ appearance: 'auto' }}>
113	              {PROJECTS.length === 0
114	                ? <option value="">No projects</option>
115	                : PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
116	            </select>
117

118

119
120
 e.preventDefault()}
123	          onClick={() => {
124	            const inp = document.createElement('input');
125	            inp.type = 'file'; inp.multiple = true;
126	            inp.onchange = handleDrop;
127	            inp.click();
128	          }}>
129	          <Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
130
Drop files here or click to browse

131
Video, audio, and image files

132

133	            {['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
134	              {f})}
135

136

137
138	        {files.length > 0 && (
139

140

141	              Queue {files.length}
142
143	              <button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
144

145

146	              {files.map(f => (
147

148	                  <Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
149	                    style={{ color: 'var(--text-3)' }} />
150

151

152	                      {f.name}
153	                      {f.size}
154

155

156

161

162	                    {f.status === 'error' && (
163
{f.error}

164	                    )}
165

166
170	                    {f.status === 'done' ? '✓ done'
171	                     : f.status === 'error' ? '✗ failed'
172	                     : f.progress + '%'}
173
174

175	              ))}
176

177

178	        )}
179

180

181	  );
182	}
183
184	/* ===== Recorders ===== */
185	function _normRecorder(r) {
186	  let elapsed = '—';
187	  if (r.status === 'recording' && r.started_at) {
188	    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
189	    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
190	              String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
191	              String(s % 60).padStart(2, '0');
192	  }
193	  const cfg = r.source_config || {};
194	  return {
195	    ...r,
196	    source: r.source_type || '—',
197	    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
198	    codec: r.recording_codec || '—',
199	    res: r.recording_resolution || '—',
200	    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
201	    elapsed,
202	    bitrate: '—',
203	    health: 100,
204	    audio: false,
205	  };
206	}
207
208	function Recorders({ navigate, onNew }) {
209	  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
210
211	  const refresh = React.useCallback(() => {
212	    window.ZAMPP_API.fetch('/recorders')
213	      .then(raw => {
214	        const norm = (raw || []).map(_normRecorder);
215	        window.ZAMPP_DATA.RECORDERS = norm;
216	        setRecorders(norm);
217	      })
218	      .catch(() => {});
219	  }, []);
220
221	  React.useEffect(() => {
222	    refresh();
223	    const id = setInterval(refresh, 10000);
224	    return () => clearInterval(id);
225	  }, []);
226
227	  const liveCount = recorders.filter(r => r.status === 'recording').length;
228	  const errCount  = recorders.filter(r => r.status === 'error').length;
229
230	  return (
231

232

233
Recorders

234	        Live ingest from SRT, RTMP, and SDI sources
235

236	        {(liveCount > 0 || errCount > 0) && (
237

238
239	            {liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}
240

241	        )}
242	        <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
243	        <button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
244

245

246	        {recorders.length === 0 ? (
247

248	            No recorders configured.
249
<button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button>

250

251	        ) : (
252

253	            {recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
254

255	        )}
256

257

258	  );
259	}
260
261	function RecorderRow({ recorder: initialRecorder, onRefresh }) {
262	  const [recorder, setRecorder] = React.useState(initialRecorder);
263	  const [pending, setPending] = React.useState(false);
264	  const [err, setErr] = React.useState(null);
265	  const [liveStatus, setLiveStatus] = React.useState(null);
266	  const isRec = recorder.status === 'recording';
267
268	  React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
269
270	  // Poll the status endpoint every 3s while recording for live feedback.
271	  React.useEffect(() => {
272	    if (!isRec) { setLiveStatus(null); return; }
273	    const poll = () => {
274	      window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
275	        .then(s => setLiveStatus(s))
276	        .catch(() => {});
277	    };
278	    poll();
279	    const id = setInterval(poll, 3000);
280	    return () => clearInterval(id);
281	  }, [isRec, recorder.id]);
282
283	  const displayElapsed = React.useMemo(() => {
284	    if (liveStatus && liveStatus.duration != null) {
285	      const d = Math.max(0, liveStatus.duration);
286	      return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
287	             String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
288	             String(d % 60).padStart(2, '0');
289	    }
290	    return recorder.elapsed;
291	  }, [liveStatus, recorder.elapsed]);
292
293	  const displaySignal = liveStatus
294	    ? (liveStatus.signal || '—')
295	    : (isRec ? 'connecting…' : '—');
296
297	  const signalColor = displaySignal === 'receiving' ? 'var(--success)'
298	    : displaySignal === 'stopped' ? 'var(--danger)'
299	    : 'var(--text-3)';
300
301	  const toggle = () => {
302	    if (pending) return;
303	    const action = isRec ? 'stop' : 'start';
304	    setPending(true);
305	    setErr(null);
306	    setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
307	    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
308	      .then(() => { setPending(false); onRefresh(); })
309	      .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
310	  };
311
312	  const handleDelete = () => {
313	    if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
314	    window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
315	      .then(() => onRefresh())
316	      .catch(e => setErr(e.message || 'Delete failed'));
317	  };
318
319	  return (
320

321

322	        {isRec
323	          ? <LiveStrip seed={recorder.id.length * 3} count={6} />
324	          :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}
325

326

327

328	          {recorder.name}
329
330	            <StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
331
332	          {recorder.source}
333

334
{recorder.url}

335

336	          {recorder.codec}·
337	          {recorder.res}
338

339	        {err &&
{err}
}
340	        {liveStatus?.lastError && isRec && (
341
{liveStatus.lastError}

342	        )}
343

344

345

346
Elapsed

347
{displayElapsed}

348

349

350
Signal

351
{displaySignal}

352

353	        {liveStatus?.currentFps != null && (
354

355
FPS

356
{Number(liveStatus.currentFps).toFixed(1)}

357

358	        )}
359

360

361	        {isRec
362	          ? <button className="btn danger sm" onClick={toggle} disabled={pending}>
363	              {pending ? '…' : <>Stop</>}
364	            </button>
365	          : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
366	              {pending ? '…' : <>Record</>}
367	            </button>}
368	        <button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
369	          <Icon name="x" />
370	        </button>
371

372

373	  );
374	}
375
376	function badgeForStatus(s) {
377	  return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
378	}
379
380	/* ===== Capture ===== */
381	function Capture({ navigate }) {
382	  const [devices, setDevices] = React.useState([]);
383	  const [activeIdx, setActiveIdx] = React.useState(0);
384
385	  const loadDevices = () => {
386	    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
387	      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
388	      .catch(() => setDevices([]));
389	  };
390
391	  React.useEffect(() => { loadDevices(); }, []);
392
393	  if (devices.length === 0) {
394	    return (
395

396

397
Capture

398	          DeckLink SDI ingest
399

400	          <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
401

402

403

404	            No DeckLink devices found in cluster.
405

406

407

408	    );
409	  }
410
411	  const active = devices[activeIdx] || devices[0];
412
413	  return (
414

415

416
Capture

417	        DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster
418

419	        <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
420

421

422

423	          {devices.map((d, i) => (
424	            <button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
425	              {d.model || d.device || 'DeckLink'} — {d.hostname}
426	            </button>
427	          ))}
428

429

430

431	            <StatusDot status={active.online ? 'online' : 'offline'} />
432

433
{active.model || active.device || 'DeckLink'}

434
{active.hostname} · {active.ip_address}

435

436

437
Connect a source and click Refresh to see port status.

438

439

440

441	  );
442	}
443
444	/* ===== Monitors ===== */
445	function Monitors({ navigate }) {
446	  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
447	  const [grid, setGrid] = React.useState(4);
448
449	  React.useEffect(() => {
450	    const refresh = () => {
451	      window.ZAMPP_API.fetch('/recorders')
452	        .then(raw => {
453	          const norm = (raw || []).map(_normRecorder);
454	          window.ZAMPP_DATA.RECORDERS = norm;
455	          setRecorders(norm);
456	        })
457	        .catch(() => {});
458	    };
459	    refresh();
460	    const id = setInterval(refresh, 5000);
461	    return () => clearInterval(id);
462	  }, []);
463
464	  const videoFeeds = recorders.filter(r => !r.audio);
465	  const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
466	  const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
467	  const feeds = allFeeds.slice(0, grid * grid);
468
469	  return (
470

471

472
Monitors

473	        Multi-cam live monitoring
474

475

476	          {[2, 3, 4].map(n => (
477	            <button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
478	          ))}
479

480

481

482	        {feeds.length === 0 ? (
483
No active feeds. Start a recorder to see live video here.

484	        ) : (
485

486	            {feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
487

488	        )}
489

490

491	  );
492	}
493
494	function MonitorTile({ feed, seed }) {
495	  const [levels, setLevels] = React.useState([0.65, 0.78]);
496	  const isLive = feed.status === 'recording';
497
498	  React.useEffect(() => {
499	    if (!isLive) return;
500	    const id = setInterval(() => {
501	      setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
502	    }, 180);
503	    return () => clearInterval(id);
504	  }, [isLive]);
505
506	  if (feed.kind === 'audio') {
507	    return (
508

509

510	          <Waveform seed={seed * 7} color="var(--accent)" />
511

512

513	          <AudioMeter level={levels[0]} vertical />
514	          <AudioMeter level={levels[1]} vertical />
515

516

517	          LIVE
518	          {feed.name}
519

520

521	    );
522	  }
523
524	  return (
525

526	      <FauxFrame />
527	      {isLive &&
}
528

529	        {isLive                    && REC}
530	        {feed.status === 'stopped' && IDLE}
531	        {feed.status === 'idle'    && IDLE}
532	        {feed.status === 'error'   && ERR}
533

534	      {isLive && (
535

536	          <AudioMeter level={levels[0]} vertical />
537	          <AudioMeter level={levels[1]} vertical />
538

539	      )}
540

541	        {feed.name}
542	        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}
543

544

545	  );
546	}
547
548	Object.assign(window, { Upload, Recorders, Capture, Monitors });
549

// screens-ingest.jsx — Upload, Recorders, Capture, Monitors

/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024;  // 50 MB → simple upload
const _PART_SIZE  = 10 * 1024 * 1024;  // 10 MB chunks for multipart

function _xhrPost(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
      } else {
        let msg = xhr.status + ' ' + xhr.statusText;
        try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
        reject(new Error(msg));
      }
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.open('POST', url);
    xhr.send(formData);
  });
}

async function _uploadFile(file, projectId, onProgress) {
  const mime = file.type || 'application/octet-stream';

  if (file.size <= _SIMPLE_MAX) {
    const fd = new FormData();
    fd.append('file', file, file.name);
    fd.append('filename', file.name);
    fd.append('projectId', projectId);
    fd.append('contentType', mime);
    return _xhrPost('/api/v1/upload/simple', fd,
      (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
  }

  // — Multipart —
  const init = await window.ZAMPP_API.fetch('/upload/init', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
  });
  const { assetId, uploadId, key } = init;
  const totalParts = Math.ceil(file.size / _PART_SIZE);
  const parts = [];

  for (let i = 0; i < totalParts; i++) {
    const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
    const fd = new FormData();
    fd.append('file', chunk, file.name);
    fd.append('uploadId', uploadId);
    fd.append('key', key);
    fd.append('partNumber', String(i + 1));
    const partRes = await _xhrPost('/api/v1/upload/part', fd,
      (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
    parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
  }

  await window.ZAMPP_API.fetch('/upload/complete', {
    method: 'POST',
    body: JSON.stringify({ uploadId, key, assetId, parts }),
  });
  return { id: assetId };
}

/* ===== Upload ===== */
function Upload({ navigate }) {
  const { PROJECTS } = window.ZAMPP_DATA;
  const [files, setFiles] = React.useState([]);
  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');

  const updateFile = React.useCallback((id, patch) => {
    setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
  }, []);

  const startUpload = React.useCallback((entry, pid) => {
    _uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
      .then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
      .catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
  }, [updateFile]);

  const handleDrop = React.useCallback((e) => {
    e.preventDefault();
    const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
    const pid = projectId || PROJECTS[0]?.id || '';
    const newEntries = dropped.map((f, i) => ({
      id: Date.now() + i,
      name: f.name,
      size: window.ZAMPP_API.fmtSize(f.size),
      file: f,
      progress: 0,
      status: 'uploading',
      error: null,
    }));
    setFiles(prev => [...prev, ...newEntries]);
    newEntries.forEach(entry => startUpload(entry, pid));
  }, [projectId, startUpload]);

  return (

Upload

        Drop video, audio, or stills — we proxy and index automatically.

            Project
            <select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
              style={{ appearance: 'auto' }}>
              {PROJECTS.length === 0
                ? <option value="">No projects</option>
                : PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>

 e.preventDefault()}
          onClick={() => {
            const inp = document.createElement('input');
            inp.type = 'file'; inp.multiple = true;
            inp.onchange = handleDrop;
            inp.click();
          }}>
          <Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />

Drop files here or click to browse

Video, audio, and image files

            {['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
              {f})}

        {files.length > 0 && (

              Queue {files.length}

              <button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>

              {files.map(f => (

                  <Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
                    style={{ color: 'var(--text-3)' }} />

                      {f.name}
                      {f.size}

                    {f.status === 'error' && (

{f.error}

                    )}

                    {f.status === 'done' ? '✓ done'
                     : f.status === 'error' ? '✗ failed'
                     : f.progress + '%'}

              ))}

        )}

  );
}

/* ===== Recorders ===== */
function _normRecorder(r) {
  let elapsed = '—';
  if (r.status === 'recording' && r.started_at) {
    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
              String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
              String(s % 60).padStart(2, '0');
  }
  const cfg = r.source_config || {};
  return {
    ...r,
    source: r.source_type || '—',
    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
    codec: r.recording_codec || '—',
    res: r.recording_resolution || '—',
    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
    elapsed,
    bitrate: '—',
    health: 100,
    audio: false,
  };
}

function Recorders({ navigate, onNew }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);

  const refresh = React.useCallback(() => {
    window.ZAMPP_API.fetch('/recorders')
      .then(raw => {
        const norm = (raw || []).map(_normRecorder);
        window.ZAMPP_DATA.RECORDERS = norm;
        setRecorders(norm);
      })
      .catch(() => {});
  }, []);

  React.useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10000);
    return () => clearInterval(id);
  }, []);

  const liveCount = recorders.filter(r => r.status === 'recording').length;
  const errCount  = recorders.filter(r => r.status === 'error').length;

  return (

Recorders

        Live ingest from SRT, RTMP, and SDI sources

        {(liveCount > 0 || errCount > 0) && (

            {liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}

        )}
        <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>

        {recorders.length === 0 ? (

            No recorders configured.

<button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button>

        ) : (

            {recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}

        )}

  );
}

function RecorderRow({ recorder: initialRecorder, onRefresh }) {
  const [recorder, setRecorder] = React.useState(initialRecorder);
  const [pending, setPending] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [liveStatus, setLiveStatus] = React.useState(null);
  const isRec = recorder.status === 'recording';

  React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);

  // Poll the status endpoint every 3s while recording for live feedback.
  React.useEffect(() => {
    if (!isRec) { setLiveStatus(null); return; }
    const poll = () => {
      window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
        .then(s => setLiveStatus(s))
        .catch(() => {});
    };
    poll();
    const id = setInterval(poll, 3000);
    return () => clearInterval(id);
  }, [isRec, recorder.id]);

  const displayElapsed = React.useMemo(() => {
    if (liveStatus && liveStatus.duration != null) {
      const d = Math.max(0, liveStatus.duration);
      return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
             String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
             String(d % 60).padStart(2, '0');
    }
    return recorder.elapsed;
  }, [liveStatus, recorder.elapsed]);

  const displaySignal = liveStatus
    ? (liveStatus.signal || '—')
    : (isRec ? 'connecting…' : '—');

  const signalColor = displaySignal === 'receiving' ? 'var(--success)'
    : displaySignal === 'stopped' ? 'var(--danger)'
    : 'var(--text-3)';

  const toggle = () => {
    if (pending) return;
    const action = isRec ? 'stop' : 'start';
    setPending(true);
    setErr(null);
    setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
      .then(() => { setPending(false); onRefresh(); })
      .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
  };

  const handleDelete = () => {
    if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
      .then(() => onRefresh())
      .catch(e => setErr(e.message || 'Delete failed'));
  };

  return (

        {isRec
          ? <LiveStrip seed={recorder.id.length * 3} count={6} />
          :
<Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} />
}

          {recorder.name}

            <StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}

          {recorder.source}

{recorder.url}

          {recorder.codec}·
          {recorder.res}

        {err &&
{err}
}
        {liveStatus?.lastError && isRec && (

{liveStatus.lastError}

        )}

Elapsed

{displayElapsed}

Signal

{displaySignal}

        {liveStatus?.currentFps != null && (

FPS

{Number(liveStatus.currentFps).toFixed(1)}

        )}

        {isRec
          ? <button className="btn danger sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <>Stop</>}
            </button>
          : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <>Record</>}
            </button>}
        <button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
          <Icon name="x" />
        </button>

  );
}

function badgeForStatus(s) {
  return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}

/* ===== Capture ===== */
function Capture({ navigate }) {
  const [devices, setDevices] = React.useState([]);
  const [activeIdx, setActiveIdx] = React.useState(0);

  const loadDevices = () => {
    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
      .catch(() => setDevices([]));
  };

  React.useEffect(() => { loadDevices(); }, []);

  if (devices.length === 0) {
    return (

Capture

          DeckLink SDI ingest

          <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>

            No DeckLink devices found in cluster.

    );
  }

  const active = devices[activeIdx] || devices[0];

  return (

Capture

        DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster

        <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>

          {devices.map((d, i) => (
            <button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
              {d.model || d.device || 'DeckLink'} — {d.hostname}
            </button>
          ))}

            <StatusDot status={active.online ? 'online' : 'offline'} />

{active.model || active.device || 'DeckLink'}

{active.hostname} · {active.ip_address}

Connect a source and click Refresh to see port status.

  );
}

/* ===== Monitors ===== */
function Monitors({ navigate }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
  const [grid, setGrid] = React.useState(4);

  React.useEffect(() => {
    const refresh = () => {
      window.ZAMPP_API.fetch('/recorders')
        .then(raw => {
          const norm = (raw || []).map(_normRecorder);
          window.ZAMPP_DATA.RECORDERS = norm;
          setRecorders(norm);
        })
        .catch(() => {});
    };
    refresh();
    const id = setInterval(refresh, 5000);
    return () => clearInterval(id);
  }, []);

  const videoFeeds = recorders.filter(r => !r.audio);
  const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
  const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
  const feeds = allFeeds.slice(0, grid * grid);

  return (

Monitors

        Multi-cam live monitoring

          {[2, 3, 4].map(n => (
            <button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
          ))}

        {feeds.length === 0 ? (

No active feeds. Start a recorder to see live video here.

        ) : (

            {feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}

        )}

  );
}

function MonitorTile({ feed, seed }) {
  const [levels, setLevels] = React.useState([0.65, 0.78]);
  const isLive = feed.status === 'recording';

  React.useEffect(() => {
    if (!isLive) return;
    const id = setInterval(() => {
      setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
    }, 180);
    return () => clearInterval(id);
  }, [isLive]);

  if (feed.kind === 'audio') {
    return (

          <Waveform seed={seed * 7} color="var(--accent)" />

          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />

          LIVE
          {feed.name}

    );
  }

  return (

      <FauxFrame />
      {isLive &&
}

        {isLive                    && REC}
        {feed.status === 'stopped' && IDLE}
        {feed.status === 'idle'    && IDLE}
        {feed.status === 'error'   && ERR}

      {isLive && (

          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />

      )}

        {feed.name}
        {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}

  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors });


Found it. Recorders/Monitors render fake `<LiveStrip>` and `<FauxFrame>` decorations — there's never actual video. That's the "preview missing" bug. Reading Settings + admin code now, and navigating the SPA via state.


() => { const items = Array.from(document.querySelectorAll('.nav-item')); const rec = items.find(el => el.innerText.trim().startsWith('Recorders')); if (rec) { rec.click(); return 'clicked Recorders'; } return 'not found: ' + items.map(i=>i.innerText.trim()).join(','); }