feat(cluster): install capture-card drivers/SDKs from the admin screen
Per-node "Capture Drivers / SDKs" panel installs Blackmagic / AJA / Deltacast / NDI drivers without SSH. node-agent gains NODE_TOKEN-gated /driver/install + /driver/status (spawns a one-shot privileged ubuntu container that bind- mounts host kernel paths + the repo and runs deploy/install-driver.sh); mam-api adds admin-gated /cluster/:id/install-driver + /driver-status. Driver files live in-repo under sdk/<vendor>/ (private repo); binaries are admin-supplied per each sdk/<vendor>/README.md. Vendor allowlist throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
549ca6c73f
commit
fffff1c016
15 changed files with 882 additions and 1 deletions
297
deploy/install-driver.sh
Normal file
297
deploy/install-driver.sh
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# install-driver.sh <vendor>
|
||||
# ----------------------------------------------------------------------------
|
||||
# Idempotent HOST installer for capture-card runtime drivers / SDKs.
|
||||
#
|
||||
# Runs ON the cluster node's HOST kernel. The node-agent invokes it inside a
|
||||
# one-shot PRIVILEGED ubuntu container that bind-mounts this repo plus the host
|
||||
# paths needed to affect the host kernel (/lib/modules, /usr/src, /boot, /dev,
|
||||
# and the host apt/dpkg via the mounted root). dkms / modprobe / ldconfig
|
||||
# therefore operate against the running host kernel.
|
||||
#
|
||||
# Reads the proprietary vendor file(s) from sdk/<vendor>/ (in this repo).
|
||||
# NO binaries are committed — if the expected file is missing the script exits
|
||||
# non-zero with a clear message telling the operator what to drop in.
|
||||
#
|
||||
# Vendors (ALLOWLIST — nothing else is accepted):
|
||||
# blackmagic Desktop Video .deb (DKMS kernel module)
|
||||
# aja NTV2 driver source/SDK (built kernel module)
|
||||
# deltacast VideoMaster installer (kernel module)
|
||||
# ndi redistributable runtime libs (user-space only, no module)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 installed (or already present / up to date)
|
||||
# 2 bad usage / unknown vendor
|
||||
# 3 expected vendor file missing in sdk/<vendor>/
|
||||
# 4 missing kernel headers (cannot build DKMS / module)
|
||||
# 5 build / install / module-load failure
|
||||
#
|
||||
# `bash -n` must pass. set -euo pipefail with `|| true` guarding every probe.
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolve our own repo dir (deploy/ -> repo root), regardless of CWD.
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)"
|
||||
SDK_ROOT="$REPO_DIR/sdk"
|
||||
|
||||
VENDOR="${1:-}"
|
||||
KVER="$(uname -r 2>/dev/null || echo unknown)"
|
||||
REBOOT_REQUIRED=0
|
||||
|
||||
log() { echo "[install-driver] $*"; }
|
||||
warn() { echo "[install-driver] WARN: $*" >&2; }
|
||||
die() { echo "[install-driver] ERROR: $*" >&2; exit "${2:-5}"; }
|
||||
|
||||
usage() {
|
||||
echo "Usage: install-driver.sh <blackmagic|aja|deltacast|ndi>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ -n "$VENDOR" ] || usage
|
||||
case "$VENDOR" in
|
||||
blackmagic|aja|deltacast|ndi) : ;;
|
||||
*) echo "[install-driver] ERROR: unknown vendor '$VENDOR' (allowed: blackmagic aja deltacast ndi)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
VENDOR_DIR="$SDK_ROOT/$VENDOR"
|
||||
log "vendor=$VENDOR kernel=$KVER repo=$REPO_DIR"
|
||||
log "reading vendor files from $VENDOR_DIR"
|
||||
[ -d "$VENDOR_DIR" ] || die "vendor dir $VENDOR_DIR does not exist (repo not mounted?)" 3
|
||||
|
||||
# Pick the newest file matching a glob; echo its path or empty.
|
||||
newest_match() {
|
||||
# shellcheck disable=SC2012
|
||||
ls -1t $1 2>/dev/null | head -n1 || true
|
||||
}
|
||||
|
||||
ensure_headers() {
|
||||
if [ -d "/lib/modules/$KVER/build" ] || dpkg -s "linux-headers-$KVER" >/dev/null 2>&1; then
|
||||
log "kernel headers for $KVER present"
|
||||
return 0
|
||||
fi
|
||||
log "installing linux-headers-$KVER ..."
|
||||
apt-get update -y >/dev/null 2>&1 || true
|
||||
if ! apt-get install -y "linux-headers-$KVER" >/dev/null 2>&1; then
|
||||
# Fall back to the generic meta-package; still may not match a custom kernel.
|
||||
apt-get install -y linux-headers-generic >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d "/lib/modules/$KVER/build" ] || dpkg -s "linux-headers-$KVER" >/dev/null 2>&1; then
|
||||
log "kernel headers ready"
|
||||
return 0
|
||||
fi
|
||||
die "kernel headers for $KVER unavailable — cannot build module. Install linux-headers-$KVER on the host." 4
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# blackmagic — Desktop Video .deb (DKMS) + modprobe + restart desktopvideo
|
||||
# ===========================================================================
|
||||
install_blackmagic() {
|
||||
# Detect existing state first (idempotent skip).
|
||||
if lsmod 2>/dev/null | grep -q '^blackmagic' && [ -e /dev/blackmagic ]; then
|
||||
log "blackmagic module loaded and /dev/blackmagic present — already installed"
|
||||
if command -v dkms >/dev/null 2>&1; then dkms status 2>/dev/null | grep -i blackmagic || true; fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local deb
|
||||
deb="$(newest_match "$VENDOR_DIR/desktopvideo_*_amd64.deb")"
|
||||
[ -n "$deb" ] && [ -f "$deb" ] || die \
|
||||
"no desktopvideo_*_amd64.deb in $VENDOR_DIR — download Desktop Video for Ubuntu 22.04 and drop the .deb there (see sdk/blackmagic/README.md)." 3
|
||||
log "using package: $(basename "$deb")"
|
||||
|
||||
ensure_headers
|
||||
|
||||
log "apt-get install (DKMS build) ..."
|
||||
apt-get update -y >/dev/null 2>&1 || true
|
||||
apt-get install -y "$deb" || die "apt-get install of $(basename "$deb") failed (DKMS build?)" 5
|
||||
|
||||
log "depmod + modprobe blackmagic ..."
|
||||
depmod -a "$KVER" 2>/dev/null || true
|
||||
if ! modprobe blackmagic 2>/dev/null; then
|
||||
warn "modprobe blackmagic failed — a reboot may be required for the DKMS module to bind"
|
||||
REBOOT_REQUIRED=1
|
||||
fi
|
||||
|
||||
# Restart the DesktopVideoHelper daemon if present.
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl restart desktopvideo 2>/dev/null \
|
||||
|| systemctl restart DesktopVideoHelper 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if lsmod 2>/dev/null | grep -q '^blackmagic' || [ -e /dev/blackmagic ]; then
|
||||
log "blackmagic installed (module loaded / device present)"
|
||||
else
|
||||
warn "blackmagic installed but module not yet loaded — reboot required"
|
||||
REBOOT_REQUIRED=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# aja — build ntv2 kernel module from source archive + modprobe
|
||||
# ===========================================================================
|
||||
install_aja() {
|
||||
if lsmod 2>/dev/null | grep -q 'ajantv2'; then
|
||||
log "ajantv2 module already loaded — already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local src
|
||||
src="$(newest_match "$VENDOR_DIR/ntv2sdk*.zip")"
|
||||
[ -n "$src" ] || src="$(newest_match "$VENDOR_DIR/libajantv2*.tar.gz")"
|
||||
[ -n "$src" ] && [ -f "$src" ] || die \
|
||||
"no ntv2sdk*.zip / libajantv2*.tar.gz in $VENDOR_DIR — download the AJA NTV2 Linux SDK and drop it there (see sdk/aja/README.md)." 3
|
||||
log "using source: $(basename "$src")"
|
||||
|
||||
ensure_headers
|
||||
apt-get install -y build-essential unzip >/dev/null 2>&1 || true
|
||||
|
||||
local work; work="$(mktemp -d)"
|
||||
log "extracting into $work ..."
|
||||
case "$src" in
|
||||
*.zip) unzip -q -o "$src" -d "$work" || die "failed to unzip $(basename "$src")" 5 ;;
|
||||
*.tar.gz) tar -xzf "$src" -C "$work" || die "failed to untar $(basename "$src")" 5 ;;
|
||||
esac
|
||||
|
||||
# Find the linux driver dir (contains a Makefile producing ajantv2.ko).
|
||||
local drvdir
|
||||
drvdir="$(dirname "$(find "$work" -type d -path '*driver/linux' -print -quit 2>/dev/null || true)/." 2>/dev/null)"
|
||||
[ -d "$drvdir" ] || drvdir="$(dirname "$(find "$work" -name 'ajantv2.c' -print -quit 2>/dev/null || true)" 2>/dev/null)"
|
||||
[ -n "$drvdir" ] && [ -d "$drvdir" ] || die "could not locate AJA driver/linux source inside the archive" 5
|
||||
log "building module in $drvdir ..."
|
||||
make -C "$drvdir" >/dev/null 2>&1 || die "AJA module build failed" 5
|
||||
|
||||
local ko
|
||||
ko="$(find "$drvdir" -name 'ajantv2.ko' -print -quit 2>/dev/null || true)"
|
||||
if [ -n "$ko" ]; then
|
||||
install -D -m 0644 "$ko" "/lib/modules/$KVER/extra/ajantv2.ko" 2>/dev/null || true
|
||||
depmod -a "$KVER" 2>/dev/null || true
|
||||
fi
|
||||
if ! modprobe ajantv2 2>/dev/null; then
|
||||
# Fall back to the SDK's own load script if shipped.
|
||||
local loader; loader="$(find "$work" -name 'load_ajantv2' -print -quit 2>/dev/null || true)"
|
||||
if [ -n "$loader" ]; then bash "$loader" 2>/dev/null || true; fi
|
||||
fi
|
||||
|
||||
rm -rf "$work" 2>/dev/null || true
|
||||
|
||||
if lsmod 2>/dev/null | grep -q 'ajantv2'; then
|
||||
log "ajantv2 installed and loaded"
|
||||
else
|
||||
warn "ajantv2 built but not loaded — a reboot may be required (old in-tree module wedged?)"
|
||||
REBOOT_REQUIRED=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# deltacast — VideoMaster installer + module load
|
||||
# ===========================================================================
|
||||
install_deltacast() {
|
||||
if lsmod 2>/dev/null | grep -q 'videomaster' || ls /dev/deltacast* >/dev/null 2>&1; then
|
||||
log "Deltacast module loaded / device present — already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pkg
|
||||
pkg="$(newest_match "$VENDOR_DIR/VideoMaster*.run")"
|
||||
[ -n "$pkg" ] || pkg="$(newest_match "$VENDOR_DIR/VideoMaster*.tar.gz")"
|
||||
[ -n "$pkg" ] && [ -f "$pkg" ] || die \
|
||||
"no VideoMaster*.run / VideoMaster*.tar.gz in $VENDOR_DIR — obtain the Deltacast VideoMaster Linux installer and drop it there (see sdk/deltacast/README.md)." 3
|
||||
log "using installer: $(basename "$pkg")"
|
||||
|
||||
ensure_headers
|
||||
apt-get install -y build-essential dkms >/dev/null 2>&1 || true
|
||||
|
||||
local work; work="$(mktemp -d)"
|
||||
case "$pkg" in
|
||||
*.run)
|
||||
log "extracting self-extractor ..."
|
||||
chmod +x "$pkg" 2>/dev/null || true
|
||||
"$pkg" --noexec --target "$work" >/dev/null 2>&1 \
|
||||
|| cp "$pkg" "$work/installer.run"
|
||||
;;
|
||||
*.tar.gz)
|
||||
tar -xzf "$pkg" -C "$work" || die "failed to untar $(basename "$pkg")" 5
|
||||
;;
|
||||
esac
|
||||
|
||||
local installer
|
||||
installer="$(find "$work" -name 'install.sh' -print -quit 2>/dev/null || true)"
|
||||
[ -n "$installer" ] || installer="$(find "$work" -name 'installer.run' -print -quit 2>/dev/null || true)"
|
||||
[ -n "$installer" ] && [ -f "$installer" ] || die "Deltacast installer (install.sh) not found inside the package" 5
|
||||
log "running vendor installer: $(basename "$installer") ..."
|
||||
chmod +x "$installer" 2>/dev/null || true
|
||||
( cd "$(dirname "$installer")" && bash "$installer" ) || die "Deltacast VideoMaster installer failed" 5
|
||||
|
||||
depmod -a "$KVER" 2>/dev/null || true
|
||||
modprobe videomasterhd 2>/dev/null || modprobe videomaster 2>/dev/null || true
|
||||
|
||||
rm -rf "$work" 2>/dev/null || true
|
||||
|
||||
if lsmod 2>/dev/null | grep -q 'videomaster' || ls /dev/deltacast* >/dev/null 2>&1; then
|
||||
log "Deltacast VideoMaster installed"
|
||||
fi
|
||||
# First-time VideoMaster installs lay down udev rules + firmware that need a reboot.
|
||||
warn "Deltacast: a REBOOT is recommended after a first-time VideoMaster install (udev + firmware)"
|
||||
REBOOT_REQUIRED=1
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# ndi — copy redistributable runtime libs to /usr/local/lib + ldconfig
|
||||
# ===========================================================================
|
||||
install_ndi() {
|
||||
local target="/opt/ndi-lib"
|
||||
local found=0
|
||||
# shellcheck disable=SC2231
|
||||
for f in "$VENDOR_DIR"/libndi*.so*; do
|
||||
[ -e "$f" ] || continue
|
||||
found=1
|
||||
break
|
||||
done
|
||||
[ "$found" = 1 ] || die \
|
||||
"no libndi*.so* in $VENDOR_DIR — drop the NDI runtime redistributable libs there (see sdk/ndi/README.md)." 3
|
||||
|
||||
log "copying NDI runtime libs to $target ..."
|
||||
mkdir -p "$target"
|
||||
cp -av "$VENDOR_DIR"/libndi*.so* "$target"/ 2>/dev/null || die "failed copying NDI libs" 5
|
||||
|
||||
# Recreate the libndi.so dev symlink if only versioned libs were shipped.
|
||||
if [ ! -e "$target/libndi.so" ]; then
|
||||
local versioned
|
||||
versioned="$(newest_match "$target/libndi.so.*")"
|
||||
if [ -n "$versioned" ]; then
|
||||
ln -sf "$(basename "$versioned")" "$target/libndi.so" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$target" > /etc/ld.so.conf.d/ndi.conf
|
||||
ldconfig 2>/dev/null || true
|
||||
|
||||
if ldconfig -p 2>/dev/null | grep -q 'libndi'; then
|
||||
log "NDI runtime registered with the dynamic linker"
|
||||
else
|
||||
die "NDI libs copied but ldconfig did not resolve libndi" 5
|
||||
fi
|
||||
log "NDI: no kernel module and no reboot required."
|
||||
log "NDI: restart any process that already loaded an older libndi to pick up the new version."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
case "$VENDOR" in
|
||||
blackmagic) install_blackmagic ;;
|
||||
aja) install_aja ;;
|
||||
deltacast) install_deltacast ;;
|
||||
ndi) install_ndi ;;
|
||||
esac
|
||||
|
||||
if [ "$REBOOT_REQUIRED" = 1 ]; then
|
||||
log "RESULT: $VENDOR install completed — REBOOT REQUIRED"
|
||||
echo "[install-driver] REBOOT_REQUIRED=1"
|
||||
else
|
||||
log "RESULT: $VENDOR install completed — no reboot required"
|
||||
echo "[install-driver] REBOOT_REQUIRED=0"
|
||||
fi
|
||||
exit 0
|
||||
|
|
@ -55,10 +55,25 @@ services:
|
|||
BMD_MODEL: ${BMD_MODEL:-}
|
||||
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
|
||||
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
|
||||
# REPO_DIR: host path to the checked-out repo. The agent passes this to the
|
||||
# one-shot driver-install container so install-driver.sh can read
|
||||
# sdk/<vendor>/ and run deploy/install-driver.sh. Must match the host path
|
||||
# bind-mounted below (onboard-node.sh clones to /opt/wild-dragon).
|
||||
REPO_DIR: ${REPO_DIR:-/opt/wild-dragon}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /dev:/dev:ro
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
|
||||
# Capture-driver deployment ("Capture Drivers / SDKs" in the Cluster admin
|
||||
# screen): the agent itself does NOT run dkms/modprobe — it spawns a
|
||||
# separate privileged ubuntu container that bind-mounts these host paths.
|
||||
# The agent only needs to *see* the repo path so it can pass it through as
|
||||
# a bind to that container; no extra privileges are granted to the agent.
|
||||
# /opt/wild-dragon → repo (sdk/<vendor>/ + deploy/install-driver.sh)
|
||||
# The install container additionally mounts /lib/modules,/usr/src,/boot,
|
||||
# /dev and /opt from the host (handled in the agent, not here) so DKMS /
|
||||
# modprobe / ldconfig affect the host kernel.
|
||||
- ${REPO_DIR:-/opt/wild-dragon}:${REPO_DIR:-/opt/wild-dragon}:ro
|
||||
devices:
|
||||
- /dev/blackmagic:/dev/blackmagic
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ services:
|
|||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||
NODE_IP: ${NODE_IP}
|
||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
||||
# Bearer mam-api forwards to a node-agent when installing capture drivers
|
||||
# ("Capture Drivers / SDKs" panel). Set to the same value as the agents'
|
||||
# NODE_TOKEN. If empty, agents with an empty NODE_TOKEN accept the call
|
||||
# (dev); agents with a token will reject it (401).
|
||||
NODE_AGENT_TOKEN: ${NODE_AGENT_TOKEN:-}
|
||||
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
|
||||
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
||||
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
||||
|
|
|
|||
42
sdk/README.md
Normal file
42
sdk/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Capture-card SDK / driver file store
|
||||
|
||||
This directory holds the **proprietary, non-redistributable** vendor SDKs and
|
||||
drivers used to enable SDI / NDI capture cards on cluster nodes.
|
||||
|
||||
> **INTERNAL ONLY.** These files are licensed by their respective vendors and
|
||||
> must **not** be published, redistributed, or committed to any public mirror.
|
||||
> This repository is private. Do not change that.
|
||||
|
||||
## Why these live in the repo
|
||||
|
||||
The cluster admin screen lets an operator install/update capture-card drivers on
|
||||
a node from the web UI (no SSH). The node-agent spawns a one-shot privileged
|
||||
container that bind-mounts this repository and runs
|
||||
[`deploy/install-driver.sh <vendor>`](../deploy/install-driver.sh), which reads
|
||||
the vendor files from `sdk/<vendor>/`. Because the install must work offline on
|
||||
an isolated broadcast LAN, the binaries ship in-repo rather than being fetched at
|
||||
install time.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
sdk/
|
||||
README.md ← this file
|
||||
blackmagic/ ← Blackmagic Desktop Video (DeckLink) .deb
|
||||
aja/ ← AJA ntv2 driver source / installer
|
||||
deltacast/ ← Deltacast VideoMaster installer
|
||||
ndi/ ← NDI redistributable runtime libs
|
||||
```
|
||||
|
||||
Each vendor directory has its own `README.md` listing **exactly** which files an
|
||||
admin must drop in. A `.gitkeep` keeps the empty directory committed.
|
||||
|
||||
## Important
|
||||
|
||||
- **No binaries are committed by default.** The directory structure + READMEs
|
||||
are the deliverable. An admin downloads the proprietary files from the vendor
|
||||
(per their licence) and drops them in the matching `sdk/<vendor>/` directory.
|
||||
- The install script **fails gracefully** with a clear message if the expected
|
||||
file is absent — it never fabricates or downloads binaries.
|
||||
- Target host OS for all install paths is **Ubuntu 22.04 LTS (jammy), x86_64**,
|
||||
matching the cluster worker nodes.
|
||||
0
sdk/aja/.gitkeep
Normal file
0
sdk/aja/.gitkeep
Normal file
31
sdk/aja/README.md
Normal file
31
sdk/aja/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# AJA NTV2 driver
|
||||
|
||||
Drop the **AJA NTV2** driver source/SDK archive for Linux into this directory.
|
||||
|
||||
## Required file
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `ntv2sdk*linux*.zip` **or** `libajantv2*.tar.gz` | The NTV2 SDK / open-source `libajantv2` source tree containing `driver/linux/` with the kernel-module `Makefile` and `load_ajantv2` / `unload_ajantv2` scripts. |
|
||||
|
||||
Example names: `ntv2sdklinux_17.0.1.zip`, `libajantv2-17.0.1.tar.gz`
|
||||
|
||||
The installer reads the **newest** matching archive.
|
||||
|
||||
## Where to get it
|
||||
|
||||
AJA → Support → Software & firmware → *NTV2 SDK* (Linux), or the public
|
||||
`aja-video/libajantv2` source release. Download the Linux SDK zip / source
|
||||
tarball and copy it here unmodified.
|
||||
|
||||
## What the install script does
|
||||
|
||||
1. Ensures `linux-headers-$(uname -r)`, `build-essential` are present.
|
||||
2. Extracts the archive into a scratch build dir.
|
||||
3. Builds the `ajantv2` kernel module from `driver/linux` (`make`).
|
||||
4. Installs the module under `/lib/modules/$(uname -r)/extra`, runs `depmod`,
|
||||
`modprobe ajantv2` (falls back to the SDK's `load_ajantv2` script).
|
||||
5. Verifies the `ajantv2` module is loaded.
|
||||
|
||||
A **reboot is not normally required**; the module loads immediately after build.
|
||||
The script reports if a reboot is needed (e.g. an old in-tree module is wedged).
|
||||
0
sdk/blackmagic/.gitkeep
Normal file
0
sdk/blackmagic/.gitkeep
Normal file
35
sdk/blackmagic/README.md
Normal file
35
sdk/blackmagic/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Blackmagic Desktop Video (DeckLink) driver
|
||||
|
||||
Drop the **Blackmagic Desktop Video** Debian package for **Ubuntu 22.04 (x86_64)**
|
||||
into this directory.
|
||||
|
||||
## Required file
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `desktopvideo_*_amd64.deb` | The `desktopvideo` package from the Desktop Video installer archive. Provides the `blackmagic` kernel module (built via DKMS) and the `DesktopVideoHelper` daemon. |
|
||||
|
||||
Example name: `desktopvideo_14.4.1a4_amd64.deb`
|
||||
|
||||
The installer reads the **newest** matching `desktopvideo_*_amd64.deb` if more
|
||||
than one is present.
|
||||
|
||||
## Where to get it
|
||||
|
||||
Blackmagic Design → Support → *Desktop Video* (Linux). Download the
|
||||
"Desktop Video x.y.z Linux" tarball, extract it, and copy the
|
||||
`deb/<arch>/desktopvideo_*_amd64.deb` file here.
|
||||
|
||||
> Optional: `desktopvideo-gui_*_amd64.deb` is **not** required for headless
|
||||
> capture and is not installed.
|
||||
|
||||
## What the install script does
|
||||
|
||||
1. Ensures `linux-headers-$(uname -r)` is present (needed for the DKMS build).
|
||||
2. `apt-get install -y ./desktopvideo_*_amd64.deb` (pulls DKMS deps).
|
||||
3. Triggers the DKMS build, `depmod`, `modprobe blackmagic`.
|
||||
4. Restarts the `DesktopVideoHelper` daemon.
|
||||
5. Verifies `/dev/blackmagic` appears.
|
||||
|
||||
A **reboot is usually not required** but a DKMS rebuild against a freshly
|
||||
installed kernel may need one — the script reports this.
|
||||
0
sdk/deltacast/.gitkeep
Normal file
0
sdk/deltacast/.gitkeep
Normal file
31
sdk/deltacast/README.md
Normal file
31
sdk/deltacast/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Deltacast VideoMaster driver / SDK
|
||||
|
||||
Drop the **Deltacast VideoMaster** Linux installer into this directory.
|
||||
|
||||
## Required file
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `VideoMaster*.run` **or** `VideoMaster*linux*.tar.gz` | The VideoMaster SDK + driver installer for Linux. Contains the `videomasterhd` kernel module sources and the `install.sh` driver installer. |
|
||||
|
||||
Example names: `VideoMaster-6.25.0.run`, `VideoMaster_6_25_Linux.tar.gz`
|
||||
|
||||
The installer reads the **newest** matching file.
|
||||
|
||||
## Where to get it
|
||||
|
||||
Deltacast → Products → *SDK* (<https://www.deltacast.tv/products/sdk>). Request
|
||||
the VideoMaster Linux package (licence-gated) and copy the `.run` self-extractor
|
||||
or the `.tar.gz` here unmodified.
|
||||
|
||||
## What the install script does
|
||||
|
||||
1. Ensures `linux-headers-$(uname -r)`, `build-essential`, `dkms` are present.
|
||||
2. Runs the vendor installer:
|
||||
- `.run` → executed with `--noexec --target <dir>` then its `install.sh`,
|
||||
- `.tar.gz` → extracted, then its bundled `install.sh` is run.
|
||||
3. Loads the Deltacast module (`modprobe videomasterhd` / vendor load script).
|
||||
4. Verifies a `/dev/deltacast*` device node appears.
|
||||
|
||||
A **reboot may be required** after a first-time VideoMaster install (udev rules
|
||||
+ firmware). The script reports this explicitly.
|
||||
0
sdk/ndi/.gitkeep
Normal file
0
sdk/ndi/.gitkeep
Normal file
35
sdk/ndi/README.md
Normal file
35
sdk/ndi/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# NDI redistributable runtime
|
||||
|
||||
Drop the **NDI runtime redistributable** shared libraries into this directory.
|
||||
NDI has **no kernel module** — it is purely user-space shared libraries, so this
|
||||
is the lowest-risk install (no DKMS, no reboot).
|
||||
|
||||
## Required files
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `libndi.so.*` | The versioned NDI runtime shared object, e.g. `libndi.so.6`. **Required.** |
|
||||
| `libndi.so` *(optional)* | Dev symlink. The installer recreates it if absent. |
|
||||
|
||||
You may instead drop the whole **NDI SDK / Advanced SDK** `lib/x86_64-linux-gnu/`
|
||||
directory contents here; the installer copies every `libndi*.so*` it finds.
|
||||
|
||||
Example name: `libndi.so.6.1.1`
|
||||
|
||||
## Where to get it
|
||||
|
||||
NDI → Tools / SDK download (NDI 6 SDK or NDI Advanced SDK for Linux). The
|
||||
runtime libs live under `lib/x86_64-linux-gnu/` in the SDK. Per the NDI licence
|
||||
the runtime is redistributable **within your own product** only — keep it in this
|
||||
private repo, do not publish it.
|
||||
|
||||
## What the install script does
|
||||
|
||||
1. Copies every `libndi*.so*` from here into `/opt/ndi-lib`.
|
||||
2. Writes `/etc/ld.so.conf.d/ndi.conf` pointing at `/opt/ndi-lib` and runs
|
||||
`ldconfig`.
|
||||
3. Recreates the `libndi.so` → `libndi.so.<N>` dev symlink if missing.
|
||||
4. Verifies `ldconfig -p | grep libndi` resolves.
|
||||
|
||||
**No reboot required.** Running processes that already loaded an old `libndi`
|
||||
must be restarted to pick up the new version — the script notes this.
|
||||
|
|
@ -350,6 +350,83 @@ router.get('/:id/ping', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Capture-driver / SDK deployment ────────────────────────────────────────
|
||||
// Admins install/update vendor capture-card drivers on a node from the UI.
|
||||
// We resolve the node's api_url (like /:id/ping) and forward to its node-agent,
|
||||
// which runs deploy/install-driver.sh <vendor> in a privileged one-shot
|
||||
// container against the host kernel. Vendor is allowlisted here AND on the
|
||||
// agent. We never echo the agent token or proprietary paths back to the client.
|
||||
const DRIVER_VENDORS = ['blackmagic', 'aja', 'deltacast', 'ndi'];
|
||||
|
||||
// Bearer the agent expects (its NODE_TOKEN). Configured server-side; never
|
||||
// derived from client input and never returned to the browser.
|
||||
function agentAuthHeaders() {
|
||||
const tok = process.env.NODE_AGENT_TOKEN || '';
|
||||
return tok ? { Authorization: `Bearer ${tok}` } : {};
|
||||
}
|
||||
|
||||
async function resolveNode(id) {
|
||||
const r = await pool.query('SELECT id, hostname, api_url, capabilities FROM cluster_nodes WHERE id = $1', [id]);
|
||||
return r.rowCount === 0 ? null : r.rows[0];
|
||||
}
|
||||
|
||||
router.get('/:id/driver-status', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const node = await resolveNode(req.params.id);
|
||||
if (!node) return res.status(404).json({ error: 'Node not found' });
|
||||
if (!node.api_url) return res.status(409).json({ error: 'Node has no api_url registered' });
|
||||
try {
|
||||
const upstream = await fetch(`${node.api_url}/driver/status`, {
|
||||
headers: agentAuthHeaders(),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
const body = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) {
|
||||
return res.status(502).json({ error: 'Agent driver-status failed', status: upstream.status });
|
||||
}
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Node unreachable', reason: err.message });
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/:id/install-driver', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const vendor = String(req.body?.vendor || '').toLowerCase();
|
||||
if (!DRIVER_VENDORS.includes(vendor)) {
|
||||
return res.status(400).json({ error: `Invalid vendor (allowed: ${DRIVER_VENDORS.join(', ')})` });
|
||||
}
|
||||
const node = await resolveNode(req.params.id);
|
||||
if (!node) return res.status(404).json({ error: 'Node not found' });
|
||||
if (!node.api_url) return res.status(409).json({ error: 'Node has no api_url registered' });
|
||||
|
||||
try {
|
||||
// DKMS builds can take minutes — generous timeout.
|
||||
const upstream = await fetch(`${node.api_url}/driver/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...agentAuthHeaders() },
|
||||
body: JSON.stringify({ vendor }),
|
||||
signal: AbortSignal.timeout(600000),
|
||||
});
|
||||
const body = await upstream.json().catch(() => ({}));
|
||||
// Relay logs/result. install-driver.sh never echoes secrets; the agent
|
||||
// returns only its structured [install-driver] log lines + status.
|
||||
res.status(upstream.ok ? 200 : 502).json({
|
||||
ok: !!body.ok,
|
||||
vendor,
|
||||
exitCode: body.exitCode ?? null,
|
||||
rebootRequired: !!body.rebootRequired,
|
||||
status: body.status ?? null,
|
||||
logs: typeof body.logs === 'string' ? body.logs : '',
|
||||
error: body.ok ? undefined : (body.error || 'Install failed — see logs'),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Node unreachable or install timed out', reason: err.message });
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/metrics', async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,16 @@ const NODE_ROLE = process.env.NODE_ROLE || 'worker';
|
|||
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
|
||||
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
|
||||
const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
||||
const VERSION = '1.3.0';
|
||||
// Host path to the checked-out repo (onboard-node.sh clones to /opt/wild-dragon).
|
||||
// The driver-install container bind-mounts this so install-driver.sh can read
|
||||
// sdk/<vendor>/ and run from deploy/. Overridable for non-standard layouts.
|
||||
const REPO_DIR = process.env.REPO_DIR || '/opt/wild-dragon';
|
||||
const VERSION = '1.4.0';
|
||||
|
||||
// Capture-driver vendor allowlist. NOTHING outside this set is ever passed to
|
||||
// the host installer — the value is only ever used to pick a script arg, never
|
||||
// interpolated into a shell string.
|
||||
const DRIVER_VENDORS = ['blackmagic', 'aja', 'deltacast', 'ndi'];
|
||||
|
||||
// Pick the host's LAN IP. Inside a bridge-mode container,
|
||||
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
|
||||
|
|
@ -227,6 +236,147 @@ async function handleSidecarStatus(containerId, res) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Agent auth ────────────────────────────────────────────────────────────
|
||||
// When NODE_TOKEN is configured, privileged control endpoints (driver install)
|
||||
// require a matching `Authorization: Bearer <NODE_TOKEN>`. mam-api forwards the
|
||||
// node's stored token. If NODE_TOKEN is empty (dev), auth is not enforced.
|
||||
function checkAgentAuth(req) {
|
||||
if (!NODE_TOKEN) return true;
|
||||
const hdr = req.headers['authorization'] || '';
|
||||
const m = /^Bearer\s+(.+)$/i.exec(hdr);
|
||||
return !!m && m[1] === NODE_TOKEN;
|
||||
}
|
||||
|
||||
// ── Driver/SDK install ────────────────────────────────────────────────────
|
||||
// Probe host presence of each capture-driver vendor. Mirrors the detection the
|
||||
// install script uses, so the UI can show "installed / not installed" without
|
||||
// running the installer. Best-effort: every probe is guarded.
|
||||
function probeDriverStatus() {
|
||||
const out = {};
|
||||
|
||||
// blackmagic — kernel module + /dev/blackmagic device tree.
|
||||
let bmLoaded = false;
|
||||
try { bmLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /^blackmagic\b/.test(l)); } catch (_) {}
|
||||
let bmDev = false;
|
||||
try { bmDev = fs.existsSync('/dev/blackmagic') && fs.readdirSync('/dev/blackmagic').length > 0; } catch (_) {}
|
||||
out.blackmagic = { installed: bmLoaded || bmDev, module_loaded: bmLoaded, device_present: bmDev };
|
||||
|
||||
// aja — ajantv2 kernel module.
|
||||
let ajaLoaded = false;
|
||||
try { ajaLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /ajantv2/.test(l)); } catch (_) {}
|
||||
out.aja = { installed: ajaLoaded, module_loaded: ajaLoaded };
|
||||
|
||||
// deltacast — videomaster module or /dev/deltacast* node.
|
||||
let dcLoaded = false;
|
||||
try { dcLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /videomaster/.test(l)); } catch (_) {}
|
||||
let dcDev = false;
|
||||
try { dcDev = fs.readdirSync('/dev').some(n => /^deltacast\d+$/.test(n)); } catch (_) {}
|
||||
out.deltacast = { installed: dcLoaded || dcDev, module_loaded: dcLoaded, device_present: dcDev };
|
||||
|
||||
// ndi — user-space libs only. Look in the install target + common lib dirs.
|
||||
let ndiPresent = false;
|
||||
try {
|
||||
for (const dir of ['/opt/ndi-lib', '/usr/local/lib', '/usr/lib/x86_64-linux-gnu']) {
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(dir); } catch (_) { continue; }
|
||||
if (entries.some(n => /^libndi\.so/.test(n))) { ndiPresent = true; break; }
|
||||
}
|
||||
} catch (_) {}
|
||||
out.ndi = { installed: ndiPresent, libs_present: ndiPresent };
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleDriverStatus(res) {
|
||||
try {
|
||||
jsonResponse(res, 200, { kernel: os.release(), vendors: probeDriverStatus() });
|
||||
} catch (err) {
|
||||
jsonResponse(res, 500, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Run install-driver.sh <vendor> inside a one-shot PRIVILEGED ubuntu container.
|
||||
// The repo is bind-mounted read-only at /repo; host kernel paths are mounted so
|
||||
// dkms/modprobe/ldconfig affect the host. Logs are streamed back to the caller.
|
||||
async function handleDriverInstall(body, res) {
|
||||
const vendor = String(body?.vendor || '').toLowerCase();
|
||||
if (!DRIVER_VENDORS.includes(vendor)) {
|
||||
return jsonResponse(res, 400, { error: `Invalid vendor (allowed: ${DRIVER_VENDORS.join(', ')})` });
|
||||
}
|
||||
|
||||
let containerId;
|
||||
try {
|
||||
// Host paths the installer needs to reach the host kernel:
|
||||
// /lib/modules,/usr/src,/boot → DKMS / module build + install
|
||||
// /dev → device-node visibility + udev
|
||||
// The repo (sdk/<vendor>/ + deploy/install-driver.sh) is mounted read-only.
|
||||
const binds = [
|
||||
`${REPO_DIR}:/repo:ro`,
|
||||
'/lib/modules:/lib/modules',
|
||||
'/usr/src:/usr/src',
|
||||
'/boot:/boot',
|
||||
'/dev:/dev',
|
||||
// NDI install target lives under /opt; expose host /opt so libs land on host.
|
||||
'/opt:/opt',
|
||||
];
|
||||
|
||||
const spec = {
|
||||
Image: 'ubuntu:22.04',
|
||||
// NOTE: vendor is a value from DRIVER_VENDORS only — never arbitrary input.
|
||||
// Passed as a distinct argv element (Cmd array), not a shell string.
|
||||
Cmd: ['bash', '/repo/deploy/install-driver.sh', vendor],
|
||||
Env: [`REPO_DIR=/repo`],
|
||||
WorkingDir: '/repo',
|
||||
HostConfig: {
|
||||
Privileged: true,
|
||||
NetworkMode: 'host',
|
||||
Binds: binds,
|
||||
AutoRemove: false,
|
||||
},
|
||||
};
|
||||
|
||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||
if (createRes.status !== 201) {
|
||||
return jsonResponse(res, 502, { error: 'Failed to create install container', details: createRes.data });
|
||||
}
|
||||
containerId = createRes.data.Id;
|
||||
console.log(`[driver-install] ${containerId} vendor=${vendor}`);
|
||||
|
||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
if (startRes.status !== 204) {
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
return jsonResponse(res, 502, { error: 'Failed to start install container', details: startRes.data });
|
||||
}
|
||||
|
||||
// Wait for the install to finish (DKMS builds can take a minute+).
|
||||
let exitCode = null;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const inspect = await dockerApi('GET', `/containers/${containerId}/json`);
|
||||
const state = inspect.data?.State;
|
||||
if (state && !state.Running) { exitCode = state.ExitCode; break; }
|
||||
}
|
||||
|
||||
const logs = await fetchContainerLogs(containerId);
|
||||
const rebootRequired = /REBOOT_REQUIRED=1/.test(logs);
|
||||
const ok = exitCode === 0;
|
||||
jsonResponse(res, ok ? 200 : 500, {
|
||||
ok,
|
||||
vendor,
|
||||
exitCode,
|
||||
rebootRequired,
|
||||
logs,
|
||||
status: probeDriverStatus()[vendor] || null,
|
||||
});
|
||||
} catch (err) {
|
||||
jsonResponse(res, 500, { error: err.message });
|
||||
} finally {
|
||||
if (containerId) {
|
||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CPU sampling (500ms window) ───────────────────────────────────────────
|
||||
function sampleCpu() {
|
||||
return new Promise(resolve => {
|
||||
|
|
@ -572,6 +722,16 @@ const server = http.createServer((req, res) => {
|
|||
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
||||
handleSidecarStatus(id, res);
|
||||
|
||||
} else if (req.method === 'GET' && pathname === '/driver/status') {
|
||||
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
|
||||
handleDriverStatus(res);
|
||||
|
||||
} else if (req.method === 'POST' && pathname === '/driver/install') {
|
||||
if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' });
|
||||
readBody(req)
|
||||
.then(body => handleDriverInstall(body, res))
|
||||
.catch(() => jsonResponse(res, 400, { error: 'Invalid request body' }));
|
||||
|
||||
} else if (req.method === 'GET' && pathname.startsWith('/live/')) {
|
||||
serveLiveFile(pathname, res);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ function _normalizeNode(n, x, y) {
|
|||
online: b.online !== false,
|
||||
}));
|
||||
|
||||
// Deltacast ports — used by the Capture Drivers panel as a secondary
|
||||
// "driver present?" signal (heartbeat only reports a port if the card is seen).
|
||||
const deltacastPorts = (cap.deltacast || []).map(d => ({
|
||||
index: d.index ?? 0,
|
||||
device: d.device || null,
|
||||
}));
|
||||
|
||||
const memUsedMb = n.mem_used_mb || n.memory_used_mb || (n.mem && n.mem < 1000 ? n.mem * 1024 : n.mem || 0);
|
||||
const memTotalMb = n.mem_total_mb || n.memory_total_mb || (n.memTotal && n.memTotal < 1000 ? n.memTotal * 1024 : n.memTotal || 0);
|
||||
|
||||
|
|
@ -38,6 +45,7 @@ function _normalizeNode(n, x, y) {
|
|||
// Raw capabilities for the hardware panel
|
||||
gpus,
|
||||
bmdPorts,
|
||||
deltacastPorts,
|
||||
// Legacy flat arrays kept for the stat-row summary cards
|
||||
gpuCount: gpus.length,
|
||||
bmdCount: bmdPorts.length,
|
||||
|
|
@ -1169,6 +1177,148 @@ function Containers() {
|
|||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// DriverPanel - "Capture Drivers / SDKs" section inside the node detail panel.
|
||||
// Per vendor (Blackmagic / AJA / Deltacast / NDI): shows detected status from
|
||||
// GET /cluster/:id/driver-status (host probe) cross-checked with heartbeat
|
||||
// capabilities, plus an "Install / Update" button that POSTs
|
||||
// /cluster/:id/install-driver {vendor} and streams the agent log into a live
|
||||
// output area, surfacing success/failure + "reboot required".
|
||||
const DRIVER_VENDORS = [
|
||||
{ key: 'blackmagic', label: 'Blackmagic', hint: 'Desktop Video driver (.deb)' },
|
||||
{ key: 'aja', label: 'AJA', hint: 'NTV2 driver / SDK' },
|
||||
{ key: 'deltacast', label: 'Deltacast', hint: 'VideoMaster installer' },
|
||||
{ key: 'ndi', label: 'NDI', hint: 'Redistributable runtime libs' },
|
||||
];
|
||||
|
||||
function DriverPanel({ sel }) {
|
||||
const [status, setStatus] = React.useState(null); // { kernel, vendors:{...} }
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [statusErr, setStatusErr] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(null); // vendor key currently installing
|
||||
const [log, setLog] = React.useState(null); // { vendor, text, ok, rebootRequired }
|
||||
|
||||
const loadStatus = React.useCallback(() => {
|
||||
if (!sel.dbId) return;
|
||||
setLoading(true); setStatusErr(null);
|
||||
window.ZAMPP_API.fetch(`/cluster/${sel.dbId}/driver-status`)
|
||||
.then(d => { setStatus(d); setLoading(false); })
|
||||
.catch(e => { setStatusErr(e.message || 'unreachable'); setLoading(false); });
|
||||
}, [sel.dbId]);
|
||||
|
||||
React.useEffect(() => { loadStatus(); }, [loadStatus]);
|
||||
|
||||
// Heartbeat-reported capabilities give a second signal for the two card types
|
||||
// the cluster already enumerates (Blackmagic ports, Deltacast ports).
|
||||
const capPresent = (vendor) => {
|
||||
if (vendor === 'blackmagic') return (sel.bmdPorts || []).length > 0;
|
||||
if (vendor === 'deltacast') return (sel.deltacastPorts || []).length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInstalled = (vendor) => {
|
||||
const v = status && status.vendors && status.vendors[vendor];
|
||||
return (v && v.installed) || capPresent(vendor);
|
||||
};
|
||||
|
||||
const install = (vendor) => {
|
||||
setBusy(vendor);
|
||||
setLog({ vendor, text: `[ui] requesting install of ${vendor} on ${sel.id}…\n`, ok: null, rebootRequired: false });
|
||||
// Raw fetch: we need the JSON body (logs) even on a non-2xx response.
|
||||
fetch(`/api/v1/cluster/${sel.dbId}/install-driver`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||||
body: JSON.stringify({ vendor }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
let body = {};
|
||||
try { body = await res.json(); } catch (_) {}
|
||||
const text = (body.logs && body.logs.trim())
|
||||
? body.logs
|
||||
: (body.error || `Install ${res.ok ? 'completed' : 'failed'} (HTTP ${res.status})`);
|
||||
setLog({ vendor, text, ok: !!body.ok, rebootRequired: !!body.rebootRequired });
|
||||
setBusy(null);
|
||||
loadStatus();
|
||||
})
|
||||
.catch((e) => {
|
||||
setLog({ vendor, text: `[ui] request failed: ${e.message}`, ok: false, rebootRequired: false });
|
||||
setBusy(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Icon name="download" size={11} />
|
||||
Capture Drivers / SDKs
|
||||
{status && status.kernel && (
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>
|
||||
kernel {status.kernel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{statusErr && (
|
||||
<div style={{ fontSize: 11, color: "var(--danger)", marginBottom: 6 }}>
|
||||
Driver status unavailable: {statusErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{DRIVER_VENDORS.map(v => {
|
||||
const installed = isInstalled(v.key);
|
||||
const installing = busy === v.key;
|
||||
return (
|
||||
<div key={v.key} style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "6px 10px", background: "var(--bg-2)", borderRadius: 5,
|
||||
border: "1px solid var(--border)",
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>{v.label}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--text-4)" }}>{v.hint}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3,
|
||||
background: installed ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)",
|
||||
color: installed ? "var(--success)" : "var(--text-3)",
|
||||
}}>
|
||||
{loading ? "…" : (installed ? "INSTALLED" : "NOT INSTALLED")}
|
||||
</span>
|
||||
<button className="btn ghost sm" disabled={installing || !!busy}
|
||||
onClick={() => install(v.key)}>
|
||||
{installing ? "Installing…" : (installed ? "Update" : "Install")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{log && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10.5, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>{log.vendor} install output</span>
|
||||
{log.ok === true && <span style={{ color: "var(--success)", fontWeight: 700 }}>SUCCESS</span>}
|
||||
{log.ok === false && <span style={{ color: "var(--danger)", fontWeight: 700 }}>FAILED</span>}
|
||||
{log.rebootRequired && (
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 700, color: "var(--warning, #f0a500)" }}>
|
||||
⚠ REBOOT REQUIRED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0, maxHeight: 180, overflow: "auto",
|
||||
fontSize: 10.5, lineHeight: 1.4, fontFamily: "var(--font-mono)",
|
||||
color: "var(--text-2)", background: "var(--bg-1)",
|
||||
border: "1px solid var(--border)", borderRadius: 4, padding: "6px 8px",
|
||||
whiteSpace: "pre-wrap", wordBreak: "break-word",
|
||||
}}>{log.text}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BmdCardPanel - capture-card section inside the Cluster node detail panel.
|
||||
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1556,6 +1706,9 @@ function Cluster() {
|
|||
|
||||
{/* ── Capture cards ── */}
|
||||
<BmdCardPanel sel={sel} portSignals={portSignals} />
|
||||
|
||||
{/* ── Capture Drivers / SDKs ── */}
|
||||
<DriverPanel sel={sel} />
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
|
||||
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue