This commit is contained in:
2026-10-03 20:24:37 +02:00
parent d603d8d574
commit 7094d05030
8 changed files with 581 additions and 135 deletions
+13 -19
View File
@@ -3,27 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - Sensor Readouts</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; line-height: 1.5; }
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
h2 { margin-top: 2rem; color: #333; }
.endpoint { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 1rem 1.25rem; margin: 1rem 0; }
.method { display: inline-block; font-weight: bold; padding: 0.15rem 0.5rem; border-radius: 3px; color: #fff; margin-right: 0.5rem; font-size: 0.85rem; }
.get { background: #2563eb; }
.post { background: #16a34a; }
.path { font-family: monospace; font-size: 1rem; }
.desc { margin: 0.5rem 0; }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; font-size: 0.9rem; }
th { background: #f0f0f0; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 0.75rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; }
code { font-family: monospace; }
.note { background: #fef9c3; border-left: 3px solid #eab308; padding: 0.5rem 0.75rem; margin: 1rem 0; font-size: 0.9rem; }
.section-label { font-size: 0.8rem; color: #888; margin: 0.75rem 0 0.25rem; text-transform: uppercase; letter-spacing: 0.05em; }
</style>
<title>API Documentation - Yggdrasil</title>
<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') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('logout') }}">Log out</a>
</nav>
<h1>Sensor Readouts &mdash; API Documentation</h1>
<h2>Base URL</h2>
@@ -127,5 +117,9 @@ curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"</code></pre
<div class="note">
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/api$</span>
</footer>
</body>
</html>
+37 -11
View File
@@ -1,22 +1,48 @@
<!DOCTYPE html>
<html lang="pl">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Strona domowa</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
nav a { margin-right: 1rem; }
</style>
<title>Yggdrasil — Home</title>
<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') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Dom</a>
<a href="{{ url_for('sensors_page') }}">Sensory</a>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Wyloguj</a>
<a href="{{ url_for('logout') }}">Log out</a>
</nav>
<h1>Witaj, {{ user.username }}!</h1>
<p>Jesteś zalogowany{% if user.is_admin %} jako administrator{% endif %}.</p>
<h1>Yggdrasil</h1>
<div class="panel">
<p>Welcome, <span class="hl">{{ user.username }}</span>! You are logged in{% if user.is_admin %} as <b class="hl">administrator</b>{% endif %}.</p>
<p>Select an item from the menu:</p>
<ul class="mc-menu">
<li><a href="{{ url_for('sensors_page') }}"><span class="mc-key">1</span>Sensors — charts and readouts</a></li>
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">2</span>API — documentation</a></li>
<li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
</ul>
<p class="hint">Tip: press <b>1</b>, <b>2</b> or <b>0</b> to navigate.</p>
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~$</span>
</footer>
<script>
// Numbered menu hotkeys, Midnight Commander style.
// Ignored while typing in a form field.
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
const targets = {
'1': "{{ url_for('sensors_page') }}",
'2': "{{ url_for('api_docs') }}",
'0': "{{ url_for('logout') }}"
};
if (targets[e.key]) window.location.href = targets[e.key];
});
</script>
</body>
</html>
+73 -26
View File
@@ -1,34 +1,81 @@
<!DOCTYPE html>
<html lang="pl">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logowanie</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 4rem auto; padding: 0 1rem; }
form { display: flex; flex-direction: column; gap: 0.75rem; }
label { font-weight: bold; }
input { padding: 0.5rem; font-size: 1rem; }
button { padding: 0.6rem; font-size: 1rem; cursor: pointer; }
.error { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 4px; }
</style>
<title>Login — Yggdrasil</title>
<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') }}">
</head>
<body>
<h1>Logowanie</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('login', next=next) if next else url_for('login') }}">
<input type="hidden" name="next" value="{{ next }}">
<div>
<label for="username">Użytkownik:</label><br>
<input type="text" id="username" name="username" required autocomplete="username" style="width: 100%; box-sizing: border-box;">
</div>
<div>
<label for="password">Hasło:</label><br>
<input type="password" id="password" name="password" required autocomplete="current-password" style="width: 100%; box-sizing: border-box;">
</div>
<button type="submit">Zaloguj</button>
</form>
<div class="dialog">
<div class="dialog-title">[ Login ]</div>
<h1>Yggdrasil</h1>
{% if error %}
<p class="error">! {{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('login', next=next) if next else url_for('login') }}">
<input type="hidden" name="next" value="{{ next }}">
<div>
<label for="username">Username:</label><br>
<div class="term-field">
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
<span class="caret-block" aria-hidden="true"></span>
</div>
</div>
<div>
<label for="password">Password:</label><br>
<div class="term-field">
<input type="password" id="password" name="password" required autocomplete="current-password">
<span class="caret-block" aria-hidden="true"></span>
</div>
</div>
<button type="submit">[ OK ] Log in</button>
</form>
</div>
<script>
// Block caret, Midnight Commander style.
// The native caret is hidden (only when this script runs) and a
// blinking block is drawn exactly where the text cursor is.
// Measurement is exact because the font is monospace.
// Without JS the fields keep their normal native caret.
function attachBlockCaret(input) {
const field = input.closest('.term-field');
const block = field.querySelector('.caret-block');
input.classList.add('js-caret');
const ctx = document.createElement('canvas').getContext('2d');
function place() {
const cs = getComputedStyle(input);
ctx.font = cs.font;
const pos = (input.selectionStart !== null && input.selectionStart !== undefined)
? input.selectionStart
: input.value.length;
let text = input.value.slice(0, pos);
if (input.type === 'password') text = '\u2022'.repeat(text.length);
const padLeft = parseFloat(cs.paddingLeft) || 0;
const borderLeft = parseFloat(cs.borderLeftWidth) || 0;
const x = borderLeft + padLeft + ctx.measureText(text).width - (input.scrollLeft || 0);
block.style.left = Math.max(borderLeft + padLeft, x) + 'px';
}
function show() {
field.classList.add('active');
requestAnimationFrame(place);
}
function hide() { field.classList.remove('active'); }
['input', 'change', 'keyup', 'click', 'select', 'focus'].forEach((ev) => {
input.addEventListener(ev, show);
});
input.addEventListener('blur', hide);
window.addEventListener('load', () => setTimeout(place, 300)); // late autofill
if (document.fonts && document.fonts.ready) document.fonts.ready.then(place);
if (document.activeElement === input) show();
}
document.querySelectorAll('.term-field input').forEach(attachBlockCaret);
</script>
</body>
</html>
+118 -48
View File
@@ -3,24 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sensor Readouts</title>
<title>Sensor Readouts — Yggdrasil</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
select, table { width: 100%; margin-top: 0.5rem; }
select { padding: 0.4rem; }
table { border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #f5f5f5; }
label { font-weight: bold; }
.hidden { display: none; }
.chart-box { margin-top: 1rem; }
</style>
<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') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Dom</a>
<a href="{{ url_for('logout') }}">Wyloguj</a>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
</nav>
<h1>Sensor Readouts</h1>
@@ -38,8 +31,20 @@
</select>
</div>
<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>
<div id="chart-section" class="hidden chart-box">
<canvas id="chart"></canvas>
<p id="chart-error" class="error hidden"></p>
</div>
<div id="readouts-section" class="hidden">
@@ -54,15 +59,32 @@
</table>
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/sensors$</span>
</footer>
<script>
const sensorSelect = document.getElementById('sensor-select');
const typeSelect = document.getElementById('type-select');
const typeSection = document.getElementById('type-section');
const rangeSelect = document.getElementById('range-select');
const rangeSection = document.getElementById('range-section');
const chartSection = document.getElementById('chart-section');
const chartError = document.getElementById('chart-error');
const refreshButton = document.getElementById('refresh-button');
const readoutsSection = document.getElementById('readouts-section');
const readoutsBody = document.getElementById('readouts-body');
let chart = null;
// 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;
}
function formatTimestamp(iso) {
const d = new Date(iso);
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw' });
@@ -91,6 +113,7 @@
sensorSelect.addEventListener('change', async () => {
const sensorId = sensorSelect.value;
typeSection.classList.add('hidden');
rangeSection.classList.add('hidden');
chartSection.classList.add('hidden');
readoutsSection.classList.add('hidden');
typeSelect.innerHTML = '<option value="">-- select type --</option>';
@@ -106,48 +129,71 @@
typeSelect.appendChild(opt);
});
typeSection.classList.remove('hidden');
rangeSection.classList.remove('hidden');
});
typeSelect.addEventListener('change', async () => {
async function loadChart() {
const sensorId = sensorSelect.value;
const type = typeSelect.value;
const range = rangeSelect.value;
chartSection.classList.add('hidden');
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;
readoutsSection.classList.add('hidden');
readoutsBody.innerHTML = '';
if (!type) return;
// fetch hourly data for chart
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}`);
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: '#2563eb',
backgroundColor: 'rgba(37,99,235,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
}]
},
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');
if (!sensorId || !type) return;
// fetch raw readouts for table
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
@@ -157,8 +203,32 @@
readoutsBody.appendChild(tr);
});
readoutsSection.classList.remove('hidden');
}
typeSelect.addEventListener('change', async () => {
if (!typeSelect.value) {
chartSection.classList.add('hidden');
readoutsSection.classList.add('hidden');
readoutsBody.innerHTML = '';
return;
}
await loadChart();
await loadTable();
});
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);
loadSensors();
</script>
</body>