733 lines
35 KiB
JavaScript
733 lines
35 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 Users() {
|
|
const { USERS } = window.ZAMPP_DATA;
|
|
const [tab, setTab] = React.useState("users");
|
|
return (
|
|
<div className="page">
|
|
<div className="page-header">
|
|
<h1>Users & Groups</h1>
|
|
<div className="spacer" />
|
|
<button className="btn ghost sm"><Icon name="download" />Export</button>
|
|
<button className="btn primary"><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>
|
|
</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 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;
|
|
|
|
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">Logs</button>
|
|
<button className="btn ghost sm">Restart</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Cluster() {
|
|
const rawNodes = window.ZAMPP_DATA.NODES;
|
|
const nodesArr = Array.isArray(rawNodes) ? rawNodes : (rawNodes?.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];
|
|
}, []);
|
|
|
|
const [hovered, setHovered] = React.useState(null);
|
|
const [selected, setSelected] = React.useState(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"><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 sel = selected || NODES[0];
|
|
|
|
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"><Icon name="refresh" />Refresh</button>
|
|
<button className="btn primary"><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">Logs</button>
|
|
<button className="btn ghost sm">Drain</button>
|
|
{sel.role !== "primary" && <button className="btn danger sm">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() {
|
|
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" },
|
|
{ id: "branding", label: "Branding", icon: "image" },
|
|
{ id: "logs", label: "Logs & telemetry", icon: "list" },
|
|
].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={<span className="badge success">connected</span>}
|
|
>
|
|
<Field label="Endpoint URL" value="https://broadcastmgmt.wilddragon.net" mono />
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
|
<Field label="Region" value="us-east-1" mono />
|
|
<Field label="Bucket" value="dragonmam" mono />
|
|
</div>
|
|
<Field label="Access key ID" value="boLH2fUE7D6tzmgHvLb…" mono />
|
|
<Field label="Secret access key" value="••••••••••••••••" mono right={<button className="btn ghost sm">Show</button>} />
|
|
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
<button className="btn primary sm">Save & apply</button>
|
|
<button className="btn ghost sm">Test connection</button>
|
|
</div>
|
|
</SettingsCard>
|
|
<SettingsCard
|
|
icon="gpu"
|
|
title="GPU / Transcoding"
|
|
sub="NVIDIA NVENC acceleration for proxy generation and transcoding jobs"
|
|
tag={<span className="badge accent">GPUs available</span>}
|
|
>
|
|
<label className="checkbox-row">
|
|
<input type="checkbox" defaultChecked /> Enable GPU-accelerated transcoding
|
|
</label>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
|
<Field label="Encoder" value="H.264 (h264_nvenc)" select />
|
|
<Field label="Quality preset" value="p4 — medium (default)" select />
|
|
</div>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
|
<Field label="Proxy bitrate" value="2 Mbps" mono />
|
|
<Field label="Transcoding node" value="Auto (first online with GPU)" select />
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
<button className="btn primary sm">Save GPU settings</button>
|
|
</div>
|
|
</SettingsCard>
|
|
<SettingsCard
|
|
icon="link"
|
|
title="AMPP Integration"
|
|
sub="Grass Valley AMPP platform connectivity for asset sync"
|
|
tag={<span className="badge warning">setup needed</span>}
|
|
>
|
|
<Field label="AMPP base URL" value="https://ampp.example.com" mono />
|
|
<Field label="Tenant ID" value="" placeholder="e.g. wild-dragon-prod" />
|
|
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
<button className="btn primary sm">Connect</button>
|
|
</div>
|
|
</SettingsCard>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, mono, select, placeholder, right }) {
|
|
return (
|
|
<div className="field">
|
|
<label className="field-label">{label}</label>
|
|
<div className="field-input-wrap">
|
|
{select ? (
|
|
<div className="field-input select">
|
|
<span className={mono ? "mono" : ""}>{value || placeholder}</span>
|
|
<Icon name="chevronDown" size={12} style={{ color: "var(--text-3)" }} />
|
|
</div>
|
|
) : (
|
|
<input className={`field-input ${mono ? "mono" : ""}`} defaultValue={value} placeholder={placeholder} />
|
|
)}
|
|
{right}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });
|