dragonflight/services/web-ui/public/screens-admin.jsx
Zac Gaetano 328f7b4f31 feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00

1062 lines
52 KiB
JavaScript

// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings
function _normalizeNode(n, x, y) {
return {
id: n.id || n.hostname || n.name || 'node',
role: n.role || 'worker',
status: n.status || (n.online ? 'online' : 'offline'),
ip: n.ip || n.ip_address || '—',
version: n.version || '—',
uptime: n.uptime || '—',
cpu: n.cpu || n.cpu_percent || 0,
mem: n.mem || n.memory_used || n.memory_used_gb || 0,
memTotal: n.memTotal || n.mem_total || n.memory_total || n.memory_total_gb || 0,
gpus: n.gpus || (n.gpu_count ? Array(n.gpu_count).fill('GPU') : []),
devices: n.devices || n.capture_devices || [],
x, y,
};
}
function InviteUserModal({ onCreated, onClose }) {
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' });
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = () => {
if (!form.username || !form.password) { setErr('Username and password are required'); return; }
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) })
.then(user => { onCreated(user); onClose(); })
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); });
};
const onKey = e => { if (e.key === 'Enter') submit(); };
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Invite user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Username</label>
<input className="field-input" value={form.username} autoFocus
onChange={e => setForm(p => ({...p, username: e.target.value}))}
onKeyDown={onKey} placeholder="jsmith" />
</div>
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" value={form.display_name}
onChange={e => setForm(p => ({...p, display_name: e.target.value}))}
onKeyDown={onKey} placeholder="John Smith" />
</div>
<div className="field">
<label className="field-label">Password</label>
<input className="field-input" type="password" value={form.password}
onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" />
</div>
<div className="field">
<label className="field-label">Role</label>
<select className="field-input" value={form.role}
onChange={e => setForm(p => ({...p, role: e.target.value}))}
style={{ appearance: 'auto' }}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create user'}</button>
</div>
</div>
</div>
);
}
function Users() {
const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []);
const [tab, setTab] = React.useState("users");
const [showInvite, setShowInvite] = React.useState(false);
const exportCsv = () => {
const rows = [['Username', 'Name', 'Role', 'Last Seen']].concat(
users.map(u => [u.username || '', u.name || '', u.role || '', u.lastSeen || ''])
);
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
const a = document.createElement('a');
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
a.download = 'users.csv';
a.click();
};
const onCreated = (user) => {
const updated = [...users, user];
setUsers(updated);
window.ZAMPP_DATA.USERS = updated;
};
return (
<div className="page">
<div className="page-header">
<h1>Users &amp; Groups</h1>
<div className="spacer" />
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
</div>
<div className="page-body">
<div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
<button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups</button>
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
</div>
<div className="panel">
<div className="user-row head">
<div>User</div>
<div>Role</div>
<div>Groups</div>
<div>Last active</div>
<div></div>
</div>
{users.length === 0 && (
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
)}
{users.map(u => (
<div key={u.id} className="user-row">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
</div>
</div>
<div><span className={`badge ${u.role === "admin" ? "purple" : u.role === "service" ? "neutral" : "accent"}`}>{u.role}</span></div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{(u.groups || []).map(g => <span key={g} className="badge outline" style={{ textTransform: "lowercase" }}>{g}</span>)}
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>{u.lastSeen}</div>
<div><button className="icon-btn"><Icon name="more" /></button></div>
</div>
))}
</div>
</div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
</div>
);
}
function Tokens() {
const [burned, setBurned] = React.useState(14340);
const [rate, setRate] = React.useState(2.4);
const [showCalc, setShowCalc] = React.useState(false);
React.useEffect(() => {
const i = setInterval(() => {
setBurned(b => b + Math.floor(Math.random() * 8) + 1);
setRate(r => Math.max(0.8, Math.min(8, r + (Math.random() - 0.5) * 0.4)));
}, 800);
return () => clearInterval(i);
}, []);
const burnSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 100 + i * 8 + Math.sin(i * 0.7) * 12), []);
const competitorSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 200 + i * 22 + Math.cos(i * 0.5) * 30), []);
const yourCostSpark = React.useMemo(() => Array.from({ length: 40 }, () => 1), []);
const [events, setEvents] = React.useState([
{ t: "21:14:02", action: "preview thumbnail generated", cost: 4 },
{ t: "21:14:01", action: "user clicked play", cost: 12 },
{ t: "21:13:58", action: "API health check", cost: 8 },
{ t: "21:13:54", action: "asset metadata read", cost: 2 },
{ t: "21:13:51", action: "session token refreshed", cost: 18 },
{ t: "21:13:47", action: "scrubbed timeline 1 frame", cost: 6 },
{ t: "21:13:42", action: "took a deep breath near the API", cost: 24 },
]);
React.useEffect(() => {
const actions = [
"preview thumbnail generated", "user clicked play", "API health check",
"scrubbed timeline 1 frame", "asset metadata read", "session token refreshed",
"checked job queue", "rendered a tooltip", "loaded sidebar icon",
"blinked", "made eye contact with the cluster", "opened a modal (twice)",
"asset list pagination request", "thought about a comment", "moved cursor near 'Save'",
];
const i = setInterval(() => {
const now = new Date();
const t = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
const a = actions[Math.floor(Math.random() * actions.length)];
const c = Math.floor(Math.random() * 28) + 1;
setEvents(ev => [{ t, action: a, cost: c }, ...ev].slice(0, 12));
}, 1600);
return () => clearInterval(i);
}, []);
const tiers = [
{ name: "Starter", desc: "For \"evaluation only\" — definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
];
return (
<div className="page">
<div className="page-header">
<h1>Tokens</h1>
<span className="subtitle">Token-metered pricing parody · You actually pay <strong style={{ color: "var(--success)" }}>$0.00</strong></span>
<div className="spacer" />
<span className="badge warning"><Icon name="alert" size={10} /> SATIRE</span>
<button className="btn ghost sm" onClick={() => setShowCalc(!showCalc)}><Icon name="sliders" />Cost calculator</button>
</div>
<div className="page-body">
<div style={{ textAlign: 'center', padding: '8px 0 36px' }}>
<h2 style={{ fontSize: 30, fontWeight: 700, letterSpacing: '-0.02em', lineHeight: 1.3, margin: 0 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-seat</span>
{' · '}
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-stream</span>
{' · '}
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-month</span>
<br />
<span style={{ fontSize: 52, fontWeight: 800, color: 'var(--accent-text)', letterSpacing: '-0.03em' }}>Per Token.</span>
</h2>
</div>
<div className="token-hero">
<div className="token-burn-card">
<div className="token-card-label">TOKENS BURNED THIS SESSION</div>
<div className="token-counter">
<span className="token-flame">🔥</span>
<span className="token-big mono">{burned.toLocaleString()}</span>
</div>
<div className="token-rate">
<span className="mono" style={{ color: "var(--danger)" }}> {rate.toFixed(1)}k/sec</span>
<span style={{ color: "var(--text-3)", marginLeft: 10 }}>burning since you logged in</span>
</div>
<div style={{ marginTop: 12 }}>
<Sparkline data={burnSpark} color="#FF5B5B" />
</div>
</div>
<div className="token-actual-card">
<div className="token-card-label">WHAT YOU ACTUALLY PAY</div>
<div className="token-actual-amount">
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-0.04em" }}>$0</span>
<span style={{ fontSize: 18, color: "var(--text-3)" }}>.00</span>
</div>
<div style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5 }}>
Dragonflight is self-hosted. The tokens above are imaginary.<br />
Imagine them as a stress test for your sanity.
</div>
<div style={{ marginTop: 12 }}>
<Sparkline data={yourCostSpark} color="#2DD4A8" fill={false} />
</div>
</div>
</div>
<div className="token-comparison">
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN DRAGONFLIGHT vs. THE OTHER GUYS</div>
<div className="token-compare-chart">
<ChartLine
series={[
{ label: "AMPP-style competitor", data: competitorSpark, color: "#FF5B5B" },
{ label: "Dragonflight (yours)", data: yourCostSpark.map((_, i) => i < 20 ? 1 : 1), color: "#2DD4A8" },
]}
/>
<div className="token-compare-legend">
<div><span className="dot" style={{ background: "#FF5B5B" }} />Competitor: $1,247/hr and rising</div>
<div><span className="dot" style={{ background: "#2DD4A8" }} />Dragonflight: $0.00/hr forever</div>
</div>
</div>
</div>
<div className="token-grid">
<div>
<div className="token-card-label" style={{ marginBottom: 8 }}>LIVE BILLING EVENTS</div>
<div className="panel">
{events.map((e, i) => (
<div key={i} className={`token-event ${i === 0 ? "fresh" : ""}`}>
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{e.t}</span>
<span style={{ flex: 1, fontSize: 12.5 }}>{e.action}</span>
<span className="mono" style={{ color: "var(--danger)", fontWeight: 600 }}>+{e.cost} tk</span>
</div>
))}
</div>
</div>
<div>
<div className="token-card-label" style={{ marginBottom: 8 }}>PRICING TIERS WE DIDN'T COPY</div>
<div className="token-tiers">
{tiers.map(t => (
<div key={t.name} className={`token-tier ${t.popular ? "popular" : ""}`}>
{t.popular && <span className="token-tier-badge">MOST PAIN</span>}
<div className="token-tier-name" style={{ color: t.color }}>{t.name}</div>
<div className="token-tier-desc">{t.desc}</div>
<div className="token-tier-price">
<span style={{ fontSize: 26, fontWeight: 700, letterSpacing: "-0.02em" }}>{t.price}</span>
<span style={{ fontSize: 12, color: "var(--text-3)", marginLeft: 4 }}>{t.per}</span>
</div>
<div className="token-tier-tokens mono">{t.tokens}</div>
<button className="btn subtle sm" disabled style={{ width: "100%", marginTop: 8 }}>Not for sale</button>
</div>
))}
</div>
</div>
</div>
{showCalc && <CostCalculator onClose={() => setShowCalc(false)} />}
<div className="token-footnote">
<Icon name="alert" size={14} />
<div>
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists service
credentials are managed through the cluster's own JWT issuer (see <span style={{ color: "var(--accent-text)" }}>Settings → AMPP Integration</span>).
</div>
</div>
</div>
</div>
);
}
function ChartLine({ series }) {
const w = 600, h = 140;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: "100%", height: 140 }}>
<defs>
<pattern id="cgrid" width="60" height="28" patternUnits="userSpaceOnUse">
<path d="M 60 0 L 0 0 0 28" fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
</pattern>
</defs>
<rect width={w} height={h} fill="url(#cgrid)" />
{series.map((s, si) => {
const max = Math.max(...series.flatMap(x => x.data), 1);
const pts = s.data.map((d, i) => {
const x = (i / (s.data.length - 1)) * w;
const y = h - (d / max) * (h - 10) - 4;
return `${x},${y}`;
}).join(" ");
const area = `0,${h} ${pts} ${w},${h}`;
return (
<g key={si}>
<polygon points={area} fill={s.color} opacity="0.1" />
<polyline points={pts} fill="none" stroke={s.color} strokeWidth="2" />
<circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="4" fill={s.color} />
<circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="8" fill={s.color} opacity="0.3">
<animate attributeName="r" values="4;14;4" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite" />
</circle>
</g>
);
})}
</svg>
);
}
function CostCalculator({ onClose }) {
const [users, setUsers] = React.useState(12);
const [assets, setAssets] = React.useState(500);
const [clicks, setClicks] = React.useState(2000);
const cost = users * 240 + assets * 8 + clicks * 0.12;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Token Cost Calculator</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>What it would cost on AMPP-style pricing</div>
</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<CalcSlider label="Users" value={users} onChange={setUsers} min={1} max={100} unit=" people" />
<CalcSlider label="Assets in library" value={assets} onChange={setAssets} min={50} max={10000} step={50} unit="" />
<CalcSlider label="UI clicks per day" value={clicks} onChange={setClicks} min={100} max={20000} step={100} unit="" />
<div style={{ background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, marginTop: 8 }}>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>You would be paying</div>
<div style={{ fontSize: 36, fontWeight: 700, color: "var(--danger)", letterSpacing: "-0.02em", marginTop: 4 }}>
${cost.toLocaleString("en-US", { maximumFractionDigits: 0 })}<span style={{ fontSize: 14, color: "var(--text-3)", fontWeight: 400, marginLeft: 4 }}>/ month</span>
</div>
<div style={{ marginTop: 8, padding: 10, background: "var(--success-soft)", borderRadius: 6, fontSize: 12.5, color: "var(--success)" }}>
<strong>Your actual Dragonflight cost:</strong> $0.00. You're welcome.
</div>
</div>
</div>
</div>
</div>
);
}
function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) {
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6, fontSize: 12 }}>
<span style={{ color: "var(--text-2)" }}>{label}</span>
<span className="mono" style={{ color: "var(--text-1)", fontWeight: 600 }}>{value.toLocaleString()}{unit}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={e => onChange(Number(e.target.value))}
style={{ width: "100%", accentColor: "var(--accent)" }}
/>
</div>
);
}
function Containers() {
const [containers, setContainers] = React.useState(null);
function load() {
setContainers(null);
window.ZAMPP_API.fetch('/cluster/containers')
.then(data => setContainers(Array.isArray(data) ? data : (data.containers || [])))
.catch(() => setContainers([]));
}
React.useEffect(() => { load(); }, []);
const running = (containers || []).filter(c => c.state === 'running').length;
const showLogs = (c) => {
alert('To view logs for ' + c.name + ', run:\n\ndocker compose logs -f ' + c.name);
};
const restartContainer = (c) => {
if (!window.confirm('Restart container ' + c.name + '?')) return;
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
.then(() => { alert(c.name + ' restarted.'); load(); })
.catch(() => alert('No restart endpoint available.\nRun manually:\n\ndocker compose restart ' + c.name));
};
return (
<div className="page">
<div className="page-header">
<h1>Containers</h1>
<span className="subtitle">Docker Compose services across the cluster</span>
<div className="spacer" />
{containers !== null && containers.length > 0 && (
<div className="status-pip">
<span className="dot" />
<span>{running} / {containers.length} running</span>
</div>
)}
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
{containers === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
)}
{containers !== null && containers.length === 0 && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🐳</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>No container data available</div>
<div style={{ fontSize: 12, marginTop: 6 }}>Container metrics endpoint not yet wired</div>
</div>
)}
{containers !== null && containers.length > 0 && (
<div className="panel">
<div className="container-row head">
<div>Container</div>
<div>Image</div>
<div>State</div>
<div>CPU</div>
<div>Memory</div>
<div>Ports</div>
<div></div>
</div>
{containers.map(c => (
<div key={c.id || c.name} className="container-row">
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-2)" }}>{c.image}</div>
<div>
<span className="badge success"><StatusDot status="online" /> RUNNING</span>
{c.healthy && <span style={{ fontSize: 10.5, color: "var(--success)", marginLeft: 6 }}>healthy</span>}
</div>
<div className="mono" style={{ fontSize: 11.5 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 40, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${Math.min((c.cpu || 0) * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span>{(c.cpu || 0).toFixed(1)}%</span>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5 }}>{c.mem} MB</div>
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-3)" }}>{c.ports}</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn ghost sm" onClick={() => showLogs(c)}>Logs</button>
<button className="btn ghost sm" onClick={() => restartContainer(c)}>Restart</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function Cluster() {
const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
const [hovered, setHovered] = React.useState(null);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/cluster')
.then(data => {
window.ZAMPP_DATA.NODES = data;
setNodesData(data);
})
.catch(() => {});
}, []);
const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
const NODES = React.useMemo(() => {
if (!nodesArr.length) return [];
const primaryRaw = nodesArr.find(n => n.role === 'primary') || nodesArr[0];
const others = nodesArr.filter(n => n !== primaryRaw);
const primary = _normalizeNode(primaryRaw, 0.5, 0.46);
const positioned = others.map((n, i) => {
const angle = others.length <= 1
? Math.PI / 2
: (i / others.length) * 2 * Math.PI - Math.PI / 2;
return _normalizeNode(n, 0.5 + 0.32 * Math.cos(angle), 0.46 + 0.35 * Math.sin(angle));
});
return [primary, ...positioned];
}, [nodesData]);
const [selected, setSelected] = React.useState(null);
const sel = selected || NODES[0] || null;
const W = 720, H = 460;
if (!NODES.length) {
return (
<div className="page">
<div className="page-header">
<h1>Cluster</h1>
<div className="spacer" />
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>No cluster nodes available</div>
</div>
</div>
);
}
const primary = NODES.find(n => n.role === 'primary') || NODES[0];
const edges = NODES.filter(n => n.id !== primary.id).map(n => ({
from: primary,
to: n,
alive: n.status === 'online',
}));
const addNode = () => {
alert('To add a worker node:\n\n1. Install Docker + docker-compose on the target machine\n2. Copy /opt/wild-dragon to that machine\n3. Set NODE_ROLE=worker in the .env file\n4. Run: docker compose up -d\n\nThe node will register with this cluster automatically.');
};
const drainNode = (node) => {
alert('Drain is not yet automated.\n\nTo drain ' + node.id + ':\n1. Stop new jobs from routing to this node\n2. Wait for in-progress jobs to complete\n3. Then remove the node safely');
};
const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.id), { method: 'DELETE' })
.then(() => refresh())
.catch(e => alert('Remove failed: ' + e.message));
};
const nodeLogsHint = (node) => {
alert('To view logs for ' + node.id + ' (' + node.ip + '):\n\nSSH to ' + node.ip + ' and run:\ndocker compose -f /opt/wild-dragon/docker-compose.yml logs -f');
};
return (
<div className="page">
<div className="page-header">
<h1>Cluster</h1>
<span className="subtitle">{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online</span>
<div className="spacer" />
<div className="status-pip"><span className="dot" /><span>Live</span></div>
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={addNode}><Icon name="plus" />Add node</button>
</div>
<div className="page-body">
<div className="stat-row" style={{ padding: 0, marginBottom: 16 }}>
<div className="stat-card">
<div className="label"><Icon name="cluster" size={12} />Nodes</div>
<div className="value">{NODES.length}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="cpu" size={12} />Avg CPU</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>%</span></div>
</div>
<div className="stat-card">
<div className="label"><Icon name="gpu" size={12} />GPUs</div>
<div className="value">{NODES.reduce((a, n) => a + n.gpus.length, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="hdd" size={12} />Avg Memory</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>GB</span></div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 16, alignItems: "start" }}>
<div className="cluster-canvas">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Topology</span>
<div className="tab-group">
<button className="active">Graph</button>
<button>List</button>
</div>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ display: "block", width: "100%", height: "auto" }}>
<defs>
<radialGradient id="nodeGlow">
<stop offset="0%" stopColor="rgba(91,124,250,0.3)" />
<stop offset="100%" stopColor="rgba(91,124,250,0)" />
</radialGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" strokeWidth="1" />
</pattern>
</defs>
<rect width={W} height={H} fill="url(#grid)" />
{edges.map((e, i) => {
const x1 = e.from.x * W, y1 = e.from.y * H;
const x2 = e.to.x * W, y2 = e.to.y * H;
return (
<g key={i}>
<line x1={x1} y1={y1} x2={x2} y2={y2}
stroke={e.alive ? "var(--accent)" : "var(--text-4)"}
strokeWidth="1"
strokeDasharray={e.alive ? "0" : "4 3"}
opacity={e.alive ? 0.5 : 0.25}
/>
{e.alive && (
<circle r="3" fill="var(--accent)">
<animateMotion dur={`${2 + i * 0.4}s`} repeatCount="indefinite"
path={`M ${x1} ${y1} L ${x2} ${y2}`} />
</circle>
)}
</g>
);
})}
{NODES.map(n => {
const cx = n.x * W, cy = n.y * H;
const isSelected = sel && sel.id === n.id;
const color = n.status === "online" ? "var(--success)" : "var(--text-4)";
return (
<g key={n.id} transform={`translate(${cx}, ${cy})`}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => setSelected(n)}>
{n.status === "online" && (
<circle r="44" fill="url(#nodeGlow)">
<animate attributeName="r" values="34;48;34" dur="3s" repeatCount="indefinite" />
</circle>
)}
<circle r={isSelected ? 26 : 22} fill="var(--bg-2)" stroke={isSelected ? "var(--accent)" : "var(--border-stronger)"} strokeWidth={isSelected ? 2 : 1} />
<circle r="6" cx="-13" cy="-13" fill={color} />
{n.role === "primary" && <path d="M -4 -2 L 0 2 L 4 -2 L 0 -6 Z" fill="var(--accent)" stroke="none" />}
{n.role !== "primary" && <text textAnchor="middle" y="3" fill="var(--text-2)" fontSize="10" fontFamily="var(--font-mono)">{n.role[0].toUpperCase()}</text>}
<text textAnchor="middle" y="40" fill={isSelected ? "var(--text-1)" : "var(--text-2)"} fontSize="11" fontWeight={isSelected ? 600 : 500}>{n.id}</text>
<text textAnchor="middle" y="54" fill="var(--text-3)" fontSize="10" fontFamily="var(--font-mono)">{n.ip}</text>
</g>
);
})}
</svg>
</div>
{sel && (
<div className="panel">
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={sel.status} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{sel.id}</span>
<span className={`badge ${sel.role === "primary" ? "accent" : "neutral"}`}>{sel.role}</span>
</div>
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
<DetailRow k="Status" v={<span style={{ color: sel.status === "online" ? "var(--success)" : "var(--text-3)" }}>{sel.status}</span>} />
<DetailRow k="IP" v={sel.ip} mono />
<DetailRow k="Version" v={sel.version} mono />
<DetailRow k="Uptime" v={sel.uptime} mono />
<DetailRow k="CPU" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${sel.cpu}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span className="mono">{sel.cpu}%</span>
</div>
} />
{sel.memTotal > 0 && (
<DetailRow k="Memory" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${(sel.mem / sel.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
</div>
<span className="mono">{sel.mem} / {sel.memTotal} GB</span>
</div>
} />
)}
{sel.gpus.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({sel.gpus.length})</div>
{sel.gpus.map((g, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
<span>{g}</span>
</div>
))}
</div>
)}
{sel.devices && sel.devices.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
{sel.devices.map((d, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4 }}>
<Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
</div>
))}
</div>
)}
<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>
{sel.role !== "primary" && <button className="btn danger sm" onClick={() => removeNode(sel)}>Remove</button>}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function DetailRow({ k, v, mono }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
<span style={{ color: "var(--text-3)" }}>{k}</span>
<span className={mono ? "mono" : ""} style={{ fontSize: mono ? 11.5 : 12 }}>{v}</span>
</div>
);
}
function Settings() {
const [section, setSection] = React.useState('storage');
const SECTIONS = [
{ id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
{ id: 'gpu', label: 'GPU / Transcoding', icon: 'gpu' },
{ id: 'growing', label: 'Growing files (SMB)', icon: 'hdd' },
{ id: 'sdi', label: 'SDI capture', icon: 'video' },
{ id: 'ampp', label: 'AMPP integration', icon: 'link' },
];
return (
<div className="page">
<div className="page-header">
<h1>Settings</h1>
<span className="subtitle">System configuration · changes apply without restart</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, alignItems: 'start' }}>
<nav className="settings-nav">
{SECTIONS.map(s => (
<a key={s.id}
className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
onClick={() => setSection(s.id)}
style={{ cursor: 'pointer' }}>
<Icon name={s.icon} size={14} />{s.label}
</a>
))}
</nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'storage' && <S3SettingsCard />}
{section === 'gpu' && <GpuSettingsCard />}
{section === 'growing' && <GrowingSettingsCard />}
{section === 'sdi' && <SdiSettingsCard />}
{section === 'ampp' && <AmppSettingsCard />}
</div>
</div>
</div>
</div>
);
}
function S3SettingsCard() {
const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [secretExists, setSecretExists] = React.useState(false);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/s3')
.then(data => {
setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1' });
setSecretExists(!!data.s3_secret_key_exists);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
const test = () => {
setTesting(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
.then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
.catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
};
return (
<SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}>
{loading ? <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div> : (<>
<SField label="Endpoint URL">
<input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
<SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
</div>
<SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</>)}
</SettingsCard>
);
}
function GpuSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration"><div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
return (
<SettingsCard icon="gpu" title="GPU / Transcoding" sub="NVENC / VAAPI hardware acceleration for proxy generation"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable hardware encoding">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Route proxy jobs through GPU encoders when available</span>
</label>
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Codec">
<select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
<option value="h264_nvenc">h264_nvenc</option>
<option value="hevc_nvenc">hevc_nvenc</option>
<option value="h264_vaapi">h264_vaapi</option>
<option value="hevc_vaapi">hevc_vaapi</option>
</select>
</SField>
<SField label="Preset">
<select className="field-input" value={cfg.gpu_preset || 'p4'} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
{['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)}
</select>
</SField>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Bitrate (Mbps)">
<input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="8" />
</SField>
<SField label="Rate control">
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
<option value="cbr">CBR</option>
<option value="vbr">VBR</option>
<option value="cqp">CQP</option>
</select>
</SField>
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable growing-file capture">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
</label>
</SField>
<SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
</SField>
<SField label="SMB share URL (for editors)">
<input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
</SField>
<SField label="Promote-to-S3 idle threshold (seconds)">
<input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function SdiSettingsCard() {
return (
<SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
tag={<span className="badge neutral">per-recorder</span>}>
<div style={{ color: 'var(--text-3)', fontSize: 12.5, lineHeight: 1.6 }}>
SDI settings are configured per-recorder. Use{' '}
<strong style={{ color: 'var(--text-2)' }}>Ingest → Recorders → New recorder</strong>{' '}
to pick the DeckLink port, codec, and audio routing.
</div>
<div style={{ marginTop: 12 }}>
<a className="btn ghost sm" href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
<Icon name="video" />Open Capture dashboard
</a>
</div>
</SettingsCard>
);
}
function AmppSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [tokenExists, setTokenExists] = React.useState(false);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/ampp').then(d => {
setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
setTokenExists(!!d.ampp_token_exists);
}).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
const test = () => {
setTesting(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
.then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
.catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…"><div style={{color:'var(--text-3)'}}>…</div></SettingsCard>;
return (
<SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
tag={tokenExists ? <span className="badge success">connected</span> : <span className="badge neutral">not configured</span>}>
<SField label="AMPP base URL">
<input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
</SField>
<SField label="API token">
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved type to replace)' : 'AMPP API token'} />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving' : 'Save'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing' : 'Test connection'}</button>
</div>
</SettingsCard>
);
}
function SettingsMsg({ msg }) {
if (!msg) return null;
return (
<div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid',
background: msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)',
borderColor: msg.ok ? 'var(--success)' : 'var(--danger)',
color: msg.ok ? 'var(--success)' : 'var(--danger)' }}>
{msg.text}
</div>
);
}
function SField({ label, children }) {
return (
<div className="field">
<label className="field-label">{label}</label>
{children}
</div>
);
}
function SettingsCard({ icon, title, sub, tag, children }) {
return (
<div className="settings-card">
<div className="settings-card-head">
<div className="settings-card-icon"><Icon name={icon} size={16} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>{sub}</div>
</div>
{tag}
</div>
<div className="settings-card-body">{children}</div>
</div>
);
}
Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });