bojler-esp/res/index.html
2026-09-07 15:33:11 +02:00

492 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="cs" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bojler</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Definice barev: zelená -> žlutá -> červená */
:root {
--color-low: #198754;
/* Zelená pod t0 */
--color-ok: #ffc107;
/* Žlutá v rozmezí t0-t1 */
--color-high: #dc3545;
/* Červená nad t1 */
}
[data-bs-theme="dark"] {
--color-low: #2ecc71;
/* Jemnější sytá zelená */
--color-ok: #f1c40f;
/* Výrazná žlutá */
--color-high: #e74c3c;
/* Sytá červená */
}
.temp-low {
color: var(--color-low) !important;
}
.temp-ok {
color: var(--color-ok) !important;
}
.temp-high {
color: var(--color-high) !important;
}
.temp-display {
font-size: 4rem;
font-weight: 700;
transition: color 0.3s ease;
}
.card {
border-radius: 15px;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] body {
background-color: #121212;
}
[data-bs-theme="dark"] .card {
background-color: #1e1e1e;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
}
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: #2b2b2b;
border-color: #3a3a3a;
color: #ffffff;
}
#notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
display: none;
}
/* Styl pro log událostí */
#event-log-container {
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
}
#event-log-container::-webkit-scrollbar {
width: 8px;
}
#event-log-container::-webkit-scrollbar-track {
background: transparent;
}
#event-log-container::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.3);
border-radius: 4px;
}
.valve-warning {
color: var(--color-ok);
font-size: 80%;
font-weight: normal;
}
</style>
</head>
<body>
<div id="notification" class="alert rounded-pill px-4 shadow"></div>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0 fw-bold">🌡️ bojler</h2>
<button id="theme-toggle" class="btn btn-outline-light rounded-pill">
☀️ Světlý
</button>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100 text-center p-4">
<h5 class="opacity-75 mb-2">Aktuální teplota</h5>
<div id="current-temp" class="temp-display text-secondary">-- °C</div>
<hr class="my-4 border-secondary opacity-25">
<h5 class="opacity-75 mb-3">Stav ventilu</h5>
<h3 id="valve-state" class="fw-bold">❓ Neznámý</h3>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 p-4">
<h5 class="opacity-75 mb-4">⚙️ Nastavení</h5>
<form id="settings-form">
<div class="mb-3">
<label for="t0" class="form-label fw-bold">Spodní mez (t0) [°C]</label>
<input type="number" step="0.1" class="form-control form-control-lg" id="t0" required>
</div>
<div class="mb-4">
<label for="t1" class="form-label fw-bold">Horní mez (t1) [°C]</label>
<input type="number" step="0.1" class="form-control form-control-lg" id="t1" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 fw-bold" id="save-btn">💾 Uložit
hodnoty</button>
</form>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap">
<h5 class="opacity-75 mb-0">📈 Historie teploty</h5>
<select id="history-range" class="form-select w-auto mt-2 mt-sm-0 shadow-sm">
<option value="24" selected>1 den (24h)</option>
<option value="48">2 dny (48h)</option>
<option value="72">3 dny (72h)</option>
<option value="96">4 dny (96h)</option>
<option value="120">5 dní (120h)</option>
</select>
</div>
<div style="height: 400px; width: 100%;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="opacity-75 mb-0">📋 Záznam událostí</h5>
<button id="refresh-log-btn" class="btn btn-sm btn-outline-secondary rounded-circle"
title="Aktualizovat události"
style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
🔄
</button>
</div>
<div id="event-log-container" class="pe-2">
<div class="text-center text-secondary py-3">Načítám události...</div>
</div>
</div>
</div>
</div>
</div>
<script>
let formInitialized = false;
let chartInstance = null;
let currentChartData = { data: [], hours: 24 }; // Uchováváme i informaci o vybraném čase
// --- Správa motivu (Dark/Light Mode) ---
const themeToggleBtn = document.getElementById('theme-toggle');
let currentTheme = localStorage.getItem('theme') || 'dark';
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
currentTheme = theme;
if (theme === 'dark') {
themeToggleBtn.innerHTML = '☀️ Světlý';
themeToggleBtn.classList.replace('btn-outline-secondary', 'btn-outline-light');
} else {
themeToggleBtn.innerHTML = '🌙 Tmavý';
themeToggleBtn.classList.replace('btn-outline-light', 'btn-outline-secondary');
}
if (currentChartData.data.length > 0) {
updateChart(currentChartData.data, currentChartData.hours);
}
}
themeToggleBtn.addEventListener('click', () => {
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
applyTheme(currentTheme);
// ---------------------------------------
function showNotification(message, isError = false) {
const notif = document.getElementById('notification');
notif.className = `alert rounded-pill px-4 shadow-lg ${isError ? 'alert-danger' : 'alert-success'}`;
notif.textContent = message;
notif.style.display = 'block';
setTimeout(() => notif.style.display = 'none', 3000);
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('API error');
const data = await res.json();
// Aktualizace UI - Teplota a barvy (<t0: zelená, t0-t1: žlutá, >t1: červená)
const tempEl = document.getElementById('current-temp');
tempEl.textContent = `${data.temp.toFixed(1)} °C`;
tempEl.classList.remove('temp-low', 'temp-ok', 'temp-high', 'text-secondary');
if (data.temp < data.t0) {
tempEl.classList.add('temp-low');
} else if (data.temp > data.t1) {
tempEl.classList.add('temp-high');
} else {
tempEl.classList.add('temp-ok');
}
// Aktualizace UI - Ventil + Varovný symbol
const valveEl = document.getElementById('valve-state');
const isOpen = (data.valve === 1 || data.valve === true);
let warningHtml = "";
if (!isOpen && data.temp > data.t1) {
warningHtml = ' <span class="valve-warning">⚠️ Otevřete ventil</span>';
} else if (isOpen && data.temp < data.t0) {
warningHtml = ' <span class="valve-warning">⚠️ Zavřete ventil</span>';
}
if (isOpen) {
valveEl.innerHTML = `🟢 <span class="text-success">Otevřen</span>${warningHtml}`;
} else {
valveEl.innerHTML = `🔴 <span class="text-danger">Zavřen</span>${warningHtml}`;
}
if (!formInitialized) {
document.getElementById('t0').value = data.t0;
document.getElementById('t1').value = data.t1;
formInitialized = true;
}
} catch (err) {
document.getElementById('current-temp').textContent = '-- °C';
document.getElementById('current-temp').className = 'temp-display text-secondary';
document.getElementById('valve-state').innerHTML = '❓ <span class="opacity-50">Nedostupné</span>';
}
}
async function fetchHistory(hours) {
try {
const res = await fetch(`/api/history?hours=${hours}`);
if (!res.ok) throw new Error('API error');
let data = await res.json();
// 15minutový interval = 4 vzorky za hodinu.
// Pokud API vrátí více dat než požadujeme, ořízneme pole zprava (nejnovější data).
const maxPoints = hours * 4;
if (data.length > maxPoints) {
data = data.slice(-maxPoints);
}
currentChartData = { data: data, hours: Number(hours) };
updateChart(data);
} catch (err) {
console.error("Nepodařilo se načíst historii:", err);
currentChartData = { data: [], hours: Number(hours) };
updateChart([]);
}
}
function updateChart(data) {
const ctx = document.getElementById('historyChart').getContext('2d');
const textColor = currentTheme === 'dark' ? '#adb5bd' : '#495057';
const gridColor = currentTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)';
const lineColor = currentTheme === 'dark' ? '#00d4ff' : '#0d6efd';
const fillColor = currentTheme === 'dark' ? 'rgba(0, 212, 255, 0.15)' : 'rgba(13, 110, 253, 0.1)';
Chart.defaults.color = textColor;
// Výpočet pro absolutní čas a vylepšený relativní čas
const now = new Date();
const labels = [];
const relativeTimes = [];
data.forEach((_, index) => {
const minsAgo = (data.length - 1 - index) * 15;
const d = new Date(now.getTime() - minsAgo * 60000);
const day = d.getDate();
const month = d.getMonth() + 1; // getMonth je 0-indexované
const hours = d.getHours();
const minutes = d.getMinutes().toString().padStart(2, '0');
labels.push(`${day}. ${month}. ${hours}:${minutes}`);
if (minsAgo === 0) {
relativeTimes.push('nyní');
} else {
const dTime = Math.floor(minsAgo / (60 * 24));
const hTime = Math.floor((minsAgo % (60 * 24)) / 60);
const mTime = minsAgo % 60;
let parts = [];
if (dTime > 0) parts.push(`${dTime} d`);
if (hTime > 0) parts.push(`${hTime} h`);
if (mTime > 0) parts.push(`${mTime} min`);
relativeTimes.push('před ' + parts.join(' '));
}
});
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Teplota (°C)',
data: data,
borderColor: lineColor,
backgroundColor: fillColor,
borderWidth: 2,
fill: true,
pointRadius: 0,
pointHitRadius: 15,
tension: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: 'Čas', color: textColor },
ticks: {
maxTicksLimit: 8, // Omezí počet štítků na ose, aby se nepřekrývaly
maxRotation: 45,
color: textColor
},
grid: { display: false }
},
y: {
title: { display: true, text: 'Teplota (°C)', color: textColor },
ticks: { color: textColor },
grid: { color: gridColor, drawBorder: false },
min: 0,
max: 100
}
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: currentTheme === 'dark' ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)',
titleColor: currentTheme === 'dark' ? '#fff' : '#000',
bodyColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: gridColor,
borderWidth: 1,
callbacks: {
title: function (context) {
const index = context[0].dataIndex;
return `${labels[index]} (${relativeTimes[index]})`;
}
}
}
}
}
});
}
// --- Záznam událostí (Log) ---
async function fetchLog() {
const container = document.getElementById('event-log-container');
try {
const res = await fetch('/api/log');
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (!data || data.length === 0) {
container.innerHTML = '<div class="text-center text-secondary py-3">Zatím nejsou k dispozici žádné události.</div>';
return;
}
// Seřazení od nejnovější (pokud už není ze serveru)
data.sort((a, b) => b.ts - a.ts);
container.innerHTML = data.map(entry => {
// Ošetření timestampu: JS Date vyžaduje milisekundy (UNIX je typicky v sekundách)
const dateMs = entry.ts < 20000000000 ? entry.ts * 1000 : entry.ts;
const timeStr = new Date(dateMs).toLocaleString('cs-CZ');
// Barva okraje na základě typu (0 = info, 1 = warning, 2 = error)
let borderColor = 'border-info';
if (entry.t === 1) borderColor = 'border-warning';
else if (entry.t === 2) borderColor = 'border-danger';
return `
<div class="p-3 mb-3 rounded bg-body-tertiary border-start border-4 ${borderColor} shadow-sm">
<div class="d-flex justify-content-between mb-1">
<small class="text-secondary fw-semibold">${timeStr}</small>
</div>
<div class="fw-medium">${entry.m}</div>
</div>
`;
}).join('');
} catch (err) {
console.error("Nepodařilo se načíst log:", err);
container.innerHTML = '<div class="text-center text-danger py-3">Nepodařilo se načíst záznam událostí.</div>';
}
}
document.getElementById('refresh-log-btn').addEventListener('click', fetchLog);
// -----------------------------
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('save-btn');
btn.disabled = true;
const payload = {
t0: parseFloat(document.getElementById('t0').value),
t1: parseFloat(document.getElementById('t1').value)
};
try {
const res = await fetch('/api/thresholds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('✅ Nastavení bylo úspěšně uloženo!');
fetchStatus();
fetchLog(); // Obnova logu po změně nastavení může být užitečná
} catch (err) {
showNotification('❌ Chyba při ukládání!', true);
} finally {
btn.disabled = false;
}
});
document.getElementById('history-range').addEventListener('change', (e) => {
fetchHistory(e.target.value);
});
// Inicializace
fetchStatus();
fetchHistory(document.getElementById('history-range').value);
fetchLog();
setInterval(fetchStatus, 10000);
</script>
</body>
</html>