Files
solar-relay/solar_relay/web/status.js
T
franzz ec75ac15b0
Deploy Solar Relay / deploy (push) Failing after 4s
Initial commit
2026-11-01 22:33:32 +01:00

149 lines
8.1 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','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();
})();