From 1b56e2cc4242d35d3e99f861927f564339432569 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 23 Dec 2025 06:46:12 +0100 Subject: [PATCH] u --- public/dashboard.js | 257 ----------------------------------- public/index.html | 36 ----- public/style.css | 202 --------------------------- server.js | 184 ++++++++++++++++++++++--- src/client/ControllerCard.js | 11 +- src/client/Dashboard.js | 55 +++++++- src/client/EnvChart.js | 55 ++++++-- src/client/LevelChart.js | 42 +++++- src/client/OutputChart.js | 121 ++++++++--------- src/client/index.html | 1 + verify_api.js | 108 +++++++++++++++ webpack.config.js | 2 +- 12 files changed, 475 insertions(+), 599 deletions(-) delete mode 100644 public/dashboard.js delete mode 100644 public/index.html delete mode 100644 public/style.css create mode 100644 verify_api.js diff --git a/public/dashboard.js b/public/dashboard.js deleted file mode 100644 index cb13bd9..0000000 --- a/public/dashboard.js +++ /dev/null @@ -1,257 +0,0 @@ -const scriptPath = document.currentScript ? document.currentScript.src : window.location.href; -const API_BASE = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1) + 'api/'; - -// Store device info globally to simplify reload -let filteredDevices = []; -// Grouped: { "ControllerName": [ {dev info...}, {dev info...} ] } -let groupedDevices = {}; -let currentRange = 'day'; -let chartInstances = {}; - -async function init() { - setupControls(); - await loadDevices(); - - // Auto-refresh data every 60 seconds - setInterval(loadData, 60000); -} - -function setupControls() { - // Range Buttons - document.querySelectorAll('.range-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); - e.target.classList.add('active'); - currentRange = e.target.dataset.range; - loadData(); - }); - }); -} - -/** - * Fetch and setup device containers (Grouped by Controller) - */ -async function loadDevices() { - try { - const res = await fetch(`${API_BASE}devices`); - const rawDevices = await res.json(); - const container = document.getElementById('devicesContainer'); - container.innerHTML = ''; - - if (rawDevices.length === 0) { - container.innerHTML = '

No devices found.

