2026-09-13 18:14:19 +02:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-10-03 20:24:37 +02:00
|
|
|
<title>Sensor Readouts — Yggdrasil</title>
|
2026-09-13 18:14:19 +02:00
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
2026-10-03 20:24:37 +02:00
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
|
|
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
2026-09-13 18:14:19 +02:00
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-10-03 19:31:38 +02:00
|
|
|
<nav>
|
2026-10-03 20:24:37 +02:00
|
|
|
<a href="{{ url_for('home') }}">Home</a>
|
|
|
|
|
<a href="{{ url_for('api_docs') }}">API</a>
|
|
|
|
|
<a href="{{ url_for('logout') }}">Log out</a>
|
2026-10-03 19:31:38 +02:00
|
|
|
</nav>
|
2026-09-13 18:14:19 +02:00
|
|
|
<h1>Sensor Readouts</h1>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label for="sensor-select">Sensor:</label>
|
|
|
|
|
<select id="sensor-select">
|
|
|
|
|
<option value="">-- select sensor --</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="type-section" class="hidden">
|
|
|
|
|
<label for="type-select">Type:</label>
|
|
|
|
|
<select id="type-select">
|
|
|
|
|
<option value="">-- select type --</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
<div id="range-section" class="hidden">
|
|
|
|
|
<label for="range-select">Range:</label>
|
|
|
|
|
<select id="range-select">
|
|
|
|
|
<option value="today">Today</option>
|
|
|
|
|
<option value="week">Week</option>
|
|
|
|
|
<option value="month">Month</option>
|
|
|
|
|
<option value="all" selected>All</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button id="refresh-button" type="button">[ Refresh ]</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
<div id="chart-section" class="hidden chart-box">
|
|
|
|
|
<canvas id="chart"></canvas>
|
2026-10-03 20:24:37 +02:00
|
|
|
<p id="chart-error" class="error hidden"></p>
|
2026-09-13 18:14:19 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="readouts-section" class="hidden">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Timestamp</th>
|
|
|
|
|
<th>Value</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="readouts-body"></tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
<footer class="statusbar">
|
|
|
|
|
<span>{{ user.username }}@yggdrasil:~/sensors$</span>
|
|
|
|
|
</footer>
|
|
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
<script>
|
|
|
|
|
const sensorSelect = document.getElementById('sensor-select');
|
|
|
|
|
const typeSelect = document.getElementById('type-select');
|
|
|
|
|
const typeSection = document.getElementById('type-section');
|
2026-10-03 20:24:37 +02:00
|
|
|
const rangeSelect = document.getElementById('range-select');
|
|
|
|
|
const rangeSection = document.getElementById('range-section');
|
2026-09-13 18:14:19 +02:00
|
|
|
const chartSection = document.getElementById('chart-section');
|
2026-10-03 20:24:37 +02:00
|
|
|
const chartError = document.getElementById('chart-error');
|
|
|
|
|
const refreshButton = document.getElementById('refresh-button');
|
2026-09-13 18:14:19 +02:00
|
|
|
const readoutsSection = document.getElementById('readouts-section');
|
|
|
|
|
const readoutsBody = document.getElementById('readouts-body');
|
|
|
|
|
let chart = null;
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
// The page must keep working (dropdown + table) even if the
|
|
|
|
|
// Chart.js CDN is unreachable — the chart is then simply skipped.
|
|
|
|
|
if (window.Chart) {
|
|
|
|
|
Chart.defaults.color = '#d7d7d7';
|
|
|
|
|
Chart.defaults.borderColor = 'rgba(215,215,215,0.18)';
|
|
|
|
|
Chart.defaults.font.family = "'VT323', 'Courier New', monospace";
|
|
|
|
|
Chart.defaults.font.size = 20;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
function formatTimestamp(iso) {
|
|
|
|
|
const d = new Date(iso);
|
2026-09-20 18:24:32 +02:00
|
|
|
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatChartLabel(iso) {
|
|
|
|
|
const d = new Date(iso + 'Z');
|
|
|
|
|
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw', hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit', year: 'numeric' });
|
2026-09-13 18:14:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchJSON(url) {
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadSensors() {
|
|
|
|
|
const sensors = await fetchJSON('/api/sensors');
|
|
|
|
|
sensors.forEach(s => {
|
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
opt.value = s.id;
|
|
|
|
|
opt.textContent = s.name;
|
|
|
|
|
sensorSelect.appendChild(opt);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sensorSelect.addEventListener('change', async () => {
|
|
|
|
|
const sensorId = sensorSelect.value;
|
|
|
|
|
typeSection.classList.add('hidden');
|
2026-10-03 20:24:37 +02:00
|
|
|
rangeSection.classList.add('hidden');
|
2026-09-13 18:14:19 +02:00
|
|
|
chartSection.classList.add('hidden');
|
|
|
|
|
readoutsSection.classList.add('hidden');
|
|
|
|
|
typeSelect.innerHTML = '<option value="">-- select type --</option>';
|
|
|
|
|
readoutsBody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
if (!sensorId) return;
|
|
|
|
|
|
|
|
|
|
const types = await fetchJSON(`/api/sensors/${sensorId}/types`);
|
|
|
|
|
types.forEach(t => {
|
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
opt.value = t;
|
|
|
|
|
opt.textContent = t;
|
|
|
|
|
typeSelect.appendChild(opt);
|
|
|
|
|
});
|
|
|
|
|
typeSection.classList.remove('hidden');
|
2026-10-03 20:24:37 +02:00
|
|
|
rangeSection.classList.remove('hidden');
|
2026-09-13 18:14:19 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
async function loadChart() {
|
2026-09-13 18:14:19 +02:00
|
|
|
const sensorId = sensorSelect.value;
|
|
|
|
|
const type = typeSelect.value;
|
2026-10-03 20:24:37 +02:00
|
|
|
const range = rangeSelect.value;
|
2026-09-13 18:14:19 +02:00
|
|
|
chartSection.classList.add('hidden');
|
2026-10-03 20:24:37 +02:00
|
|
|
chartError.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
if (!sensorId || !type || !window.Chart) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// fetch hourly data for chart
|
|
|
|
|
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}&range=${range}`);
|
|
|
|
|
if (!hourly.length) throw new Error('no data for this range');
|
|
|
|
|
|
|
|
|
|
const labels = hourly.map(r => formatChartLabel(r.timestamp));
|
|
|
|
|
const values = hourly.map(r => r.value);
|
|
|
|
|
|
|
|
|
|
if (chart) chart.destroy();
|
|
|
|
|
const ctx = document.getElementById('chart').getContext('2d');
|
|
|
|
|
chart = new Chart(ctx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: {
|
|
|
|
|
labels: labels,
|
|
|
|
|
datasets: [{
|
|
|
|
|
label: type,
|
|
|
|
|
data: values,
|
|
|
|
|
borderColor: '#55ffff',
|
|
|
|
|
backgroundColor: 'rgba(85,255,255,0.12)',
|
|
|
|
|
pointBackgroundColor: '#ffff55',
|
|
|
|
|
pointBorderColor: '#55ffff',
|
|
|
|
|
fill: true,
|
|
|
|
|
tension: 0.3,
|
|
|
|
|
pointRadius: 3,
|
|
|
|
|
}]
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
plugins: { legend: { display: false } },
|
|
|
|
|
scales: {
|
|
|
|
|
x: { title: { display: true, text: 'Time (hourly avg)' } },
|
|
|
|
|
y: { title: { display: true, text: type } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
chartSection.classList.remove('hidden');
|
|
|
|
|
// The chart was created while the container was hidden,
|
|
|
|
|
// so force a re-measure now that it is visible.
|
|
|
|
|
chart.resize();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
chartError.textContent = '! Chart error: ' + err.message;
|
|
|
|
|
chartError.classList.remove('hidden');
|
|
|
|
|
chartSection.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadTable() {
|
|
|
|
|
const sensorId = sensorSelect.value;
|
|
|
|
|
const type = typeSelect.value;
|
2026-09-13 18:14:19 +02:00
|
|
|
readoutsSection.classList.add('hidden');
|
|
|
|
|
readoutsBody.innerHTML = '';
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
if (!sensorId || !type) return;
|
2026-09-13 18:14:19 +02:00
|
|
|
|
|
|
|
|
// fetch raw readouts for table
|
|
|
|
|
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
|
|
|
|
|
readouts.forEach(r => {
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
tr.innerHTML = `<td>${formatTimestamp(r.timestamp)}</td><td>${r.value}</td>`;
|
|
|
|
|
readoutsBody.appendChild(tr);
|
|
|
|
|
});
|
|
|
|
|
readoutsSection.classList.remove('hidden');
|
2026-10-03 20:24:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
typeSelect.addEventListener('change', async () => {
|
|
|
|
|
if (!typeSelect.value) {
|
|
|
|
|
chartSection.classList.add('hidden');
|
|
|
|
|
readoutsSection.classList.add('hidden');
|
|
|
|
|
readoutsBody.innerHTML = '';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await loadChart();
|
|
|
|
|
await loadTable();
|
2026-09-13 18:14:19 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-03 20:24:37 +02:00
|
|
|
refreshButton.addEventListener('click', async () => {
|
|
|
|
|
if (!sensorSelect.value || !typeSelect.value || refreshButton.disabled) return;
|
|
|
|
|
refreshButton.disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
await loadChart();
|
|
|
|
|
await loadTable();
|
|
|
|
|
} finally {
|
|
|
|
|
refreshButton.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
rangeSelect.addEventListener('change', loadChart);
|
|
|
|
|
|
2026-09-13 18:14:19 +02:00
|
|
|
loadSensors();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|