873 lines
43 KiB
JavaScript
873 lines
43 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 & 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 [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
|
|
const [s3Loading, setS3Loading] = React.useState(true);
|
|
const [s3Saving, setS3Saving] = React.useState(false);
|
|
const [s3Testing, setS3Testing] = React.useState(false);
|
|
const [s3Msg, setS3Msg] = 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);
|
|
setS3Loading(false);
|
|
})
|
|
.catch(() => setS3Loading(false));
|
|
}, []);
|
|
|
|
const saveS3 = () => {
|
|
setS3Saving(true); setS3Msg(null);
|
|
window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
|
|
.then(() => { setS3Saving(false); setS3Msg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
|
|
.catch(e => { setS3Saving(false); setS3Msg({ ok: false, text: e.message }); });
|
|
};
|
|
|
|
const testS3 = () => {
|
|
setS3Testing(true); setS3Msg(null);
|
|
window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
|
|
.then(r => { setS3Testing(false); setS3Msg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
|
|
.catch(e => { setS3Testing(false); setS3Msg({ ok: false, text: e.message }); });
|
|
};
|
|
|
|
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">
|
|
{[
|
|
{ id: 'storage', label: 'S3 / Object storage', icon: 'hdd' },
|
|
{ id: 'gpu', label: 'GPU / Transcoding', icon: 'gpu' },
|
|
{ id: 'sdi', label: 'SDI capture', icon: 'video' },
|
|
{ id: 'ampp', label: 'AMPP integration', icon: 'link' },
|
|
].map((s, i) => (
|
|
<a key={s.id} className={`settings-nav-item ${i === 0 ? 'active' : ''}`}>
|
|
<Icon name={s.icon} size={14} />{s.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<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>}
|
|
>
|
|
{s3Loading ? (
|
|
<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>
|
|
{s3Msg && (
|
|
<div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid', background: s3Msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)', borderColor: s3Msg.ok ? 'var(--success)' : 'var(--danger)', color: s3Msg.ok ? 'var(--success)' : 'var(--danger)' }}>
|
|
{s3Msg.text}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
|
<button className="btn primary sm" onClick={saveS3} disabled={s3Saving}>{s3Saving ? 'Saving…' : 'Save & apply'}</button>
|
|
<button className="btn ghost sm" onClick={testS3} disabled={s3Testing}>{s3Testing ? 'Testing…' : 'Test connection'}</button>
|
|
</div>
|
|
</>)}
|
|
</SettingsCard>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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 });
|