Initial pump monitor with Coolify deployment
This commit is contained in:
+456
@@ -0,0 +1,456 @@
|
||||
DASHBOARD_HTML = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0d1717">
|
||||
<title>Waterworks · Pump monitor</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #e7eee8;
|
||||
--muted: #8fa19a;
|
||||
--paper: #0d1717;
|
||||
--panel: #142220;
|
||||
--line: #29413d;
|
||||
--active: #8ee6bb;
|
||||
--active-deep: #2ea778;
|
||||
--warning: #f0bd69;
|
||||
--danger: #ee806f;
|
||||
--idle: #70827c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--paper); color: var(--ink); }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif;
|
||||
background:
|
||||
linear-gradient(rgba(142, 230, 187, .035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(142, 230, 187, .035) 1px, transparent 1px),
|
||||
radial-gradient(circle at 85% 0%, rgba(46, 167, 120, .18), transparent 34rem),
|
||||
var(--paper);
|
||||
background-size: 32px 32px, 32px 32px, auto, auto;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .16;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.22'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 42px 0 64px; }
|
||||
header { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; margin-bottom: 30px; }
|
||||
.eyebrow, .metric-label, th, .section-kicker {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .13em;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
h1 { margin: 4px 0 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(38px, 7vw, 74px); font-weight: 400; letter-spacing: -.045em; line-height: .98; }
|
||||
.connection { display: flex; gap: 9px; align-items: center; padding-bottom: 8px; color: var(--muted); font-size: 13px; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 99px; background: var(--idle); box-shadow: 0 0 0 4px rgba(112,130,124,.12); }
|
||||
.dot.live { background: var(--active); box-shadow: 0 0 0 4px rgba(142,230,187,.12), 0 0 18px rgba(142,230,187,.45); }
|
||||
.dot.offline { background: var(--danger); box-shadow: 0 0 0 4px rgba(238,128,111,.12); }
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(260px, .75fr); gap: 16px; }
|
||||
.panel { position: relative; background: rgba(20, 34, 32, .92); border: 1px solid var(--line); border-radius: 3px; overflow: hidden; }
|
||||
.last-run { min-height: 310px; padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.last-run::after { content: ""; position: absolute; inset: auto -10% -58% 36%; height: 290px; border: 1px solid rgba(142,230,187,.14); border-radius: 50%; box-shadow: 0 0 60px rgba(46,167,120,.06) inset; }
|
||||
.last-run-emphasis { position: relative; z-index: 1; display: flex; align-items: baseline; gap: 14px; }
|
||||
.last-run-value { font-family: "SFMono-Regular", Consolas, monospace; font-size: clamp(58px, 9vw, 106px); letter-spacing: -.075em; line-height: .88; color: var(--ink); }
|
||||
.last-run-unit { color: var(--muted); font-size: 14px; letter-spacing: .04em; }
|
||||
.run-facts { position: relative; z-index: 1; display: grid; grid-template-columns: 1.25fr 1.25fr .75fr .75fr; border-top: 1px solid var(--line); }
|
||||
.run-fact { min-width: 0; padding: 17px 17px 0 0; }
|
||||
.run-fact + .run-fact { padding-left: 17px; border-left: 1px solid var(--line); }
|
||||
.run-fact span { display: block; margin-bottom: 8px; font: 10px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
|
||||
.run-fact strong { display: block; color: var(--ink); font-size: 13px; font-weight: 500; line-height: 1.35; font-variant-numeric: tabular-nums; }
|
||||
.metrics { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.metric { min-height: 155px; padding: 24px; border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); }
|
||||
.metric:nth-child(2n) { border-right: 0; }
|
||||
.metric:nth-last-child(-n+2) { border-bottom: 0; }
|
||||
.metric-value { margin-top: 14px; font-family: Georgia, serif; font-size: 37px; letter-spacing: -.04em; }
|
||||
.metric-value small { font-family: "Avenir Next", sans-serif; font-size: 13px; color: var(--muted); margin-left: 5px; }
|
||||
.chart-panel { margin-top: 16px; padding: 25px 25px 18px; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 22px; }
|
||||
h2 { margin: 4px 0 0; font-family: Georgia, serif; font-size: 27px; font-weight: 400; }
|
||||
.threshold { color: var(--muted); font-size: 12px; }
|
||||
.chart-wrap { height: 250px; position: relative; }
|
||||
.chart-plot { position: absolute; inset: 6px 0 38px 62px; cursor: crosshair; touch-action: pan-y; }
|
||||
svg { width: 100%; height: 100%; overflow: visible; }
|
||||
.chart-grid { stroke: rgba(143,161,154,.18); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.chart-area { fill: url(#chartFill); }
|
||||
.chart-line { fill: none; stroke: var(--active); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.run-band { fill: rgba(142,230,187,.17); stroke: rgba(142,230,187,.58); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.hover-line { stroke: rgba(231,238,232,.45); stroke-width: 1; stroke-dasharray: 4 4; vector-effect: non-scaling-stroke; pointer-events: none; }
|
||||
.hover-dot { fill: var(--paper); stroke: var(--active); stroke-width: 3; vector-effect: non-scaling-stroke; pointer-events: none; }
|
||||
.chart-empty { position: absolute; inset: 6px 0 38px 62px; display: grid; place-items: center; color: var(--muted); font-family: Georgia, serif; font-size: 18px; pointer-events: none; }
|
||||
.x-axis { position: absolute; left: 62px; right: 0; bottom: 0; height: 31px; color: var(--muted); font: 10px/1.2 "SFMono-Regular", Consolas, monospace; }
|
||||
.x-tick { position: absolute; top: 8px; transform: translateX(-50%); white-space: nowrap; text-align: center; }
|
||||
.x-tick::before { content: ""; position: absolute; left: 50%; top: -8px; width: 1px; height: 5px; background: var(--line); }
|
||||
.x-tick.first { transform: none; text-align: left; }
|
||||
.x-tick.first::before { left: 0; }
|
||||
.x-tick.last { transform: translateX(-100%); text-align: right; }
|
||||
.x-tick.last::before { left: 100%; }
|
||||
.y-axis { position: absolute; left: 0; top: 6px; bottom: 38px; width: 51px; color: var(--muted); font: 10px/1 "SFMono-Regular", Consolas, monospace; }
|
||||
.y-tick { position: absolute; right: 0; transform: translateY(-50%); font-variant-numeric: tabular-nums; }
|
||||
.chart-tooltip { display: none; position: absolute; z-index: 4; min-width: 190px; padding: 12px 13px; border: 1px solid rgba(142,230,187,.55); background: rgba(7,16,15,.96); box-shadow: 0 15px 35px rgba(0,0,0,.35); pointer-events: none; }
|
||||
.chart-tooltip.show { display: block; }
|
||||
.tooltip-time { margin-bottom: 9px; color: var(--active); font: 10px/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .04em; }
|
||||
.tooltip-row { display: flex; justify-content: space-between; gap: 22px; padding: 3px 0; color: var(--muted); font-size: 12px; }
|
||||
.tooltip-row strong { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.tooltip-run { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
|
||||
.tooltip-run strong { color: var(--active); }
|
||||
.table-panel { margin-top: 16px; }
|
||||
.table-head { padding: 25px; border-bottom: 1px solid var(--line); }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 660px; }
|
||||
th, td { padding: 16px 25px; text-align: left; border-bottom: 1px solid rgba(41,65,61,.72); }
|
||||
th { font-weight: 500; }
|
||||
td { font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.duration { font-family: Georgia, serif; font-size: 18px; }
|
||||
.flag { display: inline-block; padding: 4px 7px; margin-left: 8px; color: var(--warning); border: 1px solid rgba(240,189,105,.35); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.empty-row { color: var(--muted); text-align: center; padding: 36px; }
|
||||
.notice { display: none; margin-bottom: 16px; padding: 14px 16px; border: 1px solid rgba(240,189,105,.45); color: var(--warning); background: rgba(240,189,105,.06); font-size: 13px; }
|
||||
.notice.show { display: block; }
|
||||
footer { margin-top: 18px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; font-family: "SFMono-Regular", Consolas, monospace; }
|
||||
a { color: var(--active); text-decoration: none; }
|
||||
@media (max-width: 760px) {
|
||||
.shell { width: min(100% - 22px, 1180px); padding-top: 26px; }
|
||||
header, .hero { grid-template-columns: 1fr; }
|
||||
.connection { padding: 0; }
|
||||
.last-run { min-height: 280px; padding: 23px; }
|
||||
.run-facts { grid-template-columns: 1fr 1fr; }
|
||||
.run-fact { padding: 14px 12px 0 0; }
|
||||
.run-fact + .run-fact { padding-left: 12px; }
|
||||
.run-fact:nth-child(3) { padding-left: 0; border-left: 0; }
|
||||
.run-fact:nth-child(n+3) { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.metric { min-height: 132px; padding: 19px; }
|
||||
.metric-value { font-size: 31px; }
|
||||
.chart-panel, .table-head { padding: 20px; }
|
||||
.chart-plot { left: 51px; }
|
||||
.chart-empty { left: 51px; }
|
||||
.x-axis { left: 51px; }
|
||||
.y-axis { width: 41px; }
|
||||
th, td { padding: 14px 20px; }
|
||||
footer { flex-direction: column; gap: 7px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.panel { animation: settle .5s ease both; }
|
||||
.chart-panel { animation-delay: .08s; }
|
||||
.table-panel { animation-delay: .14s; }
|
||||
@keyframes settle { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">Domestic waterworks / live telemetry</div>
|
||||
<h1>Pump monitor</h1>
|
||||
</div>
|
||||
<div class="connection"><span id="dot" class="dot"></span><span id="connectionText">Connecting…</span></div>
|
||||
</header>
|
||||
|
||||
<div id="notice" class="notice"></div>
|
||||
|
||||
<section class="hero">
|
||||
<article class="panel last-run">
|
||||
<div class="metric-label">Last completed run</div>
|
||||
<div class="last-run-emphasis">
|
||||
<div id="lastRunDuration" class="last-run-value">—</div>
|
||||
<div class="last-run-unit">duration</div>
|
||||
</div>
|
||||
<div class="run-facts">
|
||||
<div class="run-fact"><span>Started</span><strong id="lastRunStarted">—</strong></div>
|
||||
<div class="run-fact"><span>Stopped</span><strong id="lastRunStopped">—</strong></div>
|
||||
<div class="run-fact"><span>Average</span><strong id="lastRunAverage">—</strong></div>
|
||||
<div class="run-fact"><span>Peak</span><strong id="lastRunPeak">—</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel metrics">
|
||||
<div class="metric"><div class="metric-label">Cycles today</div><div id="cycleCount" class="metric-value">—</div></div>
|
||||
<div class="metric"><div class="metric-label">Runtime today</div><div id="runtime" class="metric-value">—</div></div>
|
||||
<div class="metric"><div class="metric-label">Average cycle</div><div id="average" class="metric-value">—</div></div>
|
||||
<div class="metric"><div class="metric-label">Longest cycle</div><div id="longest" class="metric-value">—</div></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-head">
|
||||
<div><div class="section-kicker">Last 24 hours</div><h2>Power trace</h2></div>
|
||||
<div id="threshold" class="threshold"></div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div id="yAxis" class="y-axis" aria-hidden="true"></div>
|
||||
<div id="chartPlot" class="chart-plot">
|
||||
<svg id="chart" viewBox="0 0 1000 190" preserveAspectRatio="none" aria-label="Power draw timeline for the last 24 hours">
|
||||
<defs><linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8ee6bb" stop-opacity=".28"/><stop offset="1" stop-color="#8ee6bb" stop-opacity="0"/></linearGradient></defs>
|
||||
<g id="chartGrid"></g><g id="runBands"></g>
|
||||
<path id="chartArea" class="chart-area" d=""/><path id="chartLine" class="chart-line" d=""/>
|
||||
<line id="hoverX" class="hover-line" x1="0" y1="0" x2="0" y2="190" visibility="hidden"/>
|
||||
<line id="hoverY" class="hover-line" x1="0" y1="0" x2="1000" y2="0" visibility="hidden"/>
|
||||
<circle id="hoverDot" class="hover-dot" cx="0" cy="0" r="5" visibility="hidden"/>
|
||||
</svg>
|
||||
<div id="chartTooltip" class="chart-tooltip"></div>
|
||||
</div>
|
||||
<div id="xAxis" class="x-axis" aria-hidden="true"></div>
|
||||
<div id="chartEmpty" class="chart-empty">No readings yet</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<div class="section-head table-head">
|
||||
<div><div class="section-kicker">Recorded events</div><h2>Recent pump cycles</h2></div>
|
||||
<a href="/api/cycles.csv?days=30">Download CSV</a>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Started</th><th>Stopped</th><th>Duration</th><th>Average</th><th>Peak</th></tr></thead>
|
||||
<tbody id="cycles"><tr><td colspan="5" class="empty-row">No completed cycles yet</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<footer><span id="lastUpdate">Awaiting first reading</span><span>Times shown in your browser timezone</span></footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtTime = (iso) => iso ? new Intl.DateTimeFormat(undefined, {dateStyle:'medium', timeStyle:'medium'}).format(new Date(iso)) : '—';
|
||||
const fmtDuration = (seconds) => {
|
||||
seconds = Math.round(Number(seconds || 0));
|
||||
if (seconds < 60) return `${seconds} sec`;
|
||||
const mins = Math.floor(seconds / 60), secs = seconds % 60;
|
||||
if (mins < 60) return `${mins}m ${secs}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
};
|
||||
const fmtW = (value) => value == null ? '—' : `${Math.round(value)} W`;
|
||||
const fmtRunTime = (iso) => iso ? new Intl.DateTimeFormat(undefined, {
|
||||
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit'
|
||||
}).format(new Date(iso)) : '—';
|
||||
|
||||
function setNotice(message) {
|
||||
$('notice').textContent = message || '';
|
||||
$('notice').classList.toggle('show', Boolean(message));
|
||||
}
|
||||
|
||||
function renderStatus(data) {
|
||||
const status = data.monitor;
|
||||
const summary = data.today;
|
||||
const live = status.state === 'running' || status.state === 'idle';
|
||||
$('dot').className = `dot ${live ? 'live' : 'offline'}`;
|
||||
$('connectionText').textContent = live ? `Socket online · ${fmtTime(status.last_success)}` : 'Socket data unavailable';
|
||||
$('cycleCount').textContent = summary.cycle_count;
|
||||
$('runtime').textContent = fmtDuration(summary.runtime_seconds);
|
||||
$('average').textContent = fmtDuration(summary.average_duration_seconds);
|
||||
$('longest').textContent = fmtDuration(summary.longest_duration_seconds);
|
||||
$('threshold').textContent = `Start ≥ ${data.settings.start_watts} W · stop ≤ ${data.settings.stop_watts} W`;
|
||||
$('lastUpdate').textContent = status.last_success ? `Last reading ${fmtTime(status.last_success)}` : 'Awaiting first reading';
|
||||
|
||||
setNotice(status.error || (!data.security.dashboard_auth_enabled ? 'Dashboard authentication is disabled. Set DASHBOARD_PASSWORD before exposing this page publicly.' : ''));
|
||||
}
|
||||
|
||||
function renderLastRun(row) {
|
||||
if (!row) {
|
||||
$('lastRunDuration').textContent = '—';
|
||||
for (const id of ['lastRunStarted', 'lastRunStopped', 'lastRunAverage', 'lastRunPeak']) $(id).textContent = '—';
|
||||
return;
|
||||
}
|
||||
$('lastRunDuration').textContent = fmtDuration(row.duration_seconds);
|
||||
$('lastRunStarted').textContent = fmtRunTime(row.started_at);
|
||||
$('lastRunStopped').textContent = fmtRunTime(row.ended_at);
|
||||
$('lastRunAverage').textContent = fmtW(row.average_watts);
|
||||
$('lastRunPeak').textContent = fmtW(row.maximum_watts);
|
||||
}
|
||||
|
||||
function renderCycles(rows) {
|
||||
if (!rows.length) {
|
||||
$('cycles').innerHTML = '<tr><td colspan="5" class="empty-row">No completed cycles yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
$('cycles').innerHTML = rows.map(row => {
|
||||
const incomplete = row.incomplete_start || row.incomplete_end;
|
||||
return `<tr><td>${fmtTime(row.started_at)}${incomplete ? '<span class="flag">partial</span>' : ''}</td><td>${fmtTime(row.ended_at)}</td><td class="duration">${fmtDuration(row.duration_seconds)}</td><td>${fmtW(row.average_watts)}</td><td>${fmtW(row.maximum_watts)}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const CHART_WIDTH = 1000;
|
||||
const CHART_HEIGHT = 190;
|
||||
const CHART_TOP = 8;
|
||||
const CHART_BASE = 184;
|
||||
let chartModel = null;
|
||||
|
||||
function niceCeiling(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 100;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / magnitude;
|
||||
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
function renderAxes(minT, maxT, maxW) {
|
||||
const yTicks = 5;
|
||||
$('chartGrid').innerHTML = Array.from({length: yTicks}, (_, index) => {
|
||||
const y = CHART_TOP + ((CHART_BASE - CHART_TOP) * index / (yTicks - 1));
|
||||
return `<line class="chart-grid" x1="0" y1="${y}" x2="${CHART_WIDTH}" y2="${y}"/>`;
|
||||
}).join('');
|
||||
$('yAxis').innerHTML = Array.from({length: yTicks}, (_, index) => {
|
||||
const top = index * 100 / (yTicks - 1);
|
||||
const value = maxW * (1 - index / (yTicks - 1));
|
||||
return `<span class="y-tick" style="top:${top}%">${Math.round(value).toLocaleString()} W</span>`;
|
||||
}).join('');
|
||||
|
||||
const tickCount = $('chartPlot').clientWidth < 580 ? 3 : 5;
|
||||
const dayFormat = new Intl.DateTimeFormat(undefined, {day:'numeric', month:'short'});
|
||||
const timeFormat = new Intl.DateTimeFormat(undefined, {hour:'2-digit', minute:'2-digit'});
|
||||
$('xAxis').innerHTML = Array.from({length: tickCount}, (_, index) => {
|
||||
const ratio = index / (tickCount - 1);
|
||||
const date = new Date(minT + (maxT - minT) * ratio);
|
||||
const edgeClass = index === 0 ? ' first' : index === tickCount - 1 ? ' last' : '';
|
||||
const label = index === 0 || index === tickCount - 1 ? `${dayFormat.format(date)} · ${timeFormat.format(date)}` : timeFormat.format(date);
|
||||
return `<span class="x-tick${edgeClass}" style="left:${ratio * 100}%">${label}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderChart(history) {
|
||||
const points = history.samples || [];
|
||||
const cycles = history.cycles || [];
|
||||
const minT = new Date(history.window_start).getTime();
|
||||
const maxT = new Date(history.window_end).getTime();
|
||||
const empty = points.length < 2;
|
||||
$('chartEmpty').style.display = empty ? 'grid' : 'none';
|
||||
const times = points.map(p => new Date(p.sampled_at).getTime());
|
||||
const watts = points.map(p => Number(p.watts));
|
||||
const cyclePeaks = cycles.map(cycle => Number(cycle.maximum_watts || 0));
|
||||
const maxW = niceCeiling(Math.max(100, ...watts, ...cyclePeaks) * 1.04);
|
||||
renderAxes(minT, maxT, maxW);
|
||||
|
||||
chartModel = {points, cycles, minT, maxT, maxW};
|
||||
$('runBands').innerHTML = cycles.map(cycle => {
|
||||
const start = Math.max(minT, new Date(cycle.started_at).getTime());
|
||||
const end = Math.min(maxT, cycle.ended_at ? new Date(cycle.ended_at).getTime() : maxT);
|
||||
let x = ((start - minT) / (maxT - minT)) * CHART_WIDTH;
|
||||
let width = Math.max(3, ((end - start) / (maxT - minT)) * CHART_WIDTH);
|
||||
x = Math.max(0, Math.min(CHART_WIDTH - width, x - Math.max(0, (width - ((end - start) / (maxT - minT)) * CHART_WIDTH) / 2)));
|
||||
return `<rect class="run-band" x="${x.toFixed(2)}" y="${CHART_TOP}" width="${width.toFixed(2)}" height="${CHART_BASE - CHART_TOP}"/>`;
|
||||
}).join('');
|
||||
|
||||
if (empty) {
|
||||
$('chartLine').setAttribute('d', '');
|
||||
$('chartArea').setAttribute('d', '');
|
||||
return;
|
||||
}
|
||||
const coords = points.map((p, i) => {
|
||||
const x = ((times[i] - minT) / Math.max(1, maxT - minT)) * CHART_WIDTH;
|
||||
const y = CHART_BASE - (watts[i] / maxW) * (CHART_BASE - CHART_TOP);
|
||||
return [x, y];
|
||||
});
|
||||
const line = coords.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ');
|
||||
$('chartLine').setAttribute('d', line);
|
||||
$('chartArea').setAttribute('d', `${line} L${coords.at(-1)[0].toFixed(2)},${CHART_BASE} L${coords[0][0].toFixed(2)},${CHART_BASE} Z`);
|
||||
}
|
||||
|
||||
function nearestSample(points, target) {
|
||||
let low = 0, high = points.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (new Date(points[middle].sampled_at).getTime() < target) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const current = points[low];
|
||||
const previous = points[Math.max(0, low - 1)];
|
||||
return Math.abs(new Date(previous.sampled_at) - target) <= Math.abs(new Date(current.sampled_at) - target) ? previous : current;
|
||||
}
|
||||
|
||||
function nearestCycle(cycles, target, tolerance) {
|
||||
const containing = cycles.find(cycle => {
|
||||
const start = new Date(cycle.started_at).getTime();
|
||||
const end = cycle.ended_at ? new Date(cycle.ended_at).getTime() : Date.now();
|
||||
return target >= start && target <= end;
|
||||
});
|
||||
if (containing) return containing;
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const cycle of cycles) {
|
||||
const start = new Date(cycle.started_at).getTime();
|
||||
const end = cycle.ended_at ? new Date(cycle.ended_at).getTime() : Date.now();
|
||||
const distance = Math.abs((start + end) / 2 - target);
|
||||
if (distance < bestDistance) { best = cycle; bestDistance = distance; }
|
||||
}
|
||||
return bestDistance <= tolerance ? best : null;
|
||||
}
|
||||
|
||||
function showChartTooltip(event) {
|
||||
if (!chartModel || !chartModel.points.length) return;
|
||||
const plot = $('chartPlot');
|
||||
const rect = plot.getBoundingClientRect();
|
||||
const pointerX = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
|
||||
let target = chartModel.minT + (pointerX / rect.width) * (chartModel.maxT - chartModel.minT);
|
||||
const tolerance = (8 / rect.width) * (chartModel.maxT - chartModel.minT);
|
||||
const cycle = nearestCycle(chartModel.cycles, target, tolerance);
|
||||
if (cycle && !(target >= new Date(cycle.started_at).getTime() && target <= new Date(cycle.ended_at || Date.now()).getTime())) {
|
||||
const cycleEnd = new Date(cycle.ended_at || Date.now()).getTime();
|
||||
target = (new Date(cycle.started_at).getTime() + cycleEnd) / 2;
|
||||
}
|
||||
const sample = nearestSample(chartModel.points, target);
|
||||
const sampleTime = new Date(sample.sampled_at).getTime();
|
||||
const x = ((sampleTime - chartModel.minT) / (chartModel.maxT - chartModel.minT)) * CHART_WIDTH;
|
||||
const y = CHART_BASE - (Number(sample.watts) / chartModel.maxW) * (CHART_BASE - CHART_TOP);
|
||||
for (const id of ['hoverX', 'hoverY', 'hoverDot']) $(id).setAttribute('visibility', 'visible');
|
||||
$('hoverX').setAttribute('x1', x); $('hoverX').setAttribute('x2', x);
|
||||
$('hoverY').setAttribute('y1', y); $('hoverY').setAttribute('y2', y);
|
||||
$('hoverDot').setAttribute('cx', x); $('hoverDot').setAttribute('cy', y);
|
||||
|
||||
const duration = cycle ? Number(cycle.duration_seconds || ((new Date(cycle.ended_at || Date.now()) - new Date(cycle.started_at)) / 1000)) : null;
|
||||
const partial = cycle && (cycle.incomplete_start || cycle.incomplete_end) ? ' · partial' : '';
|
||||
$('chartTooltip').innerHTML = `<div class="tooltip-time">${fmtTime(sample.sampled_at)}</div><div class="tooltip-row"><span>Power draw</span><strong>${fmtW(sample.watts)}</strong></div><div class="tooltip-row tooltip-run"><span>${cycle ? 'Run duration' : 'Pump state'}</span><strong>${cycle ? `${fmtDuration(duration)}${partial}` : 'Idle'}</strong></div>`;
|
||||
$('chartTooltip').classList.add('show');
|
||||
const tooltipWidth = $('chartTooltip').offsetWidth;
|
||||
const tooltipHeight = $('chartTooltip').offsetHeight;
|
||||
const localX = (x / CHART_WIDTH) * rect.width;
|
||||
const localY = (y / CHART_HEIGHT) * rect.height;
|
||||
$('chartTooltip').style.left = `${Math.max(6, Math.min(rect.width - tooltipWidth - 6, localX + 12))}px`;
|
||||
$('chartTooltip').style.top = `${Math.max(6, Math.min(rect.height - tooltipHeight - 6, localY - tooltipHeight / 2))}px`;
|
||||
}
|
||||
|
||||
function hideChartTooltip() {
|
||||
$('chartTooltip').classList.remove('show');
|
||||
for (const id of ['hoverX', 'hoverY', 'hoverDot']) $(id).setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
$('chartPlot').addEventListener('pointermove', showChartTooltip);
|
||||
$('chartPlot').addEventListener('pointerdown', showChartTooltip);
|
||||
$('chartPlot').addEventListener('pointerleave', hideChartTooltip);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [statusResponse, cyclesResponse, historyResponse] = await Promise.all([
|
||||
fetch('/api/status'), fetch('/api/cycles?limit=50'), fetch('/api/history?hours=24')
|
||||
]);
|
||||
if (!statusResponse.ok) throw new Error(`Dashboard request failed (${statusResponse.status})`);
|
||||
const statusData = await statusResponse.json();
|
||||
const cycleRows = (await cyclesResponse.json()).cycles;
|
||||
renderStatus(statusData);
|
||||
renderLastRun(cycleRows[0]);
|
||||
renderCycles(cycleRows);
|
||||
renderChart(await historyResponse.json());
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
$('dot').className = 'dot offline';
|
||||
$('connectionText').textContent = 'Dashboard connection lost';
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
window.addEventListener('resize', () => chartModel && renderChart({
|
||||
samples: chartModel.points,
|
||||
cycles: chartModel.cycles,
|
||||
window_start: new Date(chartModel.minT).toISOString(),
|
||||
window_end: new Date(chartModel.maxT).toISOString()
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
Reference in New Issue
Block a user