| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>AES67 Node</title>
- <style>
- *{margin:0;padding:0;box-sizing:border-box}
- body{background:#111;color:#ccc;font:13px/1.5 'JetBrains Mono','Fira Code',Consolas,monospace}
- a{color:#e8a030}
- .wrap{max-width:1100px;margin:0 auto;padding:8px 12px}
- header{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #333;margin-bottom:12px}
- header h1{font-size:16px;color:#e8a030;font-weight:600}
- header .meta{font-size:11px;color:#888;text-align:right}
- .card{background:#1a1a1a;border:1px solid #282828;border-radius:4px;padding:10px 14px;margin-bottom:10px}
- .card h2{font-size:12px;color:#e8a030;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
- .row{display:flex;flex-wrap:wrap;gap:6px 18px}
- .row span{white-space:nowrap}
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
- .st{background:#222;border:1px solid #2a2a2a;border-radius:3px;padding:8px 10px;margin-bottom:6px;display:none}
- .st .sn{color:#e8a030;font-weight:600;font-size:12px}
- .st .si{font-size:11px;color:#999;margin-top:3px}
- .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
- .dg{background:#4a4}.da{background:#e8a030}.dr{background:#c44}
- .lbl{color:#666;font-size:11px}
- .val{color:#ddd}
- #rl .re{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #222}
- #rl .re:last-child{border:none}
- .btn{background:#e8a030;color:#111;border:none;padding:3px 10px;border-radius:3px;font:11px/1.4 inherit;cursor:pointer}
- .btn:hover{background:#f0b040}
- .bd{background:#c44}.bd:hover{background:#d55}
- .ms{display:flex;gap:8px;margin-top:6px;align-items:flex-start}
- .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}
- .ep{display:none;margin-top:6px;padding-top:6px;border-top:1px solid #333}
- .ep input,.ep textarea{width:100%;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px 6px;margin:2px 0 4px;font:11px inherit}
- .ep textarea{min-height:50px;font-size:10px;line-height:1.3;resize:vertical}
- .hdr{display:flex;justify-content:space-between;align-items:center}
- 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}
- #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}
- #overlay.show{display:flex}
- #overlay .box{background:#1a1a1a;border:1px solid #e8a030;border-radius:6px;padding:20px 30px;text-align:center;font-size:13px;color:#ccc}
- #overlay .box .dot{width:10px;height:10px;margin:0 auto 10px}
- .tag{font-size:9px;padding:1px 5px;border-radius:2px;margin-left:6px;vertical-align:middle;color:#111;font-weight:600}
- .tag-gm{background:#e8a030}.tag-slave{background:#4a4}
- @media(max-width:700px){.grid{grid-template-columns:1fr}}
- </style>
- </head>
- <body>
- <div id="overlay"><div class="box"><span class="dot da"></span><div id="ol-msg">Connecting...</div></div></div>
- <div class="wrap">
- <header>
- <h1 id="devname">AES67 Node</h1>
- <div class="meta"><span id="ver">--</span><br><span id="ip">--</span></div>
- </header>
- <div class="card">
- <h2>PTP Status</h2>
- <div class="row">
- <span><span class="dot dr" id="pd"></span><span id="pl">--</span><span class="tag" id="pt" style="display:none"></span></span>
- <span><span class="lbl">GM </span><span class="val" id="pg">--</span></span>
- <span><span class="lbl">Offset </span><span class="val" id="po">--</span><span class="lbl"> ns</span></span>
- <span><span class="lbl">Jitter </span><span class="val" id="pj">--</span><span class="lbl"> ns</span></span>
- <span><span class="lbl">Delay </span><span class="val" id="py">--</span><span class="lbl"> ns</span></span>
- </div>
- </div>
- <div class="grid">
- <div class="card"><h2>Sources (TX)</h2><div id="sc"></div></div>
- <div class="card"><h2>Sinks (RX)</h2><div id="sk"></div></div>
- </div>
- <div class="card">
- <h2>Network Discovery</h2>
- <div id="rl"><span class="lbl">No remote sources discovered</span></div>
- </div>
- <div class="card">
- <h2>Manual Subscribe</h2>
- <div class="ms">
- <textarea id="sdp" placeholder="Paste SDP text here v=0 o=- ..."></textarea>
- <button class="btn" onclick="mSub()">Subscribe</button>
- </div>
- </div>
- <footer>
- <span>Uptime: <span id="fu">--</span></span>
- <span>Heap: <span id="fh">--</span></span>
- <span>RTP: <span id="fr">--</span> pps</span>
- <span>Pkts: <span id="ft">--</span></span>
- <span>Lost: <span id="fl">--</span></span>
- </footer>
- </div>
- <script>
- (function(){
- var N=4,ws,rto,lRtp=0,lT=0;
- function g(id){return document.getElementById(id)}
- function fmt(n){return n!=null?Number(n).toLocaleString():"--"}
- function fmtUp(s){
- if(s==null)return"--";
- var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),r=s%60,p=[];
- if(d)p.push(d+"d");p.push(h+"h");p.push(m+"m");p.push(r+"s");return p.join(" ");
- }
- function esc(s){return s?String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""):""}
- function dc(l){return l==="locked"?"dg":l==="locking"||l==="acquiring"?"da":"dr"}
- function fmtTime(us){
- if(!us)return"0";
- if(us>=1000000)return(us/1000000).toFixed(1)+"s";
- if(us>=1000){var ms=us/1000;return(ms%1?ms.toFixed(3).replace(/0+$/,''):ms.toFixed(0))+"ms"}
- return us+"\u00b5s";
- }
- function fc(s){return(s.codec||"?")+" / "+(s.rate?fmt(s.rate)+"Hz":"?")+" / "+(s.ch||"?")+"ch / "+fmtTime(s.ptime)}
- /* Build all slot DOM once at init. Source slots and sink slots share structure
- but sinks get edit panel + delete button. IDs follow the spec:
- src-0-name, src-0-codec, src-0-tx, sink-0-name, sink-0-rx, sink-0-lost, etc. */
- function mkSlots(container,prefix,isSink){
- var none=document.createElement("span");
- none.className="lbl";none.id=prefix+"-none";
- none.textContent=isSink?"No active sinks":"No active sources";
- container.appendChild(none);
- for(var i=0;i<N;i++){
- var d=document.createElement("div");
- d.className="st";d.id=prefix+"-slot-"+i;d.setAttribute("data-stream-id",String(i));
- 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>';
- if(isSink){
- hdr+='<button class="btn" style="font-size:10px;padding:1px 6px;margin-right:4px" onclick="TE(\''+prefix+"',"+i+')">edit</button>';
- hdr+='<button class="btn bd" style="font-size:10px;padding:1px 6px" onclick="dS('+i+')">x</button>';
- }else{
- hdr+='<button class="btn" style="font-size:10px;padding:1px 6px" onclick="TE(\''+prefix+"',"+i+')">info</button>';
- }
- hdr+='</span></div>';
- hdr+='<div class="si" id="'+prefix+"-"+i+'-codec">--</div>';
- if(isSink){
- 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>';
- 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>';
- }else{
- hdr+='<div class="si">TX: <span id="src-'+i+'-tx">--</span> pkts</div>';
- hdr+='<div class="ep" id="edit-src-'+i+'"><div class="lbl">Source ID: '+i+'</div></div>';
- }
- d.innerHTML=hdr;
- container.appendChild(d);
- }
- }
- /* Init after DOM ready (gzip delivery may race with parser) */
- function init(){
- mkSlots(g("sc"),"src",false);
- mkSlots(g("sk"),"sink",true);
- fetch("/api/info").then(function(r){return r.json()}).then(function(d){
- if(d.name)g("devname").textContent=d.name;
- if(d.version)g("ver").textContent="v"+d.version;
- if(d.ip)g("ip").textContent=d.ip;
- }).catch(function(){});
- wsConn();
- }
- if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);
- else init();
- function updateSlots(prefix,arr){
- var ids={},i;
- if(arr)for(i=0;i<arr.length;i++)ids[arr[i].id]=arr[i];
- var any=false;
- for(i=0;i<N;i++){
- var slot=g(prefix+"-slot-"+i),data=ids[i];
- if(data){
- any=true;
- slot.style.display="block";
- var on=prefix==="src"?true:data.active;
- g(prefix+"-"+i+"-dot").className="dot "+(on?"dg":"dr");
- g(prefix+"-"+i+"-name").textContent=data.name||"--";
- g(prefix+"-"+i+"-codec").textContent=fc(data);
- if(prefix==="src"){
- g("src-"+i+"-tx").textContent=fmt(data.tx);
- }else{
- g("sink-"+i+"-rx").textContent=fmt(data.rx);
- g("sink-"+i+"-lost").textContent=fmt(data.lost);
- g("sink-"+i+"-jitter").textContent=fmt(data.jitter);
- var ni=g("edit-sink-name-"+i);
- if(ni&&document.activeElement!==ni)ni.value=data.name||"";
- }
- }else{
- slot.style.display="none";
- var ep=g("edit-"+prefix+"-"+i);
- if(ep)ep.style.display="none";
- }
- }
- g(prefix+"-none").style.display=any?"none":"";
- }
- function renderRemotes(arr){
- var c=g("rl");
- if(!arr||!arr.length){c.innerHTML='<span class="lbl">No remote sources discovered</span>';return}
- var h="";
- for(var i=0;i<arr.length;i++){
- var r=arr[i];
- var info=r.codec?(esc(r.codec)+' / '+fmt(r.rate)+'Hz / '+r.ch+'ch / '+fmtTime(r.ptime)):'';
- 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>';
- }
- c.innerHTML=h;
- }
- function onMsg(e){
- var d;try{d=JSON.parse(e.data)}catch(x){return}
- if(d.t!=="status")return;
- var p=d.ptp||{};
- g("pd").className="dot "+dc(p.lock);
- g("pl").textContent=p.lock||"Unknown";
- g("pg").textContent=p.gm||"--";
- var pt=g("pt");
- if(p.is_gm){pt.textContent="GRANDMASTER";pt.className="tag tag-gm";pt.style.display=""}
- else if(p.lock==="locked"){pt.textContent="SLAVE";pt.className="tag tag-slave";pt.style.display=""}
- else{pt.style.display="none"}
- g("po").textContent=fmt(p.offset);
- g("pj").textContent=fmt(p.jitter);
- g("py").textContent=fmt(p.delay);
- updateSlots("src",d.sources);
- updateSlots("sink",d.sinks);
- renderRemotes(d.sap_remotes);
- g("fu").textContent=fmtUp(d.uptime);
- g("fh").textContent=fmt(d.heap_free)+" B";
- var eth=d.eth||{},now=Date.now();
- if(lT>0&ð.rtp!=null){var dt=(now-lT)/1000;if(dt>0.1)g("fr").textContent=fmt(Math.round((eth.rtp-lRtp)/dt))}
- lRtp=eth.rtp||0;lT=now;
- g("ft").textContent=fmt(eth.total);
- g("fl").textContent=fmt(eth.lost);
- }
- var backoff=1000;
- function showOl(msg){var o=g("overlay");o.classList.add("show");g("ol-msg").textContent=msg}
- function hideOl(){g("overlay").classList.remove("show")}
- function wsConn(){
- showOl("Connecting...");
- var l=location;
- ws=new WebSocket((l.protocol==="https:"?"wss://":"ws://")+l.host+"/ws");
- ws.onopen=function(){backoff=1000;hideOl()};
- ws.onmessage=onMsg;
- ws.onclose=function(){
- var sec=Math.round(backoff/1000);
- showOl("Disconnected. Retrying in "+sec+"s...");
- rto=setTimeout(wsConn,backoff);
- backoff=Math.min(backoff*1.5,10000);
- };
- ws.onerror=function(){ws.close()};
- }
- /* Globals for onclick handlers */
- 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"};
- window.rS=function(id){
- var n=g("edit-sink-name-"+id),s=g("edit-sink-sdp-"+id);
- if(!s||!s.value.trim()){alert("Paste SDP text to re-subscribe");return}
- fetch("/api/sinks/"+id,{method:"DELETE"}).then(function(){
- return fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n?n.value:"Sink",sdp:s.value})});
- });
- };
- window.sR=function(o,n){fetch("/api/sinks",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,origin:o})})};
- window.dS=function(id){fetch("/api/sinks/"+id,{method:"DELETE"})};
- window.mSub=function(){
- var v=g("sdp").value.trim();if(!v)return;
- 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=""});
- };
- })();
- </script>
- </body>
- </html>
|