1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Solar relay · Collection status</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23171d20'/%3E%3Ccircle cx='32' cy='32' r='15' fill='%23e8ba70'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="assets/status.css">
|
||||
<script src="assets/status.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="masthead">
|
||||
<div class="brand"><span class="sun" aria-hidden="true">☀</span><span>SOLAR RELAY<small>RPI OFFICE</small></span></div>
|
||||
<div class="header-actions"><span id="overall" class="badge neutral" role="status">Connecting</span><button id="refresh" class="refresh">↻ <span>Refresh</span></button></div>
|
||||
</header>
|
||||
<section class="intro">
|
||||
<p class="eyebrow">COLLECTION MONITOR</p>
|
||||
<h1>Solar relay status</h1>
|
||||
<p class="description">Extraction, delivery and five-minute coverage of your local Fronius data.</p>
|
||||
<div class="context"><span id="source">—</span><span class="separator">/</span><span id="timezone">Europe/Zurich</span><span class="separator">/</span><span id="updated">Waiting for the relay</span></div>
|
||||
</section>
|
||||
<p id="notice" class="notice" role="alert" hidden></p>
|
||||
<section class="metrics" aria-label="Current collection status">
|
||||
<article class="metric"><p class="metric-label">Last extraction</p><strong id="last-extraction">—</strong><p id="last-extraction-date">No observation received yet</p><small id="last-success">Last successful extraction: —</small></article>
|
||||
<article class="metric"><p class="metric-label">Yesterday’s coverage</p><strong id="yesterday">— <span>/ —</span></strong><p id="yesterday-detail">Five-minute intervals</p><small>Energy, meter and boiler status</small></article>
|
||||
<article class="metric"><p class="metric-label">Waiting to upload</p><strong id="pending">—</strong><p id="upload-detail">Checking delivery</p><small id="last-upload">Last acknowledgement: —</small></article>
|
||||
<article class="metric"><p class="metric-label">Local archive</p><strong id="samples">—</strong><p>Observations retained in Parquet</p><small id="retention">Acknowledged readings kept for 30 days</small></article>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">DAILY COVERAGE</p><h2>Five-minute completeness</h2></div><label class="range">History <select id="history"><option value="7">7 days</option><option value="14" selected>14 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label></div>
|
||||
<p class="panel-note">Complete means a valid energy, meter and boiler extraction in that five-minute interval. It does not mean continuous measurement. Recovered cloud history supplies energy only.</p>
|
||||
<div class="table-scroll"><table><thead><tr><th>Day</th><th>Complete intervals</th><th class="source-column">Energy</th><th class="source-column">Meter</th><th class="source-column">Boiler</th><th>Status</th></tr></thead><tbody id="days"><tr><td colspan="6" class="empty">Waiting for the local archive…</td></tr></tbody></table></div>
|
||||
</section>
|
||||
<section class="panel detail" aria-label="Selected day interval details">
|
||||
<div class="panel-heading"><div><p class="eyebrow">FIVE-MINUTE INTERVALS</p><h2 id="selected-date">Select a day</h2></div><div class="legend"><span><i class="complete"></i>Complete</span><span><i class="partial"></i>Partial</span><span><i class="missing"></i>Missing</span><span><i class="future"></i>Upcoming</span></div></div>
|
||||
<p id="selected-summary" class="panel-note">Each column is one hour. Each cell is a five-minute interval.</p>
|
||||
<div id="slots" class="slots" aria-label="Five-minute coverage"></div>
|
||||
<p id="missing-ranges" class="gap-summary"></p>
|
||||
</section>
|
||||
<footer><span>Read-only status · refreshes every 15 seconds</span><span id="version">solar-relay</span></footer>
|
||||
<noscript><p class="notice">Enable JavaScript to load extraction and coverage status.</p></noscript>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
:root{color-scheme:dark;--bg:#101518;--panel:#171e22;--line:#2b353a;--ink:#eef1ed;--muted:#9ba9ac;--amber:#e8ba70;--green:#7ad6ac;--red:#e68d86;--radius:12px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible{outline:2px solid var(--amber);outline-offset:4px}main{max-width:1216px;margin:auto;padding:0 32px}.masthead{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-size:13px;letter-spacing:.14em;font-weight:750}.brand small{display:block;color:var(--muted);font-size:9px;letter-spacing:.22em;margin-top:5px}.sun{font-size:36px;color:var(--amber);line-height:1}.header-actions{display:flex;align-items:center;gap:16px}.badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:20px;padding:6px 11px;font-size:11px;font-weight:650;line-height:1.3;background:#252e32;color:var(--muted)}.badge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}.badge.ok,.badge.complete{color:var(--green);background:#193a2e}.badge.partial,.badge.in_progress{color:var(--amber);background:#3a3121}.badge.stale{color:var(--red);background:#3d2829}.badge.missing,.badge.waiting,.badge.neutral{color:var(--muted);background:#252e32}.refresh{border:1px solid var(--line);background:transparent;color:var(--ink);border-radius:7px;padding:8px 12px;font-size:13px}.refresh:hover{border-color:var(--muted)}.refresh:disabled{opacity:.45;cursor:wait}.intro{padding:42px 0 28px}.eyebrow{color:var(--amber);font-size:10px;font-weight:700;letter-spacing:.17em;margin:0 0 13px}h1{font-size:clamp(27px,3.4vw,42px);letter-spacing:-.045em;font-weight:550;line-height:1.14;margin:0 0 13px}.description{color:var(--muted);font-size:14px;line-height:1.6;margin:0}.context{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted);margin-top:20px;flex-wrap:wrap}.separator{color:#48565c}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 28px}.metric{border:1px solid var(--line);border-radius:var(--radius);padding:23px 20px;background:var(--panel)}.metric-label{font-size:11px;font-weight:550;color:var(--muted);margin:0 0 18px}.metric strong{display:block;font-size:30px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.04em;line-height:1.1;min-height:34px}.metric strong span{color:var(--muted);font-size:17px;font-weight:400;letter-spacing:normal}.metric p:not(.metric-label){font-size:11px;margin:12px 0 7px;color:#d1d9d7}.metric small{font-size:10px;color:var(--muted);line-height:1.6;display:block}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:22px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 24px 0}.panel-heading .eyebrow{margin-bottom:7px}h2{font-size:19px;font-weight:550;letter-spacing:-.025em;margin:0}.panel-note{font-size:11px;line-height:1.7;color:var(--muted);padding:0 24px;margin:12px 0 22px}.range{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:11px;white-space:nowrap}select{background:#20292e;border:1px solid #354149;border-radius:6px;color:var(--ink);padding:7px 27px 7px 9px;font-size:11px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;font-size:12px;font-variant-numeric:tabular-nums}th{background:#1c252a;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:11px 18px;font-size:9px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}th:first-child,td:first-child{padding-left:24px}td{padding:13px 18px;border-bottom:1px solid #283137;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tr.selected{background:#222d32}tr:hover{background:#202a2f}.day-select{display:block;text-align:left;color:var(--ink);background:transparent;border:0;padding:0;line-height:1.55;font-size:12px}.day-select small{display:block;color:var(--muted);font-size:10px}.coverage-number{display:flex;align-items:baseline;gap:5px;font-size:12px}.coverage-number small{font-size:10px;color:var(--muted)}.progress{height:3px;width:126px;border-radius:2px;background:#303c42;margin-top:7px;overflow:hidden}.progress span{display:block;height:100%;background:var(--green)}.source-count{color:#c1ceca;font-size:11px}.source-count.incomplete{color:var(--amber)}.empty{padding:40px!important;text-align:center;color:var(--muted)}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:6px}.legend i{display:block;width:7px;height:7px;border-radius:2px}.slots{display:flex;gap:5px;padding:0 24px;overflow-x:auto}.hour{display:grid;grid-template-rows:16px repeat(12,5px);gap:3px;flex:1;min-width:14px}.hour-label{font-size:9px;color:var(--muted);font-variant-numeric:tabular-nums;text-align:center}.slot{display:block;border-radius:1px;min-height:5px}.complete{background:#76c9a4}.partial{background:#bd9558}.missing{bacLine truncated
|
||||
@media(max-width:900px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.metric{padding:20px}.source-column{display:none}.legend{max-width:215px;gap:10px}.slots{gap:4px}.progress{width:115px}}
|
||||
@media(max-width:560px){main{padding:0 16px}.masthead{height:82px}.brand{font-size:11px;gap:8px}.sun{font-size:30px}.header-actions{gap:8px}.refresh span{display:none}.refresh{font-size:17px;padding:4px 9px}.badge{font-size:10px;padding:6px 8px}.intro{padding-top:30px}.metrics{gap:10px;margin-bottom:20px}.metric{padding:17px 14px}.metric strong{font-size:26px}.metric strong span{font-size:14px}.metric-label{margin-bottom:14px}.metric small{font-size:9px}.panel-heading{padding:20px 16px 0;align-items:flex-start}h2{font-size:16px}.panel-note{padding:0 16px;font-size:10px}th,td{padding-left:11px;padding-right:11px}th:first-child,td:first-child{padding-left:16px}.progress{width:88px}.range{flex-direction:column;align-items:flex-end;gap:5px}.legend{font-size:9px;gap:7px;max-width:150px}.slots{padding:0 16px;gap:3px}.hour{min-width:10px;grid-template-rows:16px repeat(12,5px);gap:3px}.hour-label{font-size:8px}.gap-summary{padding:16px;font-size:10px}.day-select{font-size:11px}footer{font-size:9px;flex-wrap:wrap}.detail .panel-heading{gap:8px}}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* 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();
|
||||
})();
|
||||
Reference in new issue
Block a user