From 19f0abeabe39eb5ded4378ceb36cc8f17e126a9b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 17:37:37 -0400 Subject: [PATCH] feat(cluster+home): Add Node wizard, homepage tagline/logo/settings tweaks Cluster: AddNodeModal on Admin->Cluster mints a node token via /auth/tokens and emits a ready-to-paste curl|bash onboarding command. New admin-only GET /cluster/onboard-info returns apiUrl/scriptUrl/branch. Role->PROFILES mapping (worker/capture/gpu); gate worker-l4 behind compose profile [gpu]. Home: restore "Let's Create" kicker + one-line "Media Asset Management & Production Platform" tagline; animated accent pulse behind the dragon logo (reduced-motion safe); move Settings tile to a centered bottom row. Co-Authored-By: Claude Opus 4.8 --- docker-compose.worker.yml | 1 + services/mam-api/src/routes/cluster.js | 14 ++ services/web-ui/public/screens-admin.jsx | 174 +++++++++++++++++++++-- services/web-ui/public/screens-home.jsx | 52 +++++-- services/web-ui/public/styles-fixes.css | 61 ++++++++ 5 files changed, 275 insertions(+), 27 deletions(-) diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 629516e..110ccd0 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -103,6 +103,7 @@ services: # worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to # zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here. worker-l4: + profiles: [gpu] build: context: ./services/worker dockerfile: Dockerfile.gpu diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 174ca49..d13e64f 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -1,9 +1,23 @@ import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; +import { requireAdmin } from '../middleware/auth.js'; const router = express.Router(); +// GET /onboard-info – admin-only. Supplies the Add Node wizard with the bits it +// needs to build a `curl … | bash` onboarding command: the primary API URL the +// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and +// the deploy branch. apiUrl is a best guess the UI lets the operator edit. +router.get('/onboard-info', requireAdmin, (req, res) => { + const branch = process.env.DEPLOY_BRANCH || 'main'; + const apiUrl = process.env.PUBLIC_API_URL + || `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`; + const scriptUrl = + `https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`; + res.json({ apiUrl, scriptUrl, branch }); +}); + // If the agent reported Docker's default bridge IP (172.17.x) but the request // itself came from a real LAN address, prefer the request source IP instead. // We only check 172.17.x — the default docker0 bridge — not the full RFC1918 diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 555080f..c561998 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -1181,18 +1181,8 @@ function Cluster() { const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?} - const addNode = () => setAdviceModal({ - title: 'Add a worker node', - lines: [ - 'Worker nodes auto-register with the cluster on first heartbeat.', - 'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):', - ], - commands: [ - 'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight', - 'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP', - 'docker compose -f docker-compose.worker.yml up -d', - ], - }); + const [showAddNode, setShowAddNode] = React.useState(false); + const addNode = () => setShowAddNode(true); const drainNode = (node) => setAdviceModal({ title: `Drain ${node.id}`, @@ -1399,6 +1389,7 @@ function Cluster() { )} + {showAddNode && setShowAddNode(false)} />} {adviceModal && (
setAdviceModal(null)}>
e.stopPropagation()}> @@ -1429,6 +1420,165 @@ function Cluster() { ); } +// AddNodeModal — Approach A onboarding wizard. Collects a node name + role, +// mints a one-time auth token via /auth/tokens, and renders a ready-to-paste +// `curl … | bash` command that provisions the machine via deploy/onboard-node.sh. +// +// Role → compose PROFILES mapping (see docker-compose.worker.yml): +// Worker → "worker" +// Capture → "worker capture" +// GPU → "worker gpu" (worker-l4 service, profiles: [gpu]) +const ADD_NODE_ROLES = [ + { id: 'worker', label: 'Worker', profiles: 'worker', desc: 'CPU transcode / general jobs' }, + { id: 'capture', label: 'Capture', profiles: 'worker capture', desc: 'SDI / DeckLink ingest' }, + { id: 'gpu', label: 'GPU', profiles: 'worker gpu', desc: 'NVENC-accelerated transcode' }, +]; + +function AddNodeModal({ onClose }) { + const [nodeName, setNodeName] = React.useState(''); + const [role, setRole] = React.useState('worker'); + const [apiUrl, setApiUrl] = React.useState(''); + const [info, setInfo] = React.useState(null); // { scriptUrl, branch } + const [command, setCommand] = React.useState(null); // generated string + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [copied, setCopied] = React.useState(false); + + // On open, prefill the editable apiUrl + capture scriptUrl/branch. + React.useEffect(() => { + window.ZAMPP_API.fetch('/cluster/onboard-info') + .then(d => { + setInfo({ scriptUrl: d.scriptUrl, branch: d.branch }); + if (d.apiUrl) setApiUrl(d.apiUrl); + }) + .catch(() => {}); // leave apiUrl empty → user must fill it before Generate + }, []); + + const roleDef = ADD_NODE_ROLES.find(r => r.id === role) || ADD_NODE_ROLES[0]; + + const generate = async () => { + setError(null); + if (!nodeName.trim()) { setError('Node name is required.'); return; } + if (!apiUrl.trim()) { setError('Primary API URL is required.'); return; } + setBusy(true); + try { + const r = await fetch('/api/v1/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ name: 'node: ' + nodeName.trim() }), + }); + if (r.status !== 201) { + const body = await r.json().catch(() => ({})); + setError(body.error || ('Failed to mint token (' + r.status + ')')); + return; + } + const { token } = await r.json(); + const scriptUrl = (info && info.scriptUrl) + || 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh'; + const cmd = + `curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} ` + + `NODE_ROLE=${role} PROFILES="${roleDef.profiles}" bash`; + setCommand(cmd); + } catch (e) { + setError(e.message || 'Network error'); + } finally { + setBusy(false); + } + }; + + const copy = () => { + if (!command || !navigator.clipboard) return; + navigator.clipboard.writeText(command) + .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }) + .catch(() => {}); + }; + + return ( +
+
e.stopPropagation()}> +
+
Add cluster node
+ +
+
+ {!command && ( + +
+ + setNodeName(e.target.value)} /> +
+ +
+ +
+ {ADD_NODE_ROLES.map(rd => ( + + ))} +
+
+ +
+ + setApiUrl(e.target.value)} /> +
+ The LAN address this new node will heartbeat to. Edit if the guess is wrong. +
+
+
+ )} + + {command && ( + +
+
+ This token is shown only once — copy the command now. +
+
+ {command} +
    +
  1. SSH into the fresh Ubuntu machine.
  2. +
  3. Paste and run this command.
  4. +
  5. The node appears in this Cluster view within ~30s.
  6. +
+
+ )} + + {error && ( +
{error}
+ )} +
+
+ {!command && ( + + + + + )} + {command && ( + + + + + )} +
+
+
+ ); +} + function DetailRow({ k, v, mono }) { return (
diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index 6a79944..b96fc92 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -89,16 +89,17 @@ function Home({ navigate }) { : (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'), desc: 'Proxy + thumbnail queue. Retry failed jobs.', }, - { - id: 'settings', - label: 'Settings', - icon: 'settings', - tone: 'neutral', - sub: 'S3 · Encoder · Growing files', - desc: 'Storage, proxy encoder, capture SDK, growing-file mode.', - }, ]; + const settingsTile = { + id: 'settings', + label: 'Settings', + icon: 'settings', + tone: 'neutral', + sub: 'S3 · Encoder · Growing files', + desc: 'Storage, proxy encoder, capture SDK, growing-file mode.', + }; + const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal; // Activity strip (#153): live recorders + last-24h assets + alerts. @@ -118,15 +119,19 @@ function Home({ navigate }) {
- Dragonflight + +

DRAGONFLIGHT

+

Let's Create

- Self-hosted broadcast media-asset management + Media Asset Management & Production Platform

@@ -167,6 +172,23 @@ function Home({ navigate }) {
+
+ +
+ {hasActivity && (
{(failedCount > 0 || errCount > 0) && ( diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 675c064..3272a12 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -293,7 +293,40 @@ text-align: center; margin-top: 8px; } +/* Logo wrapper holds the animated pulse halo behind the image. */ +.launcher-logo-wrap { + position: relative; + display: inline-grid; + place-items: center; + width: 180px; + height: 180px; +} +.launcher-logo-pulse { + position: absolute; + left: 50%; + top: 50%; + width: 200px; + height: 200px; + transform: translate(-50%, -50%) scale(0.85); + border-radius: 50%; + pointer-events: none; + z-index: 0; + background: radial-gradient( + circle at center, + color-mix(in srgb, var(--accent) 55%, transparent) 0%, + color-mix(in srgb, var(--accent) 22%, transparent) 38%, + transparent 68% + ); + filter: blur(2px); + animation: launcherLogoPulse 3.4s ease-in-out infinite; +} +@keyframes launcherLogoPulse { + 0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; } + 50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; } +} .launcher-logo { + position: relative; + z-index: 1; width: 180px; height: 180px; object-fit: contain; @@ -308,6 +341,9 @@ from { opacity: 0; transform: translateY(8px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } } +@media (prefers-reduced-motion: reduce) { + .launcher-logo-pulse { animation: none; opacity: 0.5; } +} .launcher-wordmark { margin: 0; font-size: 44px; @@ -317,11 +353,23 @@ color: var(--text-1); text-shadow: 0 0 32px rgba(91, 124, 250, 0.15); } +.launcher-kicker { + margin: 2px 0 0; + color: var(--accent); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; +} .launcher-tagline { margin: 0; color: var(--text-3); font-size: 13.5px; letter-spacing: 0.02em; + white-space: nowrap; +} +@media (max-width: 480px) { + .launcher-tagline { font-size: 11.5px; letter-spacing: 0; } } .launcher-grid { @@ -333,6 +381,19 @@ @media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } } +/* Settings sits on its own centered row beneath the main grid. */ +.launcher-settings-row { + width: 100%; + display: flex; + justify-content: center; +} +.launcher-tile-settings { + width: 100%; + max-width: calc((100% - 28px) / 3); +} +@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } } +@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } } + .launcher-tile { position: relative; display: grid;