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:
parent
f21bc490e8
commit
19f0abeabe
5 changed files with 275 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 & 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) && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue