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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 17:37:37 -04:00
parent f21bc490e8
commit 19f0abeabe
5 changed files with 275 additions and 27 deletions

View file

@ -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

View file

@ -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

View file

@ -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() {
)}
</div>
</div>
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
{adviceModal && (
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
<div className="modal" style={{ width: 560 }} onClick={e => 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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{!command && (
<React.Fragment>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
<input className="field-input" style={{ width: '100%' }} autoFocus
placeholder="e.g. zampp3"
value={nodeName} onChange={e => setNodeName(e.target.value)} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Role</label>
<div style={{ display: 'flex', gap: 6 }}>
{ADD_NODE_ROLES.map(rd => (
<button key={rd.id}
className={'btn sm' + (role === rd.id ? ' primary' : ' ghost')}
style={{ flex: 1, flexDirection: 'column', alignItems: 'flex-start', gap: 2, padding: '8px 10px' }}
onClick={() => setRole(rd.id)}>
<span style={{ fontWeight: 600 }}>{rd.label}</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>{rd.desc}</span>
</button>
))}
</div>
</div>
<div style={{ marginBottom: 4 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
<input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
placeholder="http://10.0.0.25:47432"
value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
The LAN address this new node will heartbeat to. Edit if the guess is wrong.
</div>
</div>
</React.Fragment>
)}
{command && (
<React.Fragment>
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
This token is shown only once copy the command now.
</div>
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
<ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
<li>SSH into the fresh Ubuntu machine.</li>
<li>Paste and run this command.</li>
<li>The node appears in this Cluster view within ~30s.</li>
</ol>
</React.Fragment>
)}
{error && (
<div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
)}
</div>
<div className="modal-foot">
{!command && (
<React.Fragment>
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" disabled={busy} onClick={generate}>
{busy ? 'Generating…' : 'Generate command'}
</button>
</React.Fragment>
)}
{command && (
<React.Fragment>
<button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
<button className="btn primary sm" onClick={onClose}>Done</button>
</React.Fragment>
)}
</div>
</div>
</div>
);
}
function DetailRow({ k, v, mono }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>

View file

@ -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 }) {
<div className="launcher">
<div className="launcher-inner">
<div className="launcher-hero">
<img
className="launcher-logo"
src="img/dragon-logo.png"
alt="Dragonflight"
draggable="false"
/>
<span className="launcher-logo-wrap">
<span className="launcher-logo-pulse" aria-hidden="true" />
<img
className="launcher-logo"
src="img/dragon-logo.png"
alt="Dragonflight"
draggable="false"
/>
</span>
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
<p className="launcher-kicker">Let's Create</p>
<p className="launcher-tagline">
Self-hosted broadcast media-asset management
Media Asset Management &amp; Production Platform
</p>
</div>
@ -167,6 +172,23 @@ function Home({ navigate }) {
</button>
</div>
<div className="launcher-settings-row">
<button
className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-settings'}
onClick={() => navigate(settingsTile.id)}
>
<span className="launcher-tile-icon">
<Icon name={settingsTile.icon} size={26} />
</span>
<span className="launcher-tile-label">{settingsTile.label}</span>
<span className="launcher-tile-sub">{settingsTile.sub}</span>
<span className="launcher-tile-desc">{settingsTile.desc}</span>
<span className="launcher-tile-arrow">
<Icon name="arrowRight" size={14} />
</span>
</button>
</div>
{hasActivity && (
<div className="launcher-activity">
{(failedCount > 0 || errCount > 0) && (

View file

@ -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;