Add Z-AMPP UI: screens-ingest + screens-admin: screens-admin.jsx
This commit is contained in:
parent
20dfa504e5
commit
1eaf9dff5c
1 changed files with 661 additions and 0 deletions
661
services/web-ui/public/screens-admin.jsx
Normal file
661
services/web-ui/public/screens-admin.jsx
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings
|
||||
|
||||
const { USERS, TOKENS_LIST, CONTAINERS, NODES } = window.ZAMPP_DATA;
|
||||
|
||||
/* ========== Users & Groups ========== */
|
||||
function Users() {
|
||||
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 · 4</button>
|
||||
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies · 7</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.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.avatar) }}>{u.avatar}</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.display}</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.last}</div>
|
||||
<div><button className="icon-btn"><Icon name="more" /></button></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Tokens (parody) ========== */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Containers ========== */
|
||||
function Containers() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Containers</h1>
|
||||
<span className="subtitle">Docker Compose services across the cluster</span>
|
||||
<div className="spacer" />
|
||||
<div className="status-pip">
|
||||
<span className="dot" />
|
||||
<span>{CONTAINERS.filter(c => c.state === "running").length} / {CONTAINERS.length} running</span>
|
||||
</div>
|
||||
<button className="btn ghost sm"><Icon name="refresh" />Refresh</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<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} 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 * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
|
||||
</div>
|
||||
<span>{c.cpu.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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Cluster (topology graph) ========== */
|
||||
function Cluster() {
|
||||
const [hovered, setHovered] = React.useState(null);
|
||||
const [selected, setSelected] = React.useState(NODES[0]);
|
||||
const W = 720, H = 460;
|
||||
|
||||
const primary = NODES.find(n => n.role === "primary");
|
||||
const edges = NODES.filter(n => n.id !== primary.id).map(n => ({
|
||||
from: primary,
|
||||
to: n,
|
||||
alive: n.status === "online",
|
||||
}));
|
||||
|
||||
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 className="delta">across 2 regions</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="cpu" size={12} />Total CPU</div>
|
||||
<div className="value">320 <span style={{ fontSize: 14, color: "var(--text-3)" }}>cores</span></div>
|
||||
<div className="delta">14% utilized</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="gpu" size={12} />GPUs</div>
|
||||
<div className="value">5</div>
|
||||
<div className="delta">3 idle · 2 transcoding</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="label"><Icon name="hdd" size={12} />Memory</div>
|
||||
<div className="value">243 <span style={{ fontSize: 14, color: "var(--text-3)" }}>GB</span></div>
|
||||
<div className="delta">15% utilized</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>Map</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 isHovered = hovered === n.id;
|
||||
const isSelected = selected.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>
|
||||
|
||||
<div className="panel">
|
||||
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<StatusDot status={selected.status} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{selected.id}</span>
|
||||
<span className={`badge ${selected.role === "primary" ? "accent" : "neutral"}`}>{selected.role}</span>
|
||||
</div>
|
||||
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<DetailRow k="Status" v={<span style={{ color: selected.status === "online" ? "var(--success)" : "var(--text-3)" }}>{selected.status}</span>} />
|
||||
<DetailRow k="IP" v={selected.ip} mono />
|
||||
<DetailRow k="Version" v={selected.version} mono />
|
||||
<DetailRow k="Uptime" v={selected.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: `${selected.cpu}%`, height: "100%", background: "var(--accent)" }} />
|
||||
</div>
|
||||
<span className="mono">{selected.cpu}%</span>
|
||||
</div>
|
||||
} />
|
||||
<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: `${(selected.mem / selected.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
|
||||
</div>
|
||||
<span className="mono">{selected.mem} / {selected.memTotal} GB</span>
|
||||
</div>
|
||||
} />
|
||||
{selected.gpus.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({selected.gpus.length})</div>
|
||||
{selected.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>
|
||||
)}
|
||||
{selected.devices && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
|
||||
{selected.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>
|
||||
{selected.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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Settings ========== */
|
||||
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">3 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 });
|
||||
Loading…
Reference in a new issue