149 lines
8.1 KiB
JavaScript
149 lines
8.1 KiB
JavaScript
/* 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]<day.elapsed?' incomplete':''));
|
||
row.append(cell);
|
||
}
|
||
const state = td();
|
||
state.append(badge(labels[day.status]||day.status,day.status));
|
||
row.append(state);
|
||
$('days').append(row);
|
||
}
|
||
}
|
||
function renderSlots() {
|
||
const day = current.days.find(item => 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;index<day.slots.length;index++) {
|
||
if(index%12===0) {
|
||
hour = addText($('slots'),'div','','hour');
|
||
addText(hour,'span',format(new Date(start+index*300000),{hour:'2-digit',hour12:false}),'hour-label');
|
||
}
|
||
const mask = day.slots[index];
|
||
const state = mask===7?'complete':mask===-1?'future':mask>0?'partial':'missing';
|
||
const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
|
||
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===7?'Complete':mask===-1?'Upcoming':'Incomplete: '+missing.join(', ')}`;
|
||
const cell = addText(hour,'span','','slot '+state);
|
||
cell.title = text;
|
||
cell.setAttribute('role','img');
|
||
cell.setAttribute('aria-label',text);
|
||
}
|
||
const ranges = [];
|
||
for(let index=0;index<day.elapsed;) {
|
||
if(day.slots[index]===7) {index++;continue;}
|
||
const from = index;
|
||
while(index<day.elapsed && day.slots[index]!==7) index++;
|
||
ranges.push(`${stamp(from)}–${stamp(index)}`);
|
||
}
|
||
$('missing-ranges').textContent = ranges.length ? `${number(day.missing)} incomplete intervals. Gaps: ${ranges.slice(0,6).join('; ')}${ranges.length>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();
|
||
})();
|