Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
(()=>{
|
||||
const q=(selector)=>document.querySelector(selector),limit=1000;
|
||||
const timeline=q('#timeline'),notice=q('#notice'),streamLabel=q('#stream'),connection=q('#connection'),filters=q('#filters');
|
||||
const items=[];let paused=false,overflow=0,lastId=0,source=null;
|
||||
function filtered(event){const values=new FormData(filters);return (!values.get('direction')||String(event.direction||'').toLowerCase().includes(String(values.get('direction')).trim().toLowerCase()))&&(!values.get('topic')||String(event.topic||'').toLowerCase().includes(String(values.get('topic')).trim().toLowerCase()))&&(!values.get('device')||String(event.device_id||'').toLowerCase().includes(String(values.get('device')).trim().toLowerCase()))&&(!values.get('request')||String(event.request_id||'').toLowerCase().includes(String(values.get('request')).trim().toLowerCase()))}
|
||||
function node(tag,className,text){const el=document.createElement(tag);if(className)el.className=className;if(text!==undefined)el.textContent=text;return el}
|
||||
function renderEvent(e){const card=node('article','event '+e.direction);const head=node('div','event-head');head.append(node('span','kind',e.kind));head.append(node('time','time',new Date(e.observed_at).toLocaleTimeString()));const details=[e.direction,e.topic,e.device_id&&('device '+e.device_id),e.request_id&&('request '+e.request_id),'QoS '+e.qos,e.retain?'retained':'non-retained',e.outcome].filter(Boolean);head.append(node('span','meta',details.join(' · ')));card.append(head);const p=e.payload||{};if(p.text||p.omitted){card.append(node('pre','',p.omitted?('Payload omitted · '+p.bytes+' bytes'):p.text+(p.truncated?' … [preview truncated]':'')))}const props=e.properties||{};const values=[];if(props.response_topic)values.push('Response Topic '+props.response_topic);if(props.correlation_data)values.push('Correlation Data '+props.correlation_data);if(props.message_expiry_seconds!==undefined)values.push('Message Expiry '+props.message_expiry_seconds+'s');if(values.length)card.append(node('div','meta',values.join(' · ')));if(e.latency_ms!==undefined)card.append(node('div','meta','This process measured '+e.latency_ms+' ms'));return card}
|
||||
function redraw(){timeline.replaceChildren();let shown=0;for(const e of items){if(!filtered(e))continue;timeline.append(renderEvent(e));shown++}if(!shown)timeline.append(node('div','empty',items.length?'No events match these filters.':'Waiting for events. Start the device simulator or ESP32 to see input and output.'));notice.textContent=overflow?'Event gap: '+overflow+' earlier event(s) are outside this page’s bounded view.':''}
|
||||
function add(e){lastId=Math.max(lastId,e.id||0);if(paused){overflow++;return}items.push(e);if(items.length>limit)items.splice(0,items.length-limit);redraw()}
|
||||
function compatibility(rows){const tbody=q('#compatibility');tbody.replaceChildren();for(const row of rows||[]){const tr=document.createElement('tr');tr.append(node('td','',row.feature));const td=node('td');td.append(node('span','status '+row.status,row.status));tr.append(td,node('td','',row.evidence));tbody.append(tr)}}
|
||||
function stats(data){connection.textContent=data.connection||'starting';connection.className='badge '+(data.connection||'starting');const box=q('#stats');box.replaceChildren();const all=[['Retained events',data.events?.length||0],['Last event ID',data.last_event_id||0],['Received',data.counts?.message_received||0],['Contract errors',data.counts?.validation_failed||0],['Published',data.counts?.publish_attempt||0],['Transport ACK',data.counts?.transport_ack||0],['Receipt ACKs',data.counts?.receipt_ack_sent||0],['ACK failures',data.counts?.receipt_ack_failed||0],['ACK queue drops',data.counts?.receipt_ack_dropped||0],['Accepted',data.counts?.command_accepted||0],['Completed',data.counts?.command_completed||0],['Timeouts',data.counts?.command_timeout||0],['Event gaps',data.counts?.event_gap||0]];for(const [label,value]of all){const card=node('div','stat');card.append(node('b','',String(value)),node('span','',label));box.append(card)}compatibility(data.compatibility)}
|
||||
async function connect(){streamLabel.textContent='connecting';try{const response=await fetch('/api/snapshot',{cache:'no-store'});if(!response.ok)throw Error();const data=await response.json();stats(data);if(!source){if(data.gap){lastId=Number(data.gap.reset_id)||lastId;overflow+=Number(data.gap.gap)||1}for(const event of data.events||[])add(event);source=new EventSource('/api/events?after='+encodeURIComponent(lastId));source.addEventListener('mqtt-event',e=>{try{add(JSON.parse(e.data).event)}catch{notice.textContent='Received an unreadable event.'}});source.addEventListener('gap',e=>{let value={};try{value=JSON.parse(e.data)}catch{}lastId=Number(value.reset_id)||lastId;overflow+=Number(value.gap)||1;streamLabel.textContent='resynced after event gap';redraw()});source.onopen=()=>{streamLabel.textContent='live'};source.onerror=()=>{streamLabel.textContent='reconnecting'}}}catch{connection.textContent='viewer unavailable';connection.className='badge disconnected';streamLabel.textContent='disconnected';setTimeout(connect,2000)}}
|
||||
q('#pause').addEventListener('click',()=>{paused=!paused;q('#pause').textContent=paused?'Resume view':'Pause view';if(!paused){overflow=0;redraw()}});q('#clear').addEventListener('click',()=>{items.length=0;redraw()});filters.addEventListener('input',redraw);filters.addEventListener('reset',()=>setTimeout(redraw,0));connect();setInterval(async()=>{try{const r=await fetch('/api/snapshot',{cache:'no-store'});if(r.ok)stats(await r.json())}catch{}},3000)
|
||||
})();
|
||||
Reference in New Issue
Block a user