'; - return; - } - - // Group by Controller Name - groupedDevices = rawDevices.reduce((acc, dev) => { - if (!acc[dev.dev_name]) acc[dev.dev_name] = []; - acc[dev.dev_name].push(dev); - return acc; - }, {}); - - // Create Section per Controller - for (const [controllerName, ports] of Object.entries(groupedDevices)) { - const safeControllerName = controllerName.replace(/\s+/g, '_'); - - const section = document.createElement('div'); - section.className = 'controller-section'; - - // Generate Ports HTML - let portsHtml = ''; - ports.forEach(port => { - const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_'); - - portsHtml += ` -
-

${port.port_name || 'Port ' + port.port}

-
- -
-
- `; - }); - - section.innerHTML = ` -
-

${controllerName}

-
- -
-
-

Environment (Temp / Humidity)

-
- -
-
-
- -
- ${portsHtml} -
- `; - container.appendChild(section); - } - - // Trigger initial data load - loadData(); - - } catch (err) { - console.error("Failed to load devices", err); - } -} - -/** - * Fetch data and render - */ -async function loadData() { - for (const [controllerName, ports] of Object.entries(groupedDevices)) { - const safeControllerName = controllerName.replace(/\s+/g, '_'); - - // 1. Fetch Environment Data (Use first port as representative source) - if (ports.length > 0) { - const firstPort = ports[0]; - try { - const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${firstPort.port}&range=${currentRange}`); - const data = await res.json(); - renderEnvChart(safeControllerName, data); - } catch (err) { - console.error(`Failed to load env data for ${controllerName}`, err); - } - } - - // 2. Fetch Level Data for EACH port - for (const port of ports) { - const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_'); - try { - const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${currentRange}`); - const data = await res.json(); - - const isLight = port.port_name && port.port_name.toLowerCase().includes('light'); - renderLevelChart(safePortId, data, isLight); - } catch (err) { - console.error(`Failed to load level data for ${controllerName}:${port.port}`, err); - } - } - } -} - -function formatDateLabel(timestamp) { - const date = new Date(timestamp); - if (currentRange === 'day') { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else { - return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } -} - -function renderEnvChart(safeName, data) { - const labels = data.map(d => formatDateLabel(d.timestamp)); - const ctx = document.getElementById(`env_${safeName}`).getContext('2d'); - - updateChart(`env_${safeName}`, ctx, labels, [ - { - label: 'Temperature (°C)', - data: data.map(d => d.temp_c), - borderColor: '#ff6384', - yAxisID: 'y' - }, - { - label: 'Humidity (%)', - data: data.map(d => d.humidity), - borderColor: '#36a2eb', - yAxisID: 'y1' - } - ], { - scales: { - y: { - type: 'linear', - display: true, - position: 'left', - suggestedMin: 15, - title: { display: true, text: 'Temp (°C)' } - }, - y1: { - type: 'linear', - display: true, - position: 'right', - grid: { drawOnChartArea: false }, - suggestedMin: 30, - suggestedMax: 80, - title: { display: true, text: 'Humidity (%)' } - } - } - }); -} - -function renderLevelChart(safeId, data, isLight) { - const labels = data.map(d => formatDateLabel(d.timestamp)); - const ctx = document.getElementById(`level_${safeId}`).getContext('2d'); - const levelLabel = isLight ? 'Brightness' : 'Fan Speed'; - const levelColor = isLight ? '#ffcd56' : '#9966ff'; - - updateChart(`level_${safeId}`, ctx, labels, [{ - label: levelLabel, - data: data.map(d => d.fan_speed), - borderColor: levelColor, - backgroundColor: levelColor, - stepped: true - }], { - scales: { - y: { - suggestedMin: 0, - suggestedMax: 10, - ticks: { stepSize: 1 } - } - } - }); -} - -function updateChart(id, ctx, labels, datasets, extraOptions = {}) { - if (chartInstances[id]) { - chartInstances[id].destroy(); - } - - const defaultOptions = { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - legend: { position: 'top' } - }, - scales: { - x: { - ticks: { - maxRotation: 0, - autoSkip: true, - maxTicksLimit: 12 - } - } - } - }; - - // Merge scales specifically - const mergedScales = { ...defaultOptions.scales, ...(extraOptions.scales || {}) }; - - // Merge options - const options = { - ...defaultOptions, - ...extraOptions, - scales: mergedScales - }; - - chartInstances[id] = new Chart(ctx, { - type: 'line', - data: { - labels: labels, - datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 })) - }, - options: options - }); -} - -document.addEventListener('DOMContentLoaded', init); diff --git a/public/index.html b/public/index.html deleted file mode 100644 index e210b85..0000000 --- a/public/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - AC Inf - - - - - -
-
-

AC Inf

-
- -
- - -
- - - -
-
- -
- -
-
- - - - - \ No newline at end of file diff --git a/public/style.css b/public/style.css deleted file mode 100644 index 117cf92..0000000 --- a/public/style.css +++ /dev/null @@ -1,202 +0,0 @@ -:root { - --bg-color: #f4f4f9; - --card-bg: #ffffff; - --text-color: #333; - --accent-color: #2c974b; - --border-color: #ddd; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background-color: var(--bg-color); - color: var(--text-color); - margin: 0; - padding: 20px; -} - -.container { - max-width: 1200px; - margin: 0 auto; -} - -header { - text-align: center; - margin-bottom: 30px; -} - -.controls { - display: flex; - justify-content: space-between; - align-items: center; - background: var(--card-bg); - padding: 15px 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; -} - -select { - padding: 8px 12px; - font-size: 16px; - border: 1px solid var(--border-color); - border-radius: 4px; - min-width: 200px; -} - -.time-range { - display: flex; - gap: 10px; -} - -.range-btn { - padding: 8px 16px; - border: 1px solid var(--accent-color); - background: transparent; - color: var(--accent-color); - border-radius: 4px; - cursor: pointer; - font-weight: 500; - transition: all 0.2s; -} - -.range-btn.active, -.range-btn:hover { - background: var(--accent-color); - color: white; -} - -.charts-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); - gap: 20px; -} - -.chart-wrapper { - background: var(--card-bg); - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -h3 { - margin-top: 0; - margin-bottom: 15px; - font-size: 1.1em; - color: #666; -} - -canvas { - width: 100% !important; - height: 300px !important; -} - -/* Multi-Device Layout */ -.device-section { - margin-bottom: 40px; -} - -.device-header { - background: #e9ecef; - padding: 10px 15px; - border-radius: 6px; - margin-bottom: 20px; - border-left: 5px solid #2c3e50; -} - -.charts-row { - display: flex; - flex-wrap: wrap; - gap: 20px; -} - -.chart-wrapper { - flex: 1; - min-width: 400px; - /* Ensure 2 columns on wide screens */ - background: white; - padding: 15px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.canvas-container { - position: relative; - height: 300px; - width: 100%; -} - -.device-divider { - border: 0; - height: 1px; - background: #ddd; - margin: 40px 0; -} - -/* Range Buttons Active State */ -.range-btn.active { - background-color: #2c3e50; - color: white; -} - -/* Grouped Controller Layout */ -.controller-section { - background: #fff; - border-radius: 8px; - padding: 20px; - margin-bottom: 40px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); -} - -.controller-header { - border-bottom: 2px solid #f0f0f0; - margin-bottom: 20px; - padding-bottom: 10px; -} - -.controller-header h2 { - margin: 0; - color: #2c3e50; -} - -/* Environment Chart (Full Width) */ -.environment-row { - margin-bottom: 30px; -} - -.chart-wrapper.wide { - width: 100%; - min-width: unset; - box-shadow: none; - /* Inside controller card */ - padding: 0; -} - -/* Ports Grid */ -.ports-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); - gap: 20px; -} - -.port-card { - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; - padding: 15px; -} - -.port-card h4 { - margin-top: 0; - margin-bottom: 10px; - font-size: 1.1em; - color: #495057; -} - -.canvas-container.small { - height: 200px; -} - -/* Override chart height for small containers */ -.port-card canvas { - height: 200px !important; -} \ No newline at end of file diff --git a/server.js b/server.js index 1baae3d..5901c5b 100644 --- a/server.js +++ b/server.js @@ -486,8 +486,15 @@ function evaluateRules(readings) { // Let's insert every poll to have history graph? Or just changes? // Graphs need continuous data. Let's insert every poll for now (small scale). - db.prepare('INSERT INTO output_log (dev_name, port, state, level, rule_id, rule_name) VALUES (?, ?, ?, ?, ?, ?)') - .run(val.devName || 'Unknown', val.port || 0, val.state, val.level, val.ruleId, val.ruleName); + // Log only if changed + let shouldLog = false; + if (!prev) shouldLog = true; + else if (prev.state !== val.state || (val.state === 1 && prev.level !== val.level)) shouldLog = true; + + if (shouldLog) { + db.prepare('INSERT INTO output_log (dev_name, port, state, level, rule_id, rule_name) VALUES (?, ?, ?, ?, ?, ?)') + .run(val.devName || 'Unknown', val.port || 0, val.state, val.level, val.ruleId, val.ruleName); + } // Detect Change for Alarms if (prev) { @@ -1193,42 +1200,187 @@ app.get('/api/devices', (req, res) => { // API: History app.get('/api/history', (req, res) => { try { - const { devName, port, range } = req.query; + const { devName, port, range, offset = 0 } = req.query; if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' }); - let timeFilter; + const off = parseInt(offset, 10) || 0; + let bucketSize; // seconds + switch (range) { - case 'week': timeFilter = "-7 days"; break; - case 'month': timeFilter = "-30 days"; break; - case 'day': default: timeFilter = "-24 hours"; break; + case 'week': + bucketSize = 15 * 60; + break; + case 'month': + bucketSize = 60 * 60; + break; + case 'day': default: + bucketSize = 3 * 60; + break; } + // Calculate time modifiers using offset in seconds + let durationSec; + if (range === 'week') durationSec = 7 * 24 * 3600; + else if (range === 'month') durationSec = 30 * 24 * 3600; + else durationSec = 24 * 3600; // day + + const endOffsetSec = off * durationSec; + const startOffsetSec = (off + 1) * durationSec; + + const endMod = `-${endOffsetSec} seconds`; + const startMod = `-${startOffsetSec} seconds`; + + // Select raw data + // Select raw data const stmt = db.prepare(` - SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed + SELECT strftime('%s', timestamp) as ts, temp_c, humidity, fan_speed FROM readings - WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?) + WHERE dev_name = ? AND port = ? + AND timestamp >= datetime('now', ?) + AND timestamp < datetime('now', ?) ORDER BY timestamp ASC `); - const rows = stmt.all(devName, parseInt(port, 10), timeFilter); - res.json(rows); + const rows = stmt.all(devName, parseInt(port, 10), startMod, endMod); + + if (rows.length === 0) return res.json({ start: 0, step: bucketSize, temps: [], hums: [], levels: [] }); + + // Aggregate into buckets + const startTs = parseInt(rows[0].ts); + // Align start to bucket + const roundedStart = Math.floor(startTs / bucketSize) * bucketSize; + + const buckets = new Map(); + + rows.forEach(r => { + const ts = parseInt(r.ts); + const bucketKey = Math.floor(ts / bucketSize) * bucketSize; + + if (!buckets.has(bucketKey)) { + buckets.set(bucketKey, { count: 0, tempSum: 0, humSum: 0, levelSum: 0 }); + } + + const b = buckets.get(bucketKey); + b.count++; + if (r.temp_c !== null) b.tempSum += r.temp_c; + if (r.humidity !== null) b.humSum += r.humidity; + if (r.fan_speed !== null) b.levelSum += r.fan_speed; + }); + + const temps = []; + const hums = []; + const levels = []; + + // Fill gaps if strictly needed? + // For dense array, we need continuous steps from Start. + // Let's find max TS to know length. + const lastRow = rows[rows.length - 1]; + const endTs = parseInt(lastRow.ts); + const roundedEnd = Math.floor(endTs / bucketSize) * bucketSize; + + const numBuckets = (roundedEnd - roundedStart) / bucketSize + 1; + + for (let i = 0; i < numBuckets; i++) { + const currentTs = roundedStart + (i * bucketSize); + const b = buckets.get(currentTs); + + if (b && b.count > 0) { + // Formatting to 1 decimal place + temps.push(parseFloat((b.tempSum / b.count).toFixed(1))); + hums.push(parseFloat((b.humSum / b.count).toFixed(1))); + // Level: round to nearest int? or keep decimal? User said "averaged values". + // Usually levels are int, but average might be 5.5. Let's keep 1 decimal for smoothness or round? + // Charts handle decimals. + levels.push(parseFloat((b.levelSum / b.count).toFixed(1))); + } else { + // Gap -> null + temps.push(null); + hums.push(null); + levels.push(null); + } + } + + res.json({ + start: roundedStart, + step: bucketSize, + temps, + hums, + levels + }); + } catch (error) { console.error(error); res.status(500).json({ error: error.message }); } }); -// API: Output History (New) +// API: Output History (Compressed) app.get('/api/outputs/history', (req, res) => { try { + const { range, offset = 0 } = req.query; + const off = parseInt(offset, 10) || 0; + + // Calculate duration in seconds + let durationSec; + if (range === 'week') durationSec = 7 * 24 * 3600; + else if (range === 'month') durationSec = 30 * 24 * 3600; + else durationSec = 24 * 3600; // day + + const endOffsetSec = off * durationSec; + const startOffsetSec = (off + 1) * durationSec; + + const endMod = `-${endOffsetSec} seconds`; + const startMod = `-${startOffsetSec} seconds`; + const stmt = db.prepare(` - SELECT * FROM output_log - WHERE timestamp > datetime('now', '-24 hours') + SELECT timestamp, dev_name, port, state, level + FROM output_log + WHERE timestamp >= datetime('now', ?) + AND timestamp < datetime('now', ?) ORDER BY timestamp ASC `); - const rows = stmt.all(); - res.json(rows); + + const rows = stmt.all(startMod, endMod); + + // Compress: Group by "Dev:Port" -> [[ts, state, level], ...] + const compressed = {}; + + rows.forEach(r => { + const key = `${r.dev_name}:${r.port}`; + if (!compressed[key]) compressed[key] = []; + + // Convert timestamp to epoch ms for client (saves parsing there) or seconds? + // Client uses `new Date(ts).getTime()`. Let's give them epoch milliseconds to be consistent with other efficient APIs. + // SQLite 'timestamp' is string "YYYY-MM-DD HH:MM:SS". + // We can use strftime('%s', timestamp) * 1000 in SQL or parse here. + // Let's parse here to be safe with timezones if needed, effectively assuming UTC/server time. + // Actually, querying SQL for epoch is faster. + // Let's treat the existing ROWs which are strings. + const ts = new Date(r.timestamp + 'Z').getTime(); // Append Z to force UTC if missing + + const lvl = r.level === null ? 0 : r.level; + + // Check last entry for this key + const lastEntry = compressed[key][compressed[key].length - 1]; + + if (!lastEntry) { + // First entry, always add + compressed[key].push([ts, r.state, lvl]); + } else { + // Compare with last entry: [ts, state, level] + const lastState = lastEntry[1]; + const lastLvl = lastEntry[2]; + + if (r.state != lastState || lvl != lastLvl) { + // State changed, add new point + compressed[key].push([ts, r.state, lvl]); + } + } + }); + + res.json(compressed); } catch (error) { + console.error(error); res.status(500).json({ error: error.message }); } }); diff --git a/src/client/ControllerCard.js b/src/client/ControllerCard.js index e836686..fbd5bef 100644 --- a/src/client/ControllerCard.js +++ b/src/client/ControllerCard.js @@ -21,7 +21,8 @@ class ControllerCard extends Component { componentDidUpdate(prevProps) { if (prevProps.controllerName !== this.props.controllerName || - prevProps.range !== this.props.range) { + prevProps.range !== this.props.range || + prevProps.offset !== this.props.offset) { this.fetchData(); } } @@ -33,13 +34,13 @@ class ControllerCard extends Component { } fetchData = async () => { - const { controllerName, ports, range } = this.props; + const { controllerName, ports, range, offset } = this.props; try { if (ports.length === 0) return; // Fetch all ports concurrently const promises = ports.map(port => - fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}`) + fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}&offset=${offset || 0}`) .then(res => res.json()) .then(data => ({ port: port.port, data })) ); @@ -81,7 +82,7 @@ class ControllerCard extends Component { {t('controller.environment')} - + @@ -101,7 +102,7 @@ class ControllerCard extends Component { {port.port_name || `${t('controller.port')} ${port.port}`} - + diff --git a/src/client/Dashboard.js b/src/client/Dashboard.js index 934cfa4..a6c6ca1 100644 --- a/src/client/Dashboard.js +++ b/src/client/Dashboard.js @@ -9,25 +9,62 @@ class Dashboard extends Component { constructor(props) { super(props); this.state = { - range: 'day' // 'day', 'week', 'month' + range: 'day', // 'day', 'week', 'month' + offset: 0 }; } setRange = (range) => { - this.setState({ range }); + this.setState({ range, offset: 0 }); + }; + + setOffset = (offset) => { + if (offset < 0) offset = 0; + this.setState({ offset }); }; render() { const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props; - const { range } = this.state; + const { range, offset } = this.state; + + const nowMs = Date.now(); + const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600)); + const endMs = nowMs - (offset * durationSec * 1000); + const startMs = endMs - (durationSec * 1000); + + const dateOpts = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }; + const dateRangeLabel = `${new Date(startMs).toLocaleString([], dateOpts)} - ${new Date(endMs).toLocaleString([], dateOpts)}`; if (loading) return {t('dashboard.loading')}; if (error) return {error}; return ( - + + + {dateRangeLabel} + + + @@ -55,10 +99,11 @@ class Dashboard extends Component { controllerName={controllerName} ports={ports} range={range} + offset={this.state.offset} /> ))} - + ); } diff --git a/src/client/EnvChart.js b/src/client/EnvChart.js index 49bf783..b616aa4 100644 --- a/src/client/EnvChart.js +++ b/src/client/EnvChart.js @@ -22,7 +22,7 @@ ChartJS.register( ); class EnvChart extends Component { - formatDateLabel = (timestamp) => { + formatTime = (timestamp) => { const { range } = this.props; const date = new Date(timestamp); if (range === 'day') { @@ -36,35 +36,58 @@ class EnvChart extends Component { render() { const { data } = this.props; - if (!data || data.length === 0) return null; + if (!data || !data.temps || data.temps.length === 0) return null; + + const { start, step, temps, hums } = data; + + // Generate points: x (timestamp ms), y (value) + // start is in seconds, step in seconds. + // We can just map the index. + const startTimeMs = start * 1000; + const stepMs = step * 1000; + + const tempPoints = temps.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val })); + const humPoints = hums.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val })); + + // Filter out nulls if charts line breaks are desired on gaps + // Chart.js handles nulls by breaking the line, which is usually desired. const chartData = { - labels: data.map(d => this.formatDateLabel(d.timestamp)), datasets: [ { label: 'Temperature (°C)', - data: data.map(d => d.temp_c), + data: tempPoints, borderColor: '#ff6384', backgroundColor: '#ff6384', yAxisID: 'y', tension: 0.4, pointRadius: 0, - borderWidth: 2 + borderWidth: 2, + spanGaps: true // or false if we want breaks }, { label: 'Humidity (%)', - data: data.map(d => d.humidity), + data: humPoints, borderColor: '#36a2eb', backgroundColor: '#36a2eb', yAxisID: 'y1', tension: 0.4, pointRadius: 0, - borderWidth: 2 + borderWidth: 2, + spanGaps: true }, ], }; + const nowMs = Date.now(); + const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600)); + + // Use offset to calculate exact window + const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000); + const startMs = endMs - (durationSec * 1000); + const options = { + animation: false, responsive: true, maintainAspectRatio: false, interaction: { @@ -73,10 +96,14 @@ class EnvChart extends Component { }, scales: { x: { + type: 'linear', + min: startMs, + max: endMs, ticks: { maxRotation: 0, autoSkip: true, - maxTicksLimit: 12 + maxTicksLimit: 12, + callback: (value) => this.formatTime(value) } }, y: { @@ -96,6 +123,18 @@ class EnvChart extends Component { suggestedMax: 80, }, }, + plugins: { + tooltip: { + callbacks: { + title: (context) => { + if (context.length > 0) { + return this.formatTime(context[0].parsed.x); + } + return ''; + } + } + } + } }; return ; diff --git a/src/client/LevelChart.js b/src/client/LevelChart.js index bdfa97e..22e1b91 100644 --- a/src/client/LevelChart.js +++ b/src/client/LevelChart.js @@ -22,7 +22,7 @@ ChartJS.register( ); class LevelChart extends Component { - formatDateLabel = (timestamp) => { + formatTime = (timestamp) => { const { range } = this.props; const date = new Date(timestamp); if (range === 'day') { @@ -36,24 +36,30 @@ class LevelChart extends Component { render() { const { data, isLight, isCO2 } = this.props; - if (!data || data.length === 0) return null; + if (!data || !data.levels || data.levels.length === 0) return null; + + const { start, step, levels } = data; + const startTimeMs = start * 1000; + const stepMs = step * 1000; + + const points = levels.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val })); // Determine label and color based on sensor type const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed'); const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff'); const chartData = { - labels: data.map(d => this.formatDateLabel(d.timestamp)), datasets: [ { label: levelLabel, - data: data.map(d => d.fan_speed), + data: points, borderColor: levelColor, backgroundColor: levelColor, stepped: !isCO2, // CO2 uses smooth lines tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others borderWidth: 2, - pointRadius: 0 + pointRadius: 0, + spanGaps: true }, ], }; @@ -63,7 +69,15 @@ class LevelChart extends Component { ? { suggestedMin: 200, suggestedMax: 900 } : { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } }; + const nowMs = Date.now(); + const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600)); + + // Use offset to calculate exact window + const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000); + const startMs = endMs - (durationSec * 1000); + const options = { + animation: false, responsive: true, maintainAspectRatio: false, interaction: { @@ -72,14 +86,30 @@ class LevelChart extends Component { }, scales: { x: { + type: 'linear', + min: startMs, + max: endMs, ticks: { maxRotation: 0, autoSkip: true, - maxTicksLimit: 8 + maxTicksLimit: 8, + callback: (value) => this.formatTime(value) } }, y: yScale }, + plugins: { + tooltip: { + callbacks: { + title: (context) => { + if (context.length > 0) { + return this.formatTime(context[0].parsed.x); + } + return ''; + } + } + } + } }; return ; diff --git a/src/client/OutputChart.js b/src/client/OutputChart.js index 3036deb..32fff95 100644 --- a/src/client/OutputChart.js +++ b/src/client/OutputChart.js @@ -40,7 +40,7 @@ class OutputChart extends Component { } componentDidUpdate(prevProps) { - if (prevProps.range !== this.props.range) { + if (prevProps.range !== this.props.range || prevProps.offset !== this.props.offset) { this.fetchData(); } } @@ -52,9 +52,9 @@ class OutputChart extends Component { } fetchData = async () => { - const { range } = this.props; + const { range, offset } = this.props; try { - const res = await fetch(`api/outputs/history?range=${range || 'day'}`); + const res = await fetch(`api/outputs/history?range=${range || 'day'}&offset=${offset || 0}`); const logs = await res.json(); this.setState({ data: logs, loading: false }); } catch (err) { @@ -63,32 +63,45 @@ class OutputChart extends Component { } }; + formatTime = (timestamp) => { + const { range } = this.props; + const date = new Date(timestamp); + if (range === 'day') { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' + + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + }; + render() { - const { devices = [] } = this.props; + const { devices = [], range } = this.props; const { data, loading } = this.state; if (loading) return ; if (!data || data.length === 0) return null; - // Group data by "Device:Port" - const groupedData = {}; - data.forEach(log => { - const key = `${log.dev_name}:${log.port}`; - if (!groupedData[key]) groupedData[key] = []; - groupedData[key].push(log); - }); + // Data is now a dictionary: { "Dev:Port": [ [ts, state, level], ... ] } + const groupedData = data; // Gruvbox Palette const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374']; + const nowMs = Date.now(); + const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600)); + + const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000); + const startMs = endMs - (durationSec * 1000); + // Generate datasets const datasets = Object.keys(groupedData).map((key, index) => { const logs = groupedData[key]; const color = gruvboxColors[index % gruvboxColors.length]; + const offset = index * 0.15; // Visual offset to prevent overlap // Resolve Label let label = key; - const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check + const isTapo = key.includes('tapo') || key.includes('Plug'); if (devices && devices.length > 0) { const [dName, pNum] = key.split(':'); @@ -99,68 +112,41 @@ class OutputChart extends Component { } } - // Fallback friendly name for Tapo if not found in devices list (which comes from readings) - // Check if key looks like "tapo-xxx:0" if (label === key && key.startsWith('tapo-')) { - // "tapo-001:0" -> "Tapo Plug 001" const parts2 = key.split(':'); const tapoId = parts2[0].replace('tapo-', ''); label = `Tapo Plug ${tapoId}`; } + // Map [ts, state, level] -> {x, y} + const points = logs.map(d => ({ + x: d[0], // Already epoch ms + y: (d[1] === 0 ? 0 : (d[2] || 10)) + offset + })); + + // Extend line to end of window (or "now") + // If offset is 0, endMs is now. If offset > 0, endMs is historical. + // But visually we want the line to extend to the right edge of the chart. + if (points.length > 0) { + const lastPoint = points[points.length - 1]; + points.push({ + x: endMs, // Extend to end of current view window + y: lastPoint.y + }); + } + return { label: label, - data: logs.map(d => ({ - x: new Date(d.timestamp).getTime(), - y: d.state === 0 ? 0 : (d.level || 10) - })), + data: points, borderColor: color, backgroundColor: color, - stepped: true, + stepped: true, // Crucial for sparse data pointRadius: 0, borderWidth: 2, - // Custom property to identify binary devices in tooltip isBinary: isTapo }; }); - // Create a time axis based on the data range - const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort(); - - // We need to normalize data to these timestamps for "Line" chart - const chartLabels = allTimestamps.map(ts => { - const date = new Date(ts); - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }); - - // We need to map *each* dataset's data to align with `chartLabels`. - const alignedDatasets = datasets.map((ds, index) => { - const alignedData = []; - let lastValue = 0; // Default off - const offset = index * 0.15; // Small offset to avoid overlap - - // Populate alignedData matching `allTimestamps` - allTimestamps.forEach(ts => { - // Find if we have a log at this specific timestamp - const timeMs = new Date(ts).getTime(); - const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance - - if (exactLog) { - lastValue = exactLog.y; - } - // Apply offset to the value for visualization - // If value is 0 (OFF), we might still want to offset it? - // Or only if ON? The user said "levels are on top of each other". - // Even OFF lines might overlap. Let's offset everything. - alignedData.push(lastValue + offset); - }); - - return { - ...ds, - data: alignedData - }; - }); - const options = { responsive: true, maintainAspectRatio: false, @@ -170,16 +156,20 @@ class OutputChart extends Component { }, scales: { x: { + type: 'linear', + min: startMs, + max: endMs, ticks: { maxRotation: 0, autoSkip: true, - maxTicksLimit: 12 + maxTicksLimit: 12, + callback: (value) => this.formatTime(value) } }, y: { type: 'linear', min: 0, - max: 12, // Increased max to accommodate offsets + max: 12, ticks: { stepSize: 1, callback: (val) => { @@ -203,13 +193,18 @@ class OutputChart extends Component { }, tooltip: { callbacks: { + title: (context) => { + if (context.length > 0) { + return this.formatTime(context[0].parsed.x); + } + return ''; + }, label: (context) => { // Round down to ignore the offset - const val = Math.floor(context.raw); + const val = Math.floor(context.raw.y || context.raw); const ds = context.dataset; if (val === 0) return `${ds.label}: OFF`; - // If it's binary (Tapo) or max level, show ON if (val === 10 || ds.isBinary) return `${ds.label}: ON`; return `${ds.label}: Level ${val}`; @@ -222,7 +217,7 @@ class OutputChart extends Component { return ( - + ); diff --git a/src/client/index.html b/src/client/index.html index fe6f62b..6a3c843 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,6 +5,7 @@ Tischlerei Dashboard + diff --git a/verify_api.js b/verify_api.js new file mode 100644 index 0000000..0d3f462 --- /dev/null +++ b/verify_api.js @@ -0,0 +1,108 @@ +import http from 'http'; + +const OPTIONS = { + hostname: '127.0.0.1', + port: 3905, + path: '/api/history?devName=Test&port=1&range=day', + method: 'GET' +}; + +const req = http.request(OPTIONS, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + console.log('Status Code:', res.statusCode); + try { + const json = JSON.parse(data); + console.log('Response Keys:', Object.keys(json)); + if (json.start !== undefined && Array.isArray(json.temps)) { + console.log('SUCCESS: API returned compressed structure.'); + console.log('Step Size:', json.step); + console.log('Temps Length:', json.temps.length); + } else { + console.log('FAILURE: API returned unexpected structure.', json); + } + } catch (e) { + console.error('Failed to parse JSON:', e); + console.log('Raw Data:', data); + } + }); +}); + +req.on('error', (e) => { + console.error('Request error (server might not be running):', e.message); +}); + +req.end(); + +const OUTPUT_OPTIONS = { + hostname: '127.0.0.1', + port: 3905, + path: '/api/outputs/history?range=day', + method: 'GET' +}; + +const reqOut = http.request(OUTPUT_OPTIONS, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + console.log('Output History Status Code:', res.statusCode); + try { + const json = JSON.parse(data); + const keys = Object.keys(json); + console.log('Output Keys:', keys); + if (keys.length > 0) { + const firstKey = keys[0]; + const entries = json[firstKey]; + if (Array.isArray(entries) && entries.length > 0 && Array.isArray(entries[0])) { + console.log('SUCCESS: Output API returned compressed dictionary.'); + console.log('Sample Entry:', entries[0]); + console.log(`Entries count for ${firstKey}: ${entries.length}`); + } else { + console.log('FAILURE: Output API format incorrect.', entries[0]); + } + } else { + console.log('WARNING: Output API returned empty object (no history).'); + } + } catch (e) { + console.error('Failed to parse Output JSON:', e); + } + }); +}); + +reqOut.on('error', (e) => { + console.error('Output Request error:', e.message); +}); + +reqOut.end(); + +// Test offset +const OFFSET_OPTIONS = { + hostname: '127.0.0.1', + port: 3905, + path: '/api/history?devName=Tent&port=1&range=day&offset=1', + method: 'GET' +}; + +const reqOffset = http.request(OFFSET_OPTIONS, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + console.log('Offset History Status Code:', res.statusCode); + try { + const json = JSON.parse(data); + if (json.start && json.start > 0) { + const startDate = new Date(json.start * 1000); + console.log(`Offset 1 Start Time: ${json.start} (${startDate.toISOString()})`); + // Check if it's roughly 48h ago (since offset 1 day means window start is -48h) + // vs offset 0 start is -24h. + const now = Date.now() / 1000; + const hoursDiff = (now - json.start) / 3600; + console.log(`Hours difference from now: ${hoursDiff.toFixed(1)}h (Should be ~48h)`); + } + } catch (e) { console.error(e); } + }); +}); +reqOffset.end(); diff --git a/webpack.config.js b/webpack.config.js index 09e3593..c34d754 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,7 +11,7 @@ export default { output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.[contenthash].js', - publicPath: '/ac/', + publicPath: '/', clean: true // Clean dist folder on rebuild }, module: {