486 lines
19 KiB
HTML
486 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;
|
|
}
|
|
</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 title="Otevřete ventil" style="cursor: help;">⚠️</span>';
|
|
} else if (isOpen && data.temp < data.t0) {
|
|
warningHtml = ' <span title="Zavřete ventil" style="cursor: help;">⚠️</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> |