index.html 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1">
  6. <title>AES67 Node</title>
  7. <style>
  8. *{margin:0;padding:0;box-sizing:border-box}
  9. body{background:#111;color:#ccc;font:13px/1.5 'JetBrains Mono','Fira Code',Consolas,monospace}
  10. a{color:#e8a030}
  11. .wrap{max-width:1100px;margin:0 auto;padding:8px 12px}
  12. header{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #333;margin-bottom:12px}
  13. header h1{font-size:16px;color:#e8a030;font-weight:600}
  14. header .meta{font-size:11px;color:#888;text-align:right}
  15. .card{background:#1a1a1a;border:1px solid #282828;border-radius:4px;padding:10px 14px;margin-bottom:10px}
  16. .card h2{font-size:12px;color:#e8a030;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
  17. .row{display:flex;flex-wrap:wrap;gap:6px 18px}
  18. .row span{white-space:nowrap}
  19. .grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
  20. .st{background:#222;border:1px solid #2a2a2a;border-radius:3px;padding:8px 10px;margin-bottom:6px;display:none}
  21. .st .sn{color:#e8a030;font-weight:600;font-size:12px}
  22. .st .si{font-size:11px;color:#999;margin-top:3px}
  23. .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
  24. .dg{background:#4a4}.da{background:#e8a030}.dr{background:#c44}
  25. .lbl{color:#666;font-size:11px}
  26. .val{color:#ddd}
  27. #rl .re{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #222}
  28. #rl .re:last-child{border:none}
  29. .btn{background:#e8a030;color:#111;border:none;padding:3px 10px;border-radius:3px;font:11px/1.4 inherit;cursor:pointer}
  30. .btn:hover{background:#f0b040}
  31. .bd{background:#c44}.bd:hover{background:#d55}
  32. .ms{display:flex;gap:8px;margin-top:6px;align-items:flex-start}
  33. .ms textarea{flex:1;background:#222;border:1px solid #333;color:#ccc;padding:4px 8px;border-radius:3px;font:11px/1.4 inherit;resize:vertical;min-height:60px}
  34. .ep{display:none;margin-top:6px;padding-top:6px;border-top:1px solid #333}
  35. .ep input,.ep textarea{width:100%;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px 6px;margin:2px 0 4px;font:11px inherit}
  36. .ep textarea{min-height:50px;font-size:10px;line-height:1.3;resize:vertical}
  37. .hdr{display:flex;justify-content:space-between;align-items:center}
  38. footer{border-top:1px solid #333;padding:8px 0;font-size:11px;color:#666;display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px;margin-top:8px}
  39. #overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:99;justify-content:center;align-items:center}
  40. #overlay.show{display:flex}
  41. #overlay .box{background:#1a1a1a;border:1px solid #e8a030;border-radius:6px;padding:20px 30px;text-align:center;font-size:13px;color:#ccc}
  42. #overlay .box .dot{width:10px;height:10px;margin:0 auto 10px}
  43. .tag{font-size:9px;padding:1px 5px;border-radius:2px;margin-left:6px;vertical-align:middle;color:#111;font-weight:600}
  44. .tag-gm{background:#e8a030}.tag-slave{background:#4a4}
  45. @media(max-width:700px){.grid{grid-template-columns:1fr}}
  46. </style>
  47. </head>
  48. <body>
  49. <div id="overlay"><div class="box"><span class="dot da"></span><div id="ol-msg">Connecting...</div></div></div>
  50. <div class="wrap">
  51. <header>
  52. <h1 id="devname">AES67 Node</h1>
  53. <div class="meta"><span id="ver">--</span><br><span id="ip">--</span></div>
  54. </header>
  55. <div class="card">
  56. <h2>PTP Status</h2>
  57. <div class="row">
  58. <span><span class="dot dr" id="pd"></span><span id="pl">--</span><span class="tag" id="pt" style="display:none"></span></span>
  59. <span><span class="lbl">GM </span><span class="val" id="pg">--</span></span>
  60. <span><span class="lbl">Offset </span><span class="val" id="po">--</span><span class="lbl"> ns</span></span>
  61. <span><span class="lbl">Jitter </span><span class="val" id="pj">--</span><span class="lbl"> ns</span></span>
  62. <span><span class="lbl">Delay </span><span class="val" id="py">--</span><span class="lbl"> ns</span></span>
  63. </div>
  64. </div>
  65. <div class="grid">
  66. <div class="card"><h2>Sources (TX)</h2><div id="sc"></div></div>
  67. <div class="card"><h2>Sinks (RX)</h2><div id="sk"></div></div>
  68. </div>
  69. <div class="card">
  70. <h2>Network Discovery</h2>
  71. <div id="rl"><span class="lbl">No remote sources discovered</span></div>
  72. </div>
  73. <div class="card">
  74. <h2>Manual Subscribe</h2>
  75. <div class="ms">
  76. <textarea id="sdp" placeholder="Paste SDP text here&#10;v=0&#10;o=- ..."></textarea>
  77. <button class="btn" onclick="mSub()">Subscribe</button>
  78. </div>
  79. </div>
  80. <footer>
  81. <span>Uptime: <span id="fu">--</span></span>
  82. <span>Heap: <span id="fh">--</span></span>
  83. <span>RTP: <span id="fr">--</span> pps</span>
  84. <span>Pkts: <span id="ft">--</span></span>
  85. <span>Lost: <span id="fl">--</span></span>
  86. </footer>
  87. </div>
  88. <script>
  89. (function(){
  90. var N=4,ws,rto,lRtp=0,lT=0;
  91. function g(id){return document.getElementById(id)}
  92. function fmt(n){return n!=null?Number(n).toLocaleString():"--"}
  93. function fmtUp(s){
  94. if(s==null)return"--";
  95. var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),r=s%60,p=[];
  96. if(d)p.push(d+"d");p.push(h+"h");p.push(m+"m");p.push(r+"s");return p.join(" ");
  97. }
  98. function esc(s){return s?String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}
  99. function dc(l){return l==="locked"?"dg":l==="locking"||l==="acquiring"?"da":"dr"}
  100. function fmtTime(us){
  101. if(!us)return"0";
  102. if(us>=1000000)return(us/1000000).toFixed(1)+"s";
  103. if(us>=1000){var ms=us/1000;return(ms%1?ms.toFixed(3).replace(/0+$/,''):ms.toFixed(0))+"ms"}
  104. return us+"\u00b5s";
  105. }
  106. function fc(s){return(s.codec||"?")+" / "+(s.rate?fmt(s.rate)+"Hz":"?")+" / "+(s.ch||"?")+"ch / "+fmtTime(s.ptime)}
  107. /* Build all slot DOM once at init. Source slots and sink slots share structure
  108. but sinks get edit panel + delete button. IDs follow the spec:
  109. src-0-name, src-0-codec, src-0-tx, sink-0-name, sink-0-rx, sink-0-lost, etc. */
  110. function mkSlots(container,prefix,isSink){
  111. var none=document.createElement("span");
  112. none.className="lbl";none.id=prefix+"-none";
  113. none.textContent=isSink?"No active sinks":"No active sources";
  114. container.appendChild(none);
  115. for(var i=0;i<N;i++){
  116. var d=document.createElement("div");
  117. d.className="st";d.id=prefix+"-slot-"+i;d.setAttribute("data-stream-id",String(i));
  118. var hdr='<div class="hdr"><span class="sn"><span class="dot dr" id="'+prefix+"-"+i+'-dot"></span><span id="'+prefix+"-"+i+'-name">--</span></span><span>';
  119. if(isSink){
  120. hdr+='<button class="btn" style="font-size:10px;padding:1px 6px;margin-right:4px" onclick="TE(\''+prefix+"',"+i+')">edit</button>';
  121. hdr+='<button class="btn bd" style="font-size:10px;padding:1px 6px" onclick="dS('+i+')">x</button>';
  122. }else{
  123. hdr+='<button class="btn" style="font-size:10px;padding:1px 6px" onclick="TE(\''+prefix+"',"+i+')">info</button>';
  124. }
  125. hdr+='</span></div>';
  126. hdr+='<div class="si" id="'+prefix+"-"+i+'-codec">--</div>';
  127. if(isSink){
  128. hdr+='<div class="si">RX: <span id="sink-'+i+'-rx">--</span> pkts | Lost: <span id="sink-'+i+'-lost">--</span> | Jitter: <span id="sink-'+i+'-jitter">--</span> us</div>';
  129. hdr+='<div class="ep" id="edit-sink-'+i+'"><div class="lbl">Name:</div><input id="edit-sink-name-'+i+'" value=""><div class="lbl">SDP (paste to re-subscribe):</div><textarea id="edit-sink-sdp-'+i+'" placeholder="Paste SDP..."></textarea><button class="btn" onclick="rS('+i+')">Re-subscribe</button></div>';
  130. }else{
  131. hdr+='<div class="si">TX: <span id="src-'+i+'-tx">--</span> pkts</div>';
  132. hdr+='<div class="ep" id="edit-src-'+i+'"><div class="lbl">Source ID: '+i+'</div></div>';
  133. }
  134. d.innerHTML=hdr;
  135. container.appendChild(d);
  136. }
  137. }
  138. /* Init after DOM ready (gzip delivery may race with parser) */
  139. function init(){
  140. mkSlots(g("sc"),"src",false);
  141. mkSlots(g("sk"),"sink",true);
  142. fetch("/api/info").then(function(r){return r.json()}).then(function(d){
  143. if(d.name)g("devname").textContent=d.name;
  144. if(d.version)g("ver").textContent="v"+d.version;
  145. if(d.ip)g("ip").textContent=d.ip;
  146. }).catch(function(){});
  147. wsConn();
  148. }
  149. if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);
  150. else init();
  151. function updateSlots(prefix,arr){
  152. var ids={},i;
  153. if(arr)for(i=0;i<arr.length;i++)ids[arr[i].id]=arr[i];
  154. var any=false;
  155. for(i=0;i<N;i++){
  156. var slot=g(prefix+"-slot-"+i),data=ids[i];
  157. if(data){
  158. any=true;
  159. slot.style.display="block";
  160. var on=prefix==="src"?true:data.active;
  161. g(prefix+"-"+i+"-dot").className="dot "+(on?"dg":"dr");
  162. g(prefix+"-"+i+"-name").textContent=data.name||"--";
  163. g(prefix+"-"+i+"-codec").textContent=fc(data);
  164. if(prefix==="src"){
  165. g("src-"+i+"-tx").textContent=fmt(data.tx);
  166. }else{
  167. g("sink-"+i+"-rx").textContent=fmt(data.rx);
  168. g("sink-"+i+"-lost").textContent=fmt(data.lost);
  169. g("sink-"+i+"-jitter").textContent=fmt(data.jitter);
  170. var ni=g("edit-sink-name-"+i);
  171. if(ni&&document.activeElement!==ni)ni.value=data.name||"";
  172. }
  173. }else{
  174. slot.style.display="none";
  175. var ep=g("edit-"+prefix+"-"+i);
  176. if(ep)ep.style.display="none";
  177. }
  178. }
  179. g(prefix+"-none").style.display=any?"none":"";
  180. }
  181. function renderRemotes(arr){
  182. var c=g("rl");
  183. if(!arr||!arr.length){c.innerHTML='<span class="lbl">No remote sources discovered</span>';return}
  184. var h="";
  185. for(var i=0;i<arr.length;i++){
  186. var r=arr[i];
  187. var info=r.codec?(esc(r.codec)+' / '+fmt(r.rate)+'Hz / '+r.ch+'ch / '+fmtTime(r.ptime)):'';
  188. h+='<div class="re"><span>'+esc(r.name)+' <span class="lbl">('+esc(r.origin)+')</span>'+(info?' <span class="lbl">'+info+'</span>':'')+'</span><button class="btn" onclick="sR(\''+esc(r.origin)+"','"+esc(r.name)+'\')">Subscribe</button></div>';
  189. }
  190. c.innerHTML=h;
  191. }
  192. function onMsg(e){
  193. var d;try{d=JSON.parse(e.data)}catch(x){return}
  194. if(d.t!=="status")return;
  195. var p=d.ptp||{};
  196. g("pd").className="dot "+dc(p.lock);
  197. g("pl").textContent=p.lock||"Unknown";
  198. g("pg").textContent=p.gm||"--";
  199. var pt=g("pt");
  200. if(p.is_gm){pt.textContent="GRANDMASTER";pt.className="tag tag-gm";pt.style.display=""}
  201. else if(p.lock==="locked"){pt.textContent="SLAVE";pt.className="tag tag-slave";pt.style.display=""}
  202. else{pt.style.display="none"}
  203. g("po").textContent=fmt(p.offset);
  204. g("pj").textContent=fmt(p.jitter);
  205. g("py").textContent=fmt(p.delay);
  206. updateSlots("src",d.sources);
  207. updateSlots("sink",d.sinks);
  208. renderRemotes(d.sap_remotes);
  209. g("fu").textContent=fmtUp(d.uptime);
  210. g("fh").textContent=fmt(d.heap_free)+" B";
  211. var eth=d.eth||{},now=Date.now();
  212. if(lT>0&&eth.rtp!=null){var dt=(now-lT)/1000;if(dt>0.1)g("fr").textContent=fmt(Math.round((eth.rtp-lRtp)/dt))}
  213. lRtp=eth.rtp||0;lT=now;
  214. g("ft").textContent=fmt(eth.total);
  215. g("fl").textContent=fmt(eth.lost);
  216. }
  217. var backoff=1000;
  218. function showOl(msg){var o=g("overlay");o.classList.add("show");g("ol-msg").textContent=msg}
  219. function hideOl(){g("overlay").classList.remove("show")}
  220. function wsConn(){
  221. showOl("Connecting...");
  222. var l=location;
  223. ws=new WebSocket((l.protocol==="https:"?"wss://":"ws://")+l.host+"/ws");
  224. ws.onopen=function(){backoff=1000;hideOl()};
  225. ws.onmessage=onMsg;
  226. ws.onclose=function(){
  227. var sec=Math.round(backoff/1000);
  228. showOl("Disconnected. Retrying in "+sec+"s...");
  229. rto=setTimeout(wsConn,backoff);
  230. backoff=Math.min(backoff*1.5,10000);
  231. };
  232. ws.onerror=function(){ws.close()};
  233. }
  234. /* Globals for onclick handlers */
  235. window.TE=function(t,id){var e=g("edit-"+t+"-"+id);if(e)e.style.display=(!e.style.display||e.style.display==="none")?"block":"none"};
  236. window.rS=function(id){
  237. var n=g("edit-sink-name-"+id),s=g("edit-sink-sdp-"+id);
  238. if(!s||!s.value.trim()){alert("Paste SDP text to re-subscribe");return}
  239. fetch("/api/sinks/"+id,{method:"DELETE"}).then(function(){
  240. return fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n?n.value:"Sink",sdp:s.value})});
  241. });
  242. };
  243. window.sR=function(o,n){fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,origin:o})})};
  244. window.dS=function(id){fetch("/api/sinks/"+id,{method:"DELETE"})};
  245. window.mSub=function(){
  246. var v=g("sdp").value.trim();if(!v)return;
  247. fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"Manual",sdp:v})}).then(function(r){if(r.ok)g("sdp").value=""});
  248. };
  249. })();
  250. </script>
  251. </body>
  252. </html>