/* Read-only local archive dashboard. All URLs remain relative to the proxy prefix. */ (() => { 'use strict'; const $ = id => document.getElementById(id); const labels = {ok:'Collecting',waiting:'Waiting for data',partial:'Partial extraction',stale:'Extraction overdue',complete:'Complete',in_progress:'In progress',missing:'No data'}; let current = null; let selected = null; let busy = false; let zone = 'Europe/Zurich'; const number = value => new Intl.NumberFormat('en-GB').format(value); const format = (value, options) => value ? new Intl.DateTimeFormat('en-GB', {timeZone:zone,...options}).format(new Date(value)) : '—'; const time = value => format(value,{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}); const date = value => format(value,{day:'2-digit',month:'short',year:'numeric'}); function badge(text, status) { const element = document.createElement('span'); element.className = 'badge ' + status; element.textContent = text; return element; } function td(className) { const element = document.createElement('td'); if(className) element.className = className; return element; } function addText(parent, tag, text, className) { const element = document.createElement(tag); if(className) element.className = className; element.textContent = text; parent.append(element); return element; } function renderDays() { $('days').replaceChildren(); for(const day of current.days) { const row = document.createElement('tr'); row.classList.toggle('selected',day.date===selected); const dayCell = td(); const button = addText(dayCell,'button',format(day.date+'T12:00:00Z',{day:'2-digit',month:'short'}),'day-select'); button.type = 'button'; button.setAttribute('aria-expanded',String(day.date===selected)); addText(button,'small',day.status==='in_progress'?'Today':format(day.date+'T12:00:00Z',{weekday:'long'})); button.addEventListener('click',() => {selected=day.date;renderDays();renderSlots();}); row.append(dayCell); const amount = td(); const count = addText(amount,'div',number(day.complete),'coverage-number'); addText(count,'small','/ '+number(day.elapsed)+(day.status==='in_progress'?' so far':'')); const progress = addText(amount,'div','','progress'); const fill = addText(progress,'span',''); fill.style.width = (day.elapsed ? day.complete/day.elapsed*100:0)+'%'; row.append(amount); for(const field of ['energy_complete','meter_complete','boiler_complete']) { const cell = td('source-column'); addText(cell,'span',number(day[field])+'/'+number(day.elapsed),'source-count'+(day[field] item.date===selected); if(!day) return; $('selected-date').textContent = format(day.date+'T12:00:00Z',{weekday:'long',day:'numeric',month:'long',year:'numeric'}); $('selected-summary').textContent = `${number(day.complete)} complete of ${number(day.elapsed)} elapsed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`; const start = Date.parse(day.day_start); const stamp = index => format(new Date(start+index*300000),{hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset',hour12:false}); $('slots').replaceChildren(); let hour; for(let index=0;index0?'partial':'missing'; const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<6?`; plus ${number(ranges.length-6)} more ranges.`:'.'}` : day.status==='in_progress'?'All elapsed intervals are complete. The current interval is still being recorded.':'All five-minute intervals have complete data.'; } function render(data) { current = data; zone = data.timezone; $('source').textContent = data.source_id; $('timezone').textContent = zone; $('updated').textContent = 'Updated '+time(data.generated_at); $('overall').textContent = labels[data.status]||data.status; $('overall').className = 'badge '+data.status; $('last-extraction').textContent = data.collector.last_attempt?time(data.collector.last_attempt):'Waiting'; $('last-extraction-date').textContent = data.collector.last_attempt?date(data.collector.last_attempt):'No observation received yet'; $('last-success').textContent = 'Last successful: '+(data.collector.last_success?date(data.collector.last_success)+' '+time(data.collector.last_success):'not yet recorded'); const yesterday = data.days[1]; if(yesterday) { $('yesterday').replaceChildren(document.createTextNode(number(yesterday.complete)+' ')); addText($('yesterday'),'span','/ '+number(yesterday.expected)); $('yesterday-detail').textContent = labels[yesterday.status]+' · '+date(yesterday.date+'T12:00:00Z'); } $('pending').textContent = number(data.archive.pending); $('upload-detail').textContent = !data.archive.upload_enabled?'Uploads not configured':data.archive.pending?'Saved locally, awaiting delivery':'All observations acknowledged'; $('last-upload').textContent = 'Last acknowledgement: '+(data.archive.last_upload?date(data.archive.last_upload)+' '+time(data.archive.last_upload):'none yet'); $('samples').textContent = number(data.archive.samples); $('retention').textContent = `Acknowledged readings kept for ${number(data.archive.retention_days)} days · pending readings retained`; $('version').textContent = 'solar-relay '+data.version; if(!selected || !data.days.some(day=>day.date===selected)) selected=(data.days.find(day=>day.samples>0)||yesterday||data.days[0]).date; renderDays(); renderSlots(); } async function refresh() { if(busy) return; busy=true; $('refresh').disabled=true; const controller = new AbortController(); const timeout = setTimeout(()=>controller.abort(),12000); try { const response = await fetch('api/status?days='+encodeURIComponent($('history').value),{cache:'no-store',signal:controller.signal}); if(!response.ok) throw new Error('Status request failed'); render(await response.json()); $('notice').hidden=true; } catch { $('notice').textContent = current?'The relay cannot be reached. Showing the last received status; automatic refresh will retry.':'Waiting for the relay status service. Check that solar-relay.service is running and the reverse proxy forwards this path to port 8765.'; $('notice').hidden=false; $('overall').textContent='Connection lost'; $('overall').className='badge stale'; } finally { clearTimeout(timeout); busy=false; $('refresh').disabled=false; } } $('refresh').addEventListener('click',refresh); $('history').addEventListener('change',refresh); document.addEventListener('visibilitychange',()=>{if(!document.hidden) refresh();}); setInterval(()=>{if(!document.hidden) refresh();},15000); refresh(); })();