Files
solar-relay/solar_relay/web/status.js
T
franzz 89621bc7c3
Deploy Solar Relay / deploy (push) Successful in 1m2s
Simplify status interface
2026-11-02 23:14:13 +01:00

150 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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','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)} assessed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`;
const start = Date.parse(day.day_start);
const currentIndex = day.status==='in_progress' ? Math.floor((Date.parse(current.generated_at)-start)/300000) : -1;
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===3?'complete':mask===-1?'future':mask>0?'partial':'missing';
const missing = ['Energy','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===3?'Complete':mask===-1?(index===currentIndex?'Awaiting extraction':'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]===3) {index++;continue;}
const from = index;
while(index<day.elapsed && day.slots[index]!==3) 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'?(day.slots[currentIndex]===-1?'All previous intervals are complete. Waiting for the current extraction.':'All intervals checked so far have complete data.'):'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 = `Retention: ${number(data.archive.retention_days)} full calendar days · pending days 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();
})();