This commit is contained in:
sebseb7
2025-12-21 01:46:12 +01:00
commit 2baa1af2e8
23 changed files with 9874 additions and 0 deletions

257
public/dashboard.js Normal file
View File

@@ -0,0 +1,257 @@
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 = '<p>No devices found.</p>';
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 += `
<div class="port-card">
<h4>${port.port_name || 'Port ' + port.port}</h4>
<div class="canvas-container small">
<canvas id="level_${safePortId}"></canvas>
</div>
</div>
`;
});
section.innerHTML = `
<div class="controller-header">
<h2>${controllerName}</h2>
</div>
<div class="environment-row">
<div class="chart-wrapper wide">
<h3>Environment (Temp / Humidity)</h3>
<div class="canvas-container">
<canvas id="env_${safeControllerName}"></canvas>
</div>
</div>
</div>
<div class="ports-grid">
${portsHtml}
</div>
`;
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);

36
public/index.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AC Inf</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>AC Inf</h1>
</header>
<div class="controls">
<!-- Device Select Removed -->
<div class="time-range">
<button class="range-btn active" data-range="day">24 Hours</button>
<button class="range-btn" data-range="week">7 Days</button>
<button class="range-btn" data-range="month">30 Days</button>
</div>
</div>
<div id="devicesContainer" class="devices-container">
<!-- Dynamic Content Will Be Loaded Here -->
</div>
</div>
<script src="dashboard.js"></script>
</body>
</html>

202
public/style.css Normal file
View File

@@ -0,0 +1,202 @@
: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;
}