428	function Tokens() {
429	  const [burned, setBurned] = React.useState(14340);
430	  const [rate, setRate] = React.useState(2.4);
431	  const [showCalc, setShowCalc] = React.useState(false);
432
433	  React.useEffect(() => {
434	    const i = setInterval(() => {
435	      setBurned(b => b + Math.floor(Math.random() * 8) + 1);
436	      setRate(r => Math.max(0.8, Math.min(8, r + (Math.random() - 0.5) * 0.4)));
437	    }, 800);
438	    return () => clearInterval(i);
439	  }, []);
440
441	  const burnSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 100 + i * 8 + Math.sin(i * 0.7) * 12), []);
442	  const competitorSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 200 + i * 22 + Math.cos(i * 0.5) * 30), []);
443	  const yourCostSpark = React.useMemo(() => Array.from({ length: 40 }, () => 1), []);
444
445	  const [events, setEvents] = React.useState([
446	    { t: "21:14:02", action: "preview thumbnail generated", cost: 4 },
447	    { t: "21:14:01", action: "user clicked play", cost: 12 },
448	    { t: "21:13:58", action: "API health check", cost: 8 },
449	    { t: "21:13:54", action: "asset metadata read", cost: 2 },
450	    { t: "21:13:51", action: "session token refreshed", cost: 18 },
451	    { t: "21:13:47", action: "scrubbed timeline 1 frame", cost: 6 },
452	    { t: "21:13:42", action: "took a deep breath near the API", cost: 24 },
453	  ]);
454
455	  React.useEffect(() => {
456	    const actions = [
457	      "preview thumbnail generated", "user clicked play", "API health check",
458	      "scrubbed timeline 1 frame", "asset metadata read", "session token refreshed",
459	      "checked job queue", "rendered a tooltip", "loaded sidebar icon",
460	      "blinked", "made eye contact with the cluster", "opened a modal (twice)",
461	      "asset list pagination request", "thought about a comment", "moved cursor near 'Save'",
462	    ];
463	    const i = setInterval(() => {
464	      const now = new Date();
465	      const t = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
466	      const a = actions[Math.floor(Math.random() * actions.length)];
467	      const c = Math.floor(Math.random() * 28) + 1;
468	      setEvents(ev => [{ t, action: a, cost: c }, ...ev].slice(0, 12));
469	    }, 1600);
470	    return () => clearInterval(i);
471	  }, []);
472
473	  const tiers = [
474	    { name: "Starter", desc: "For \"evaluation only\" — definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
475	    { name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
476	    { 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" },
477	  ];
478
479	  return (
480

481

482
Tokens

483	        Token-metered pricing parody · You actually pay $0.00
484

485	        <Icon name="alert" size={10} /> SATIRE
486	        <button className="btn ghost sm" onClick={() => setShowCalc(!showCalc)}><Icon name="sliders" />Cost calculator</button>
487

488

489
490

491

492	            Per-seat
493	            {' · '}
494	            Per-stream
495	            {' · '}
496	            Per-month
497

498	            Per Token.
499

500

501
502

503

504
TOKENS BURNED THIS SESSION

505

506	              🔥
507	              {burned.toLocaleString()}
508

509

510	              ↑ {rate.toFixed(1)}k/sec
511	              burning since you logged in
512

513

514	              <Sparkline data={burnSpark} color="#FF5B5B" />
515

516

517
518

519
WHAT YOU ACTUALLY PAY

520

521	              $0
522	              .00
523

524

525	              Dragonflight is self-hosted. The tokens above are imaginary.

526	              Imagine them as a stress test for your sanity.
527

528

529	              <Sparkline data={yourCostSpark} color="#2DD4A8" fill={false} />
530

531

532

533
534

535
HOURLY BURN — DRAGONFLIGHT vs. THE OTHER GUYS

536

537	            <ChartLine
538	              series={[
539	                { label: "AMPP-style competitor", data: competitorSpark, color: "#FF5B5B" },
540	                { label: "Dragonflight (yours)", data: yourCostSpark.map((_, i) => i < 20 ? 1 : 1), color: "#2DD4A8" },
541	              ]}
542	            />
543

544
Competitor: $1,247/hr and rising

545
Dragonflight: $0.00/hr forever

546

547

548

549
550

551

552
LIVE BILLING EVENTS

553

554	              {events.map((e, i) => (
555	                <div key={i} className={`token-event ${i === 0 ? "fresh" : ""}`}>
556	                  {e.t}
557	                  {e.action}
558	                  +{e.cost} tk
559

560	              ))}
561

562

563
564

565
PRICING TIERS WE DIDN'T COPY

566

567	              {tiers.map(t => (
568	                <div key={t.name} className={`token-tier ${t.popular ? "popular" : ""}`}>
569	                  {t.popular && MOST PAIN}
570
{t.name}

571
{t.desc}

572

573	                    {t.price}
574	                    {t.per}
575

576
{t.tokens}

577	                  <button className="btn subtle sm" disabled style={{ width: "100%", marginTop: 8 }}>Not for sale</button>
578

579	              ))}
580

581

582

583
584	        {showCalc && <CostCalculator onClose={() => setShowCalc(false)} />}
585
586

587	          <Icon name="alert" size={14} />
588

589	            Disclaimer: No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
590	            is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
591	            and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
592	            credentials are managed through the cluster's own JWT issuer.
593

594

595

596

597	  );
598	}
599
600	function ChartLine({ series }) {
601	  const w = 600, h = 140;
602	  return (
603	    <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: "100%", height: 140 }}>
604	      <defs>
605	        <pattern id="cgrid" width="60" height="28" patternUnits="userSpaceOnUse">
606	          <path d="M 60 0 L 0 0 0 28" fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
607	        </pattern>
608	      </defs>
609	      <rect width={w} height={h} fill="url(#cgrid)" />
610	      {series.map((s, si) => {
611	        const max = Math.max(...series.flatMap(x => x.data), 1);
612	        const pts = s.data.map((d, i) => {
613	          const x = (i / (s.data.length - 1)) * w;
614	          const y = h - (d / max) * (h - 10) - 4;
615	          return `${x},${y}`;
616	        }).join(" ");
617	        const area = `0,${h} ${pts} ${w},${h}`;
618	        return (
619	          <g key={si}>
620	            <polygon points={area} fill={s.color} opacity="0.1" />
621	            <polyline points={pts} fill="none" stroke={s.color} strokeWidth="2" />
622	            <circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="4" fill={s.color} />
623	            <circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="8" fill={s.color} opacity="0.3">
624	              <animate attributeName="r" values="4;14;4" dur="2s" repeatCount="indefinite" />
625	              <animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite" />
626	            </circle>
627	          </g>


Bash

grep -n "Containers\|Cluster\|Settings" /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx | tail -20


1214:function GrowingSettingsCard() {
1232:  if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…">
…
</SettingsCard>;
1237:    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
1254:      <SettingsMsg msg={msg} />
1258:    </SettingsCard>
1262:function SdiSettingsCard() {
1264:    <SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
1276:    </SettingsCard>
1313:function SdkSettingsCard() {
1324:    <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
1329:      <SettingsMsg msg={msg} />
1340:    </SettingsCard>
1422:function AmppSettingsCard() {
1449:  if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…">
…
</SettingsCard>;
1452:    <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
1460:      <SettingsMsg msg={msg} />
1465:    </SettingsCard>
1469:function SettingsMsg({ msg }) {
1490:function SettingsCard({ icon, title, sub, tag, children }) {
1506:Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });


Now let me check for the Containers and Cluster screens and look at more dead handlers:


700	  const showLogs = (c) => {
701	    alert('To view logs for ' + c.name + ', run:\n\ndocker compose logs -f ' + c.name);
702	  };
703
704	  const restartContainer = (c) => {
705	    if (!window.confirm('Restart container ' + c.name + '?')) return;
706	    window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
707	      .then(() => { alert(c.name + ' restarted.'); load(); })
708	      .catch(() => alert('No restart endpoint available.\nRun manually:\n\ndocker compose restart ' + c.name));
709	  };
710
711	  return (
712

713

714
Containers

715	        Docker Compose services across the cluster
716

717	        {containers !== null && containers.length > 0 && (
718

719
720	            {running} / {containers.length} running
721

722	        )}
723	        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
724

725

726	        {containers === null && (
727
Loading…

728	        )}
729	        {containers !== null && containers.length === 0 && (
730

731
🐳

732
No container data available

733
Container metrics endpoint not yet wired

734

735	        )}
736	        {containers !== null && containers.length > 0 && (
737

738

739
Container

740
Image

741
State

742
CPU

743
Memory

744
Ports

745

746

747	            {containers.map(c => (
748

749

750
{c.name}

751
up {c.uptime}

752

753
{c.image}

754

755	                  <StatusDot status="online" /> RUNNING
756	                  {c.healthy && healthy}
757

758

759

760

761	                      <div style={{ width: `${Math.min((c.cpu || 0) * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
762

763	                    {(c.cpu || 0).toFixed(1)}%
764

765

766
{c.mem} MB

767
{c.ports}

768

769	                  <button className="btn ghost sm" onClick={() => showLogs(c)}>Logs</button>
770	                  <button className="btn ghost sm" onClick={() => restartContainer(c)}>Restart</button>
771

772

773	            ))}
774

775	        )}
776

777

778	  );
779	}
780
781	function Cluster() {
782	  const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
783	  const [hovered, setHovered] = React.useState(null);
784
785	  const refresh = React.useCallback(() => {
786	    window.ZAMPP_API.fetch('/cluster')
787	      .then(data => {
788	        window.ZAMPP_DATA.NODES = data;
789	        setNodesData(data);
790	      })
791	      .catch(() => {});
792	  }, []);
793
794	  const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
795
796	  const NODES = React.useMemo(() => {
797	    if (!nodesArr.length) return [];
798	    const primaryRaw = nodesArr.find(n => n.role === 'primary') || nodesArr[0];
799	    const others = nodesArr.filter(n => n !== primaryRaw);
800	    const primary = _normalizeNode(primaryRaw, 0.5, 0.46);
801	    const positioned = others.map((n, i) => {
802	      const angle = others.length <= 1
803	        ? Math.PI / 2
804	        : (i / others.length) * 2 * Math.PI - Math.PI / 2;
805	      return _normalizeNode(n, 0.5 + 0.32 * Math.cos(angle), 0.46 + 0.35 * Math.sin(angle));
806	    });
807	    return [primary, ...positioned];
808	  }, [nodesData]);
809
810	  const [selected, setSelected] = React.useState(null);
811	  const sel = selected || NODES[0] || null;
812	  const W = 720, H = 460;
813
814	  if (!NODES.length) {
815	    return (
816

817

818
Cluster

819

820	          <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
821

822

823
No cluster nodes available

824

825

826	    );
827	  }
828
829	  const primary = NODES.find(n => n.role === 'primary') || NODES[0];
830	  const edges = NODES.filter(n => n.id !== primary.id).map(n => ({
831	    from: primary,
832	    to: n,
833	    alive: n.status === 'online',
834	  }));
835
836	  const addNode = () => {
837	    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.');
838	  };
839
840	  const drainNode = (node) => {
841	    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');
842	  };
843
844	  const removeNode = (node) => {
845	    if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return;
846	    window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.id), { method: 'DELETE' })
847	      .then(() => refresh())
848	      .catch(e => alert('Remove failed: ' + e.message));
849	  };
850
851	  const nodeLogsHint = (node) => {
852	    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');
853	  };
854
855	  return (
856

857

858
Cluster

859	        {NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online
860

861
Live

862	        <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
863	        <button className="btn primary" onClick={addNode}><Icon name="plus" />Add node</button>
864

865

866

867

868
<Icon name="cluster" size={12} />Nodes

869
{NODES.length}

870

871

872
<Icon name="cpu" size={12} />Avg CPU

873
{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} %

874

875

876
<Icon name="gpu" size={12} />GPUs

877
{NODES.reduce((a, n) => a + n.gpus.length, 0)}

878

879

880
<Icon name="hdd" size={12} />Avg Memory

881
{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} GB

882

883

884
885

886

887

888	              Topology
889

890	                <button className="active">Graph</button>
891	                <button>List</button>
892

893

894	            <svg viewBox={`0 0 ${W} ${H}`} style={{ display: "block", width: "100%", height: "auto" }}>
895	              <defs>
896	                <radialGradient id="nodeGlow">
897	                  <stop offset="0%" stopColor="rgba(91,124,250,0.3)" />
898	                  <stop offset="100%" stopColor="rgba(91,124,250,0)" />
899	                </radialGradient>
900	                <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
901	                  <path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" strokeWidth="1" />
902	                </pattern>
903	              </defs>
904	              <rect width={W} height={H} fill="url(#grid)" />
905	              {edges.map((e, i) => {
906	                const x1 = e.from.x * W, y1 = e.from.y * H;
907	                const x2 = e.to.x * W, y2 = e.to.y * H;
908	                return (
909	                  <g key={i}>
910	                    <line x1={x1} y1={y1} x2={x2} y2={y2}
911	                      stroke={e.alive ? "var(--accent)" : "var(--text-4)"}
912	                      strokeWidth="1"
913	                      strokeDasharray={e.alive ? "0" : "4 3"}
914	                      opacity={e.alive ? 0.5 : 0.25}
915	                    />
916	                    {e.alive && (
917	                      <circle r="3" fill="var(--accent)">
918	                        <animateMotion dur={`${2 + i * 0.4}s`} repeatCount="indefinite"
919	                          path={`M ${x1} ${y1} L ${x2} ${y2}`} />
920	                      </circle>
921	                    )}
922	                  </g>
923	                );
924	              })}
925	              {NODES.map(n => {
926	                const cx = n.x * W, cy = n.y * H;
927	                const isSelected = sel && sel.id === n.id;
928	                const color = n.status === "online" ? "var(--success)" : "var(--text-4)";
929	                return (
930	                  <g key={n.id} transform={`translate(${cx}, ${cy})`}
931	                    style={{ cursor: "pointer" }}
932	                    onMouseEnter={() => setHovered(n.id)}
933	                    onMouseLeave={() => setHovered(null)}
934	                    onClick={() => setSelected(n)}>
935	                    {n.status === "online" && (
936	                      <circle r="44" fill="url(#nodeGlow)">
937	                        <animate attributeName="r" values="34;48;34" dur="3s" repeatCount="indefinite" />
938	                      </circle>
939	                    )}
940	                    <circle r={isSelected ? 26 : 22} fill="var(--bg-2)" stroke={isSelected ? "var(--accent)" : "var(--border-stronger)"} strokeWidth={isSelected ? 2 : 1} />
941	                    <circle r="6" cx="-13" cy="-13" fill={color} />
942	                    {n.role === "primary" && <path d="M -4 -2 L 0 2 L 4 -2 L 0 -6 Z" fill="var(--accent)" stroke="none" />}
943	                    {n.role !== "primary" && <text textAnchor="middle" y="3" fill="var(--text-2)" fontSize="10" fontFamily="var(--font-mono)">{n.role[0].toUpperCase()}</text>}
944	                    <text textAnchor="middle" y="40" fill={isSelected ? "var(--text-1)" : "var(--text-2)"} fontSize="11" fontWeight={isSelected ? 600 : 500}>{n.id}</text>
945	                    <text textAnchor="middle" y="54" fill="var(--text-3)" fontSize="10" fontFamily="var(--font-mono)">{n.ip}</text>
946	                  </g>
947	                );
948	              })}
949	            </svg>
950

951
952	          {sel && (
953

954

955	                <StatusDot status={sel.status} />
956	                {sel.id}
957	                <span className={`badge ${sel.role === "primary" ? "accent" : "neutral"}`}>{sel.role}
958

959

960	                <DetailRow k="Status" v={{sel.status}} />
961	                <DetailRow k="IP" v={sel.ip} mono />
962	                <DetailRow k="Version" v={sel.version} mono />
963	                <DetailRow k="Uptime" v={sel.uptime} mono />
964	                <DetailRow k="CPU" v={
965

966

967	                      <div style={{ width: `${sel.cpu}%`, height: "100%", background: "var(--accent)" }} />
968

969	                    {sel.cpu}%
970

971	                } />
972	                {sel.memTotal > 0 && (
973	                  <DetailRow k="Memory" v={
974

975

976	                        <div style={{ width: `${(sel.mem / sel.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
977

978	                      {sel.mem} / {sel.memTotal} GB
979

980	                  } />
981	                )}
982	                {sel.gpus.length > 0 && (
983

984
GPUs ({sel.gpus.length})

985	                    {sel.gpus.map((g, i) => (
986

987	                        <Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
988	                        {g}
989

990	                    ))}
991

992	                )}
993	                {sel.devices && sel.devices.length > 0 && (
994

995
Capture devices

996	                    {sel.devices.map((d, i) => (
997

998	                        <Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
999


grep -n "function Containers\|function Cluster\|function Settings" /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx


686:function Containers() {
781:function Cluster() {
1026:function Settings() {
1469:function SettingsMsg({ msg }) {
1490:function SettingsCard({ icon, title, sub, tag, children }) {


Perfect! Now let me check the Settings function and a couple other things:


1026	function Settings() {
1027	  const [section, setSection] = React.useState('storage');
1028
1029	  const SECTIONS = [
1030	    { id: 'storage',  label: 'S3 / Object storage', icon: 'hdd' },
1031	    { id: 'proxy',    label: 'Proxy encoding',      icon: 'gpu' },
1032	    { id: 'growing',  label: 'Growing files (SMB)', icon: 'hdd' },
1033	    { id: 'sdk',      label: 'Capture SDKs',        icon: 'video' },
1034	    { id: 'sdi',      label: 'SDI capture',         icon: 'video' },
1035	  ];
1036
1037	  return (
1038

1039

1040
Settings

1041	        System configuration · changes apply without restart
1042

1043

1044

1045
1046	            {SECTIONS.map(s => (
1047	              <a key={s.id}
1048	                 className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
1049	                 onClick={() => setSection(s.id)}
1050	                 style={{ cursor: 'pointer' }}>
1051	                <Icon name={s.icon} size={14} />{s.label}
1052
1053	            ))}
1054
1055

1056	            {section === 'storage' && <S3SettingsCard />}
1057	            {section === 'proxy'   && <GpuSettingsCard />}
1058	            {section === 'growing' && <GrowingSettingsCard />}
1059	            {section === 'sdk'     && <SdkSettingsCard />}
1060	            {section === 'sdi'     && <SdiSettingsCard />}
1061

1062

1063

1064

1065	  );
1066	}
1067
1068	function S3SettingsCard() {
1069	  const [s3, setS3] = React.useState({ s3_endpoint: '', s3_bucket: '', s3_access_key: '', s3_secret_key: '', s3_region: 'us-east-1' });
1070	  const [loading, setLoading] = React.useState(true);
1071	  const [saving,  setSaving]  = React.useState(false);
1072	  const [testing, setTesting] = React.useState(false);
1073	  const [msg,     setMsg]     = React.useState(null);
1074	  const [secretExists, setSecretExists] = React.useState(false);
1075
1076	  React.useEffect(() => {
1077	    window.ZAMPP_API.fetch('/settings/s3')
1078	      .then(data => {
1079	        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' });
1080	        setSecretExists(!!data.s3_secret_key_exists);
1081	        setLoading(false);
1082	      })
1083	      .catch(() => setLoading(false));
1084	  }, []);
1085
1086	  const save = () => {
1087	    setSaving(true); setMsg(null);
1088	    window.ZAMPP_API.fetch('/settings/s3', { method: 'PUT', body: JSON.stringify(s3) })
1089	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved and applied.' }); if (s3.s3_secret_key) setSecretExists(true); })
1090	      .catch(e  => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1091	  };
1092	  const test = () => {
1093	    setTesting(true); setMsg(null);
1094	    window.ZAMPP_API.fetch('/settings/s3/test', { method: 'POST', body: JSON.stringify(s3) })
1095	      .then(r => { setTesting(false); setMsg({ ok: r.ok !== false, text: r.message || 'Connection OK' }); })
1096	      .catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
1097	  };
1098
1099	  return (
1100	    <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
1101	      tag={secretExists ? connected : not configured}>
1102	      {loading ?
Loading…
 : (<>
1103	        <SField label="Endpoint URL">
1104	          <input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
1105	        </SField>
1106

1107	          <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>
1108	          <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>
1109

1110	        <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>
1111	        <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>
1112	        <SettingsMsg msg={msg} />
1113

1114	          <button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
1115	          <button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
1116

1117	      </>)}
1118	    </SettingsCard>
1119	  );
1120	}
1121
1122	function GpuSettingsCard() {
1123	  const [cfg, setCfg] = React.useState(null);
1124	  const [saving, setSaving] = React.useState(false);
1125	  const [msg, setMsg] = React.useState(null);
1126
1127	  React.useEffect(() => {
1128	    window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
1129	  }, []);
1130
1131	  const save = () => {
1132	    setSaving(true); setMsg(null);
1133	    window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
1134	      .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); })
1135	      .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
1136	  };
1137
1138	  if (!cfg) return <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file">
Loading…
</SettingsCard>;
1139
1140	  const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
1141	  const gpuEnabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
1142
1143	  return (
1144	    <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
1145	      tag={gpuEnabled ? GPU mode : CPU mode}>
1146

1147	        These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
1148

1149
1150	      <SField label="Hardware acceleration">
1151
1152	          <input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
1153	          Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware
1154
1155	      </SField>
1156
1157

1158	        <SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>
1159	          <select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
1160	            {gpuEnabled ? (<>
1161	              <option value="h264_nvenc">h264_nvenc (NVIDIA)</option>
1162	              <option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>
1163	              <option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>
1164	              <option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>
1165	            </>) : (<>
1166	              <option value="libx264">libx264 (H.264, recommended)</option>
1167	              <option value="libx265">libx265 (HEVC, slower)</option>
1168	            </>)}
1169	          </select>
1170	        </SField>
1171	        <SField label="Preset">
1172	          <select className="field-input" value={cfg.gpu_preset || (gpuEnabled ? 'p4' : 'fast')} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
1173	            {gpuEnabled
1174	              ? ['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)
1175	              : ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}


grep -n "lastSeen\|fake\|Math.random" /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx | head -20


211:                  {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
435:      setBurned(b => b + Math.floor(Math.random() * 8) + 1);
436:      setRate(r => Math.max(0.8, Math.min(8, r + (Math.random() - 0.5) * 0.4)));
466:      const a = actions[Math.floor(Math.random() * actions.length)];
467:      const c = Math.floor(Math.random() * 28) + 1;


Excellent! Now let me check for unused state and buttons with no onClick:


grep -n "showCalc\|List\>.*button\|Graph\>.*button" /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx | head -20


431:  const [showCalc, setShowCalc] = React.useState(false);
486:        <button className="btn ghost sm" onClick={() => setShowCalc(!showCalc)}><Icon name="sliders" />Cost calculator</button>
584:        {showCalc && <CostCalculator onClose={() => setShowCalc(false)} />}
890:                <button className="active">Graph</button>
891:                <button>List</button>


grep -n "className=\"btn\|className=\"icon-btn" /tmp/wd/wild-dragon/services/web-ui/public/screens-admin.jsx | grep -v "onClick" | head -15


577:                  <button className="btn subtle sm" disabled style={{ width: "100%", marginTop: 8 }}>Not for sale</button>


Perfect! Now let me create my final audit report. I have enough information.

## AUDIT REPORT: React/JSX Frontend Dead Handlers & Bugs

### BROKEN BUTTONS (No onClick / No Action)

1. **screens-asset.jsx:146** — Download button with no onClick
2. **screens-asset.jsx:147** — Approve button with no onClick
3. **screens-asset.jsx:148** — More icon-btn with no onClick
4. **screens-asset.jsx:235** — Audio icon-btn with no onClick
5. **screens-asset.jsx:236** — Layout icon-btn with no onClick
6. **screens-editor.jsx:28** — Export button with no onClick
7. **screens-editor.jsx:29** — Publish button with no onClick
8. **screens-editor.jsx:36** — Search icon-btn (editor bins) with no onClick
9. **screens-editor.jsx:67** — ArrowRight icon-btn (timeline) with no onClick
10. **screens-editor.jsx:69-71** — Mark in, Mark out, Add to timeline buttons all with no onClick
11. **shell.jsx:125** — Sign out icon-btn with no onClick (power icon)
12. **shell.jsx:158** — Notifications icon-btn with no onClick (bell icon)
13. **screens-admin.jsx:577** — "Not for sale" button is disabled with no onClick (intentional, but confusing UX)
14. **screens-admin.jsx:890-891** — Graph/List tab buttons in Cluster topology section have no onClick (only Graph shows active state, List does nothing)

### FAKE / HARDCODED DATA

15. **screens-admin.jsx:429-439** — Tokens screen has fake random data: `Math.random()` spike generation, hardcoded burned tokens (14340), fake burn rate (2.4), artificial interval-based increment
16. **screens-admin.jsx:445-471** — Tokens screen: hardcoded fake billing events with fake timestamps, fake costs, fake action list; `setEvents` updates with `Math.random()` costs every 1.6s
17. **screens-admin.jsx:473-477** — Tokens screen: hardcoded pricing tiers as satire/parody (not real)
18. **screens-asset.jsx:3-18** — Hardcoded gradient palette `_FRAME_GRADIENTS` for missing thumbGrad function (workaround, not real data)

### ALERT() PLACEHOLDERS (TODO Markers)

19. **screens-admin.jsx:701** — `alert()` instead of real log viewing: "To view logs for {name}, run: docker compose logs -f {name}"
20. **screens-admin.jsx:707** — `alert()` for container restart failure fallback hint
21. **screens-admin.jsx:837** — `alert()` for adding cluster nodes (instructions, not real action)
22. **screens-admin.jsx:841** — `alert()` for node drain (unimplemented feature)
23. **screens-admin.jsx:852** — `alert()` for node logs hint (SSH/CLI instructions instead of real UI)

### UNUSED STATE

24. **screens-admin.jsx:890-891** — Cluster topology has "List" button with no onClick; button renders but clicking does nothing (Graph tab is hardcoded as active, List tab unreachable)

### INCONSISTENCIES

25. **screens-admin.jsx vs screens-editor.jsx** — Refresh buttons present in admin screens (Containers, Cluster, Jobs) but missing in Editor (no refresh for timeline assets)
26. **screens-asset.jsx:146-148 vs screens-editor.jsx:28-29** — Download/Export buttons work in Jobs screen but are dead in Asset Detail and Editor screens
27. **Topbar Notifications button (shell.jsx:158)** vs **sidebar Power button (shell.jsx:125)** — Both icon-buttons with no handlers; inconsistent with rest of nav pattern

### SATIRE / INTENTIONAL PLACEHOLDERS

28. **screens-admin.jsx Tokens screen** — Entire screen is satirical "token-metered pricing parody" (clearly marked with SATIRE badge); fake data is intentional but may confuse users about billing model

---

**Priority:** Broken buttons > Alert placeholders > Dead tabs > Fake data (Tokens is labeled satire). Most impactful: Asset Detail Download/Approve and Editor buttons (users will click expecting function). Least impactful: Tokens screen (documented as satire).