feat: Add new API endpoints and HTML pages for ML model management
- Implemented HTML pages for datasets, models, training, testing, and results. - Created API endpoints for managing repositories, results, tests, and training sessions. - Added functionality for streaming training progress via Server-Sent Events (SSE). - Introduced a Dockerfile for the ML runner with necessary dependencies. - Developed an SDK for user code execution within the runner container. - Enhanced CSS styles for improved UI layout and navigation. - Established a layout template for consistent HTML structure across pages. - Added JavaScript for dynamic interactions on the models page. - Implemented WebSocket handling for real-time communication with kiosk devices and controllers. - Implemented model registration and management API at /api/models - Added Gitea proxy API for repository interactions at /api/repos - Created results API for listing and comparing training results at /api/results - Developed training management API for enqueueing and retrieving training jobs at /api/trainings - Introduced SSE endpoint for live training progress updates - Added HTML pages for models, datasets, and training management - Created a Dockerfile for the ML runner with necessary dependencies - Developed SDK for user code execution within the runner container - Enhanced CSS styles for improved UI/UX - Implemented WebSocket communication for real-time device and controller interactions in the kiosk system
This commit is contained in:
@@ -66,6 +66,30 @@ app.get('/sessions', renderPage('sessions', {
|
||||
mapboxToken: process.env.MAPBOX_TOKEN || ''
|
||||
}));
|
||||
|
||||
app.get('/kioskedit', renderPage('kioskedit'));
|
||||
|
||||
app.get('/kiosklive', renderPage('kiosklive', {
|
||||
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||
}));
|
||||
|
||||
app.get('/forecasts', renderPage('forecasts', {
|
||||
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||
}));
|
||||
|
||||
app.get('/documentation', renderPage('documentation', {
|
||||
apiUrl: process.env.API_URL || 'http://localhost:3003'
|
||||
}));
|
||||
// retro-compatibilità: il link della dashboard punta ancora a /documentations
|
||||
app.get('/documentations', (req, res) => res.redirect(301, '/documentation'));
|
||||
|
||||
app.get('/marine', renderPage('marine', {
|
||||
apiUrl: process.env.API_URL || 'http://localhost:3003',
|
||||
marineUrl: process.env.MARINE_URL || (process.env.API_URL || 'http://localhost:3003') + '/marine'
|
||||
}));
|
||||
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Started on port ${PORT}`);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
230
console/src/pages/documentation.html
Normal file
230
console/src/pages/documentation.html
Normal file
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Documentazione — MEB Console</title>
|
||||
<link rel="stylesheet" href="../static/styles/style.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
|
||||
<style>
|
||||
.doc-layout { display: grid; grid-template-columns: 280px 1fr; gap: 0; height: calc(100vh - 80px); }
|
||||
.doc-sidebar { background: rgba(0,0,0,.15); border-right: 1px solid rgba(255,255,255,.05); padding: 1rem; overflow-y: auto; }
|
||||
.doc-sidebar h3 { margin: 0 0 .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
|
||||
.doc-new { width: 100%; padding: .5rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-bottom: .75rem; }
|
||||
.doc-new:hover { background: #2980b9; }
|
||||
.doc-list { list-style: none; padding: 0; margin: 0; }
|
||||
.doc-list li { padding: .5rem .6rem; cursor: pointer; border-radius: 6px; font-size: .88rem; display: flex; justify-content: space-between; align-items: center; gap: .4rem; }
|
||||
.doc-list li:hover { background: rgba(255,255,255,.05); }
|
||||
.doc-list li.active { background: rgba(52,152,219,.15); color: #5dade2; }
|
||||
.doc-list li .del { opacity: 0; border: none; background: transparent; color: #e74c3c; cursor: pointer; font-size: 1.1rem; padding: 0 .3rem; }
|
||||
.doc-list li:hover .del { opacity: .7; }
|
||||
.doc-list li .del:hover { opacity: 1; }
|
||||
.doc-main { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.doc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.doc-toolbar .name { font-weight: 600; font-size: 1rem; }
|
||||
.doc-toolbar .actions { display: flex; gap: .5rem; align-items: center; }
|
||||
.toggle { display: flex; background: rgba(255,255,255,.05); border-radius: 6px; padding: 2px; }
|
||||
.toggle button { border: none; background: transparent; padding: .4rem .75rem; cursor: pointer; color: inherit; border-radius: 4px; display: flex; align-items: center; gap: .35rem; font-size: .85rem; }
|
||||
.toggle button.active { background: #3498db; color: #fff; }
|
||||
.btn-save { background: #27ae60; color: #fff; border: none; padding: .5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .85rem; }
|
||||
.btn-save:hover { background: #229954; }
|
||||
.btn-save:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.doc-body { flex: 1; overflow: auto; }
|
||||
.doc-viewer { padding: 2rem; max-width: 860px; margin: 0 auto; line-height: 1.7; }
|
||||
.doc-viewer h1 { border-bottom: 1px solid rgba(255,255,255,.1); padding-bottom: .3rem; }
|
||||
.doc-viewer h2 { border-bottom: 1px solid rgba(255,255,255,.05); padding-bottom: .2rem; }
|
||||
.doc-viewer code { background: rgba(255,255,255,.08); padding: .15em .4em; border-radius: 3px; font-size: .9em; }
|
||||
.doc-viewer pre { background: #0d1117; border-radius: 6px; padding: 1rem; overflow: auto; }
|
||||
.doc-viewer pre code { background: transparent; padding: 0; }
|
||||
.doc-viewer blockquote { border-left: 3px solid #3498db; margin: 1rem 0; padding: .2rem .5rem .2rem 1rem; background: rgba(52,152,219,.05); opacity: .85; }
|
||||
.doc-viewer table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
||||
.doc-viewer th, .doc-viewer td { border: 1px solid rgba(255,255,255,.1); padding: .4rem .7rem; }
|
||||
.doc-viewer th { background: rgba(255,255,255,.03); }
|
||||
.doc-viewer a { color: #5dade2; }
|
||||
.doc-editor { width: 100%; height: 100%; border: none; padding: 1.5rem 2rem; background: #0d1117; color: #e6edf3; font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: .9rem; line-height: 1.55; resize: none; outline: none; }
|
||||
.doc-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; opacity: .5; }
|
||||
.doc-empty .icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.doc-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; background: #27ae60; color: #fff; padding: .75rem 1.2rem; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.3); opacity: 0; transform: translateY(10px); transition: all .25s; }
|
||||
.doc-toast.show { opacity: 1; transform: translateY(0); }
|
||||
.doc-toast.err { background: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="contnent" style="padding:0;">
|
||||
<div class="header" style="padding: .5rem 1.5rem;">
|
||||
<h1 style="font-size:1.2rem;">Documentazione</h1>
|
||||
<div class="profile">
|
||||
<a href="/dashboard">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-layout">
|
||||
<aside class="doc-sidebar">
|
||||
<h3>File Markdown</h3>
|
||||
<button class="doc-new" id="btnNew">+ Nuovo documento</button>
|
||||
<ul class="doc-list" id="docList">
|
||||
<li style="opacity:.6; cursor:default;">Carico…</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="doc-main">
|
||||
<div class="doc-toolbar">
|
||||
<div class="name" id="currentName">Nessun documento selezionato</div>
|
||||
<div class="actions">
|
||||
<div class="toggle" id="modeToggle">
|
||||
<button data-mode="view" class="active" title="Visualizza">👁️ <span>Visualizza</span></button>
|
||||
<button data-mode="edit" title="Modifica">✏️ <span>Modifica</span></button>
|
||||
</div>
|
||||
<button class="btn-save" id="btnSave" disabled>💾 Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-body">
|
||||
<div id="viewerWrap" class="doc-viewer">
|
||||
<div class="doc-empty">
|
||||
<div class="icon">📄</div>
|
||||
<div>Seleziona un documento dalla sidebar, o creane uno nuovo.</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="editor" class="doc-editor" style="display:none;" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="doc-toast"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = "{{ apiUrl }}";
|
||||
marked.setOptions({ breaks: true, gfm: true, highlight: (code, lang) => {
|
||||
try { return hljs.highlight(code, { language: lang || 'plaintext' }).value; }
|
||||
catch { return code; }
|
||||
}});
|
||||
|
||||
let currentName = null;
|
||||
let originalContent = '';
|
||||
let mode = 'view';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function toast(msg, kind) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'doc-toast show' + (kind === 'err' ? ' err' : '');
|
||||
setTimeout(() => { t.className = 'doc-toast'; }, 2500);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${API}${path}`, { credentials: 'include', ...opts });
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'errore');
|
||||
throw new Error(`${res.status}: ${msg}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
const res = await api('/docs');
|
||||
const files = await res.json();
|
||||
const list = $('docList');
|
||||
list.innerHTML = '';
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<li style="opacity:.5; cursor:default;">Nessun documento. Creane uno.</li>';
|
||||
return;
|
||||
}
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const f of files) {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.name = f.name;
|
||||
li.innerHTML = `<span>${f.name}</span><button class="del" title="Elimina">×</button>`;
|
||||
li.querySelector('span').addEventListener('click', () => openDoc(f.name));
|
||||
li.addEventListener('click', (e) => { if (e.target.tagName !== 'BUTTON') openDoc(f.name); });
|
||||
li.querySelector('.del').addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Eliminare "${f.name}"?`)) return;
|
||||
try { await api(`/docs/${encodeURIComponent(f.name)}`, { method: 'DELETE' }); await loadList(); if (currentName === f.name) resetView(); toast('Eliminato'); }
|
||||
catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
list.appendChild(li);
|
||||
}
|
||||
} catch (e) { toast('Errore caricamento lista: ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
currentName = null;
|
||||
originalContent = '';
|
||||
$('currentName').textContent = 'Nessun documento selezionato';
|
||||
$('btnSave').disabled = true;
|
||||
$('viewerWrap').innerHTML = '<div class="doc-empty"><div class="icon">📄</div><div>Seleziona un documento.</div></div>';
|
||||
$('editor').value = '';
|
||||
setMode('view');
|
||||
}
|
||||
|
||||
async function openDoc(name) {
|
||||
try {
|
||||
const res = await api(`/docs/${encodeURIComponent(name)}`);
|
||||
const content = await res.text();
|
||||
currentName = name;
|
||||
originalContent = content;
|
||||
$('currentName').textContent = name;
|
||||
$('editor').value = content;
|
||||
render(content);
|
||||
document.querySelectorAll('#docList li').forEach(li => li.classList.toggle('active', li.dataset.name === name));
|
||||
$('btnSave').disabled = true;
|
||||
setMode('view');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
function render(md) {
|
||||
$('viewerWrap').innerHTML = marked.parse(md || '');
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
document.querySelectorAll('#modeToggle button').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
|
||||
$('viewerWrap').style.display = m === 'view' ? '' : 'none';
|
||||
$('editor').style.display = m === 'edit' ? 'block' : 'none';
|
||||
if (m === 'view') render($('editor').value);
|
||||
}
|
||||
|
||||
document.querySelectorAll('#modeToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||
|
||||
$('editor').addEventListener('input', () => {
|
||||
$('btnSave').disabled = ($('editor').value === originalContent) || !currentName;
|
||||
});
|
||||
|
||||
$('btnSave').addEventListener('click', async () => {
|
||||
if (!currentName) return;
|
||||
try {
|
||||
await api(`/docs/${encodeURIComponent(currentName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: $('editor').value })
|
||||
});
|
||||
originalContent = $('editor').value;
|
||||
$('btnSave').disabled = true;
|
||||
toast('Salvato ✓');
|
||||
render(originalContent);
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
$('btnNew').addEventListener('click', async () => {
|
||||
const name = prompt('Nome del nuovo documento (es. "guida-utente"):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await api('/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, content: `# ${name}\n\nScrivi qui…\n` })
|
||||
});
|
||||
await loadList();
|
||||
await openDoc(name.endsWith('.md') ? name : name + '.md');
|
||||
setMode('edit');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
loadList();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
300
console/src/pages/forecasts.html
Normal file
300
console/src/pages/forecasts.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Previsioni — MEB Console</title>
|
||||
<link rel="stylesheet" href="../static/styles/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
.fc-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: .75rem; margin: 1rem 0; }
|
||||
.fc-card { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
|
||||
.fc-card h4 { margin: 0 0 .35rem; font-size: .78rem; opacity: .65; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.fc-card .val { font-size: 1.8rem; font-weight: 600; line-height: 1; }
|
||||
.fc-card .unit { font-size: .9rem; opacity: .55; margin-left: .3rem; }
|
||||
.fc-card .sub { margin-top: .4rem; font-size: .8rem; opacity: .7; }
|
||||
.fc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.fc-panel { background: rgba(255,255,255,.04); border-radius: 10px; padding: 1rem; border: 1px solid rgba(255,255,255,.06); }
|
||||
.fc-panel h3 { margin: 0 0 .75rem; font-size: 1rem; }
|
||||
canvas { max-height: 260px; }
|
||||
.fc-status { display: inline-flex; align-items: center; gap: .4rem; font-size: .8rem; opacity: .8; }
|
||||
.fc-status .dot { width: 8px; height: 8px; border-radius: 50%; background: #888; }
|
||||
.fc-status.live .dot { background: #2ecc71; box-shadow: 0 0 8px #2ecc71; animation: pulse 2s infinite; }
|
||||
.fc-status.stale .dot { background: #e67e22; }
|
||||
@keyframes pulse { 50% { opacity: .4; } }
|
||||
.windrose { display: flex; align-items: center; justify-content: center; height: 220px; position: relative; }
|
||||
.windrose svg { width: 200px; height: 200px; }
|
||||
.compass-label { position: absolute; font-size: .7rem; opacity: .6; }
|
||||
.compass-label.n { top: 6px; left: 50%; transform: translateX(-50%); }
|
||||
.compass-label.s { bottom: 6px; left: 50%; transform: translateX(-50%); }
|
||||
.compass-label.e { right: 6px; top: 50%; transform: translateY(-50%); }
|
||||
.compass-label.w { left: 6px; top: 50%; transform: translateY(-50%); }
|
||||
.range-selector { display: flex; gap: .35rem; align-items: center; }
|
||||
.range-selector button { padding: .3rem .75rem; border: 1px solid rgba(255,255,255,.15); background: transparent; color: inherit; border-radius: 6px; cursor: pointer; font-size: .8rem; }
|
||||
.range-selector button.active { background: #3498db; border-color: transparent; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="contnent">
|
||||
<div class="header">
|
||||
<h1>Previsioni meteo-marine</h1>
|
||||
<div class="profile">
|
||||
<span id="fcStatus" class="fc-status"><span class="dot"></span><span id="fcStatusText">In attesa…</span></span>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fc-summary">
|
||||
<div class="fc-card">
|
||||
<h4>Temperatura</h4>
|
||||
<div><span class="val" id="sTemp">--</span><span class="unit">°C</span></div>
|
||||
<div class="sub">Umidità: <span id="sHum">--</span>%</div>
|
||||
</div>
|
||||
<div class="fc-card">
|
||||
<h4>Vento</h4>
|
||||
<div><span class="val" id="sWind">--</span><span class="unit">m/s</span></div>
|
||||
<div class="sub">Raffiche: <span id="sGust">--</span> m/s · Dir: <span id="sWindDir">--</span>°</div>
|
||||
</div>
|
||||
<div class="fc-card">
|
||||
<h4>Pressione</h4>
|
||||
<div><span class="val" id="sPressure">--</span><span class="unit">hPa</span></div>
|
||||
<div class="sub">Nuvole: <span id="sCloud">--</span>% · Prob. pioggia: <span id="sRainProb">--</span>%</div>
|
||||
</div>
|
||||
<div class="fc-card">
|
||||
<h4>Onde</h4>
|
||||
<div><span class="val" id="sWaveH">--</span><span class="unit">m</span></div>
|
||||
<div class="sub">Periodo: <span id="sWaveP">--</span>s · Dir: <span id="sWaveDir">--</span>°</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="range-selector">
|
||||
<span style="opacity:.6; margin-right:.5rem; font-size:.85rem;">Intervallo storico:</span>
|
||||
<button data-range="1h">1h</button>
|
||||
<button data-range="6h" class="active">6h</button>
|
||||
<button data-range="24h">24h</button>
|
||||
<button data-range="7d">7g</button>
|
||||
</div>
|
||||
|
||||
<div class="fc-grid">
|
||||
<div class="fc-panel">
|
||||
<h3>Temperatura & Umidità</h3>
|
||||
<canvas id="chartTemp"></canvas>
|
||||
</div>
|
||||
<div class="fc-panel">
|
||||
<h3>Vento (velocità + raffiche)</h3>
|
||||
<canvas id="chartWind"></canvas>
|
||||
</div>
|
||||
<div class="fc-panel">
|
||||
<h3>Pressione & Copertura</h3>
|
||||
<canvas id="chartPressure"></canvas>
|
||||
</div>
|
||||
<div class="fc-panel">
|
||||
<h3>Precipitazioni</h3>
|
||||
<canvas id="chartRain"></canvas>
|
||||
</div>
|
||||
<div class="fc-panel">
|
||||
<h3>Onde — altezza & periodo</h3>
|
||||
<canvas id="chartWaves"></canvas>
|
||||
</div>
|
||||
<div class="fc-panel">
|
||||
<h3>Direzione (corrente)</h3>
|
||||
<div class="windrose">
|
||||
<span class="compass-label n">N</span><span class="compass-label s">S</span>
|
||||
<span class="compass-label e">E</span><span class="compass-label w">W</span>
|
||||
<svg viewBox="-100 -100 200 200">
|
||||
<circle cx="0" cy="0" r="90" fill="none" stroke="rgba(255,255,255,.12)"/>
|
||||
<circle cx="0" cy="0" r="60" fill="none" stroke="rgba(255,255,255,.08)"/>
|
||||
<circle cx="0" cy="0" r="30" fill="none" stroke="rgba(255,255,255,.05)"/>
|
||||
<line x1="0" y1="-90" x2="0" y2="90" stroke="rgba(255,255,255,.08)"/>
|
||||
<line x1="-90" y1="0" x2="90" y2="0" stroke="rgba(255,255,255,.08)"/>
|
||||
<g id="windArrow" transform="rotate(0)">
|
||||
<polygon points="0,-70 -10,-40 0,-50 10,-40" fill="#3498db"/>
|
||||
</g>
|
||||
<g id="waveArrow" transform="rotate(0)">
|
||||
<polygon points="0,-55 -6,-35 0,-42 6,-35" fill="#e67e22" opacity=".8"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="text-align:center; font-size:.75rem; opacity:.6;">
|
||||
<span style="color:#3498db;">■</span> Vento <span style="color:#e67e22;">■</span> Onde
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="opacity:.5; font-size:.75rem; margin-top:1rem;">
|
||||
Fonte: plugin SignalK → Open-Meteo (current ogni 5 min, hourly ogni 60 min) + dati marini.
|
||||
Live via WebSocket; storico da InfluxDB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = "{{ apiUrl }}";
|
||||
const WS_URL = "{{ realtimeWsUrl }}";
|
||||
|
||||
const MEASUREMENTS = {
|
||||
temperature: 'meb.forecasts.temperature',
|
||||
humidity: 'meb.forecast.humidity',
|
||||
pressure: 'meb.forecast.pressure',
|
||||
precipitation: 'meb.forecast.precipitation',
|
||||
cloudCover: 'meb.forecast.cloudCover',
|
||||
windSpeed: 'meb.forecast.wind.speed',
|
||||
windDirection: 'meb.forecast.wind.direction',
|
||||
windGusts: 'meb.forecast.wind.gusts',
|
||||
waveHeight: 'meb.waves.height',
|
||||
waveDirection: 'meb.waves.direction',
|
||||
wavePeriod: 'meb.waves.period',
|
||||
wavePeakPeriod: 'meb.waves.peakPeriod',
|
||||
currentVelocity: 'meb.waves.currentVelocity',
|
||||
currentDirection: 'meb.waves.currentDirection',
|
||||
};
|
||||
|
||||
const current = {};
|
||||
const series = {};
|
||||
const MAX_POINTS = 500;
|
||||
for (const k of Object.keys(MEASUREMENTS)) series[k] = [];
|
||||
|
||||
function pushPoint(key, ts, value) {
|
||||
if (value == null || Number.isNaN(value)) return;
|
||||
current[key] = value;
|
||||
const arr = series[key];
|
||||
arr.push({ x: ts, y: value });
|
||||
if (arr.length > MAX_POINTS) arr.shift();
|
||||
}
|
||||
|
||||
const fmt = (v, d = 1) => v == null ? '--' : Number(v).toFixed(d);
|
||||
|
||||
function refreshSummary() {
|
||||
document.getElementById('sTemp').textContent = fmt(current.temperature);
|
||||
document.getElementById('sHum').textContent = fmt(current.humidity, 0);
|
||||
document.getElementById('sWind').textContent = fmt(current.windSpeed);
|
||||
document.getElementById('sGust').textContent = fmt(current.windGusts);
|
||||
document.getElementById('sWindDir').textContent = fmt(current.windDirection, 0);
|
||||
document.getElementById('sPressure').textContent = fmt(current.pressure, 0);
|
||||
document.getElementById('sCloud').textContent = fmt(current.cloudCover, 0);
|
||||
document.getElementById('sWaveH').textContent = fmt(current.waveHeight, 2);
|
||||
document.getElementById('sWaveP').textContent = fmt(current.wavePeriod, 1);
|
||||
document.getElementById('sWaveDir').textContent = fmt(current.waveDirection, 0);
|
||||
|
||||
if (current.windDirection != null)
|
||||
document.getElementById('windArrow').setAttribute('transform', `rotate(${current.windDirection})`);
|
||||
if (current.waveDirection != null)
|
||||
document.getElementById('waveArrow').setAttribute('transform', `rotate(${current.waveDirection})`);
|
||||
}
|
||||
|
||||
const xScale = {
|
||||
type: 'linear',
|
||||
ticks: { color: '#888', maxTicksLimit: 6, callback: (v) => {
|
||||
const d = new Date(v);
|
||||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||||
}},
|
||||
grid: { color: 'rgba(255,255,255,.05)' }
|
||||
};
|
||||
const commonOpts = {
|
||||
animation: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { x: xScale, y: { ticks: { color: '#888' }, grid: { color: 'rgba(255,255,255,.05)' } } },
|
||||
plugins: { legend: { labels: { color: '#bbb', boxWidth: 12 } } }
|
||||
};
|
||||
|
||||
const mkChart = (id, datasets) => new Chart(document.getElementById(id), {
|
||||
type: 'line', data: { datasets }, options: commonOpts
|
||||
});
|
||||
|
||||
const charts = {
|
||||
temp: mkChart('chartTemp', [
|
||||
{ label: 'Temperatura (°C)', data: series.temperature, borderColor: '#e74c3c', backgroundColor: 'rgba(231,76,60,.15)', tension: .3, fill: true },
|
||||
{ label: 'Umidità (%)', data: series.humidity, borderColor: '#3498db', tension: .3 }
|
||||
]),
|
||||
wind: mkChart('chartWind', [
|
||||
{ label: 'Velocità (m/s)', data: series.windSpeed, borderColor: '#1abc9c', tension: .3 },
|
||||
{ label: 'Raffiche (m/s)', data: series.windGusts, borderColor: '#9b59b6', borderDash: [4,4], tension: .3 }
|
||||
]),
|
||||
pressure: mkChart('chartPressure', [
|
||||
{ label: 'Pressione (hPa)', data: series.pressure, borderColor: '#f39c12', tension: .3 },
|
||||
{ label: 'Nuvole (%)', data: series.cloudCover, borderColor: '#95a5a6', tension: .3 }
|
||||
]),
|
||||
rain: mkChart('chartRain', [
|
||||
{ label: 'Precipitazioni (mm)', data: series.precipitation, borderColor: '#2980b9', backgroundColor: 'rgba(41,128,185,.3)', fill: true, tension: .2 }
|
||||
]),
|
||||
waves: mkChart('chartWaves', [
|
||||
{ label: 'Altezza (m)', data: series.waveHeight, borderColor: '#e67e22', tension: .3 },
|
||||
{ label: 'Periodo (s)', data: series.wavePeriod, borderColor: '#16a085', tension: .3 }
|
||||
]),
|
||||
};
|
||||
|
||||
let redrawPending = false;
|
||||
function scheduleRedraw() {
|
||||
if (redrawPending) return;
|
||||
redrawPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
redrawPending = false;
|
||||
refreshSummary();
|
||||
for (const c of Object.values(charts)) c.update('none');
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(cls, text) {
|
||||
document.getElementById('fcStatus').className = 'fc-status ' + cls;
|
||||
document.getElementById('fcStatusText').textContent = text;
|
||||
}
|
||||
|
||||
async function loadHistory(range = '6h') {
|
||||
setStatus('', 'Carico storico…');
|
||||
const measurements = Object.values(MEASUREMENTS);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/data/history?range=${range}&measurements=${encodeURIComponent(measurements.join(','))}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = await res.json();
|
||||
for (const [key, mName] of Object.entries(MEASUREMENTS)) {
|
||||
const rows = payload[mName] || payload[key];
|
||||
if (!Array.isArray(rows)) continue;
|
||||
series[key].length = 0;
|
||||
for (const row of rows.slice(-MAX_POINTS)) {
|
||||
series[key].push({ x: new Date(row.ts).getTime(), y: Number(row.value) });
|
||||
}
|
||||
if (rows.length) current[key] = Number(rows[rows.length - 1].value);
|
||||
}
|
||||
scheduleRedraw();
|
||||
setStatus('live', 'Storico caricato');
|
||||
} catch (e) {
|
||||
console.warn('[history]', e.message);
|
||||
setStatus('stale', 'Storico non disponibile');
|
||||
}
|
||||
}
|
||||
|
||||
let ws, reconnectTimer;
|
||||
function connect() {
|
||||
try { ws = new WebSocket(`${WS_URL}/live`); }
|
||||
catch { return setStatus('stale', 'Errore WS'); }
|
||||
ws.onopen = () => setStatus('live', 'Live');
|
||||
ws.onclose = () => { setStatus('stale', 'Disconnesso, riprovo…'); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 4000); };
|
||||
ws.onerror = () => setStatus('stale', 'Errore WS');
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
const ts = new Date(msg.timestamp || Date.now()).getTime();
|
||||
const key = Object.entries(MEASUREMENTS).find(([, v]) => v === msg.measurement)?.[0];
|
||||
if (!key) return;
|
||||
const value = msg.fields?.value ?? msg.value;
|
||||
pushPoint(key, ts, Number(value));
|
||||
scheduleRedraw();
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
|
||||
document.querySelectorAll('.range-selector button').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
document.querySelectorAll('.range-selector button').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
loadHistory(b.dataset.range);
|
||||
});
|
||||
});
|
||||
|
||||
loadHistory('6h');
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,16 +9,6 @@
|
||||
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
|
||||
<link rel="stylesheet" href="../static/styles/style.css">
|
||||
<link rel="stylesheet" href="../static/styles/kiosk.css">
|
||||
<script>
|
||||
// Detect and apply dark mode immediately to prevent flash
|
||||
const THEME_KEY = 'meb-console-theme';
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -48,7 +38,6 @@
|
||||
<p id="cardCount">0 cards</p>
|
||||
|
||||
<button id="editBtn">Edit</button>
|
||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
||||
<button id="addCardBtn" title="Aggiungi Widget">+</button>
|
||||
<button id="addMapBtn" title="Aggiungi Mappa">Map</button>
|
||||
|
||||
@@ -567,15 +556,5 @@ ws.onclose = () => {
|
||||
|
||||
</script>
|
||||
|
||||
<script src="../static/theme-toggle.js"></script>
|
||||
<script>
|
||||
// Theme toggle button event listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', toggleDarkMode);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</html>
|
||||
|
||||
331
console/src/pages/kiosklive.html
Normal file
331
console/src/pages/kiosklive.html
Normal file
@@ -0,0 +1,331 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Kiosk Live</title>
|
||||
<link rel="stylesheet" href="/static/styles/style.css">
|
||||
<link rel="stylesheet" href="/static/styles/kiosk.css">
|
||||
<style>
|
||||
body { margin:0; display:flex; flex-direction:column; height:100vh; background:#0b1220; color:#fff; font-family:sans-serif; }
|
||||
.topbar { display:flex; align-items:center; gap:12px; padding:8px 14px; background:#111827; border-bottom:1px solid #1f2937; }
|
||||
.status { display:flex; align-items:center; gap:6px; font-size:13px; }
|
||||
.dot { width:10px; height:10px; border-radius:50%; background:#6b7280; }
|
||||
.dot.on { background:#10b981; } .dot.off { background:#ef4444; }
|
||||
.topbar select, .topbar button, .topbar input { background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 10px; border-radius:6px; font-size:13px; }
|
||||
.topbar button { cursor:pointer; } .topbar button:hover { background:#374151; }
|
||||
.topbar .spacer { flex:1; }
|
||||
.main { flex:1; display:flex; min-height:0; }
|
||||
.stage { flex:1; position:relative; background:#0b1220; overflow:hidden; }
|
||||
.canvas { position:absolute; inset:0; }
|
||||
.box { position:absolute; border-radius:8px; padding:10px; overflow:hidden; cursor:pointer; border:2px solid transparent; display:flex; flex-direction:column; }
|
||||
.box:hover { border-color:#38bdf8; }
|
||||
.box.selected { border-color:#f59e0b; }
|
||||
.box .bt { font-size:12px; opacity:.7; text-transform:uppercase; letter-spacing:.05em; }
|
||||
.box .bv { flex:1; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:2.5vw; }
|
||||
.side { width:320px; background:#111827; border-left:1px solid #1f2937; padding:14px; overflow:auto; }
|
||||
.side h3 { margin:0 0 10px; font-size:14px; }
|
||||
.side label { display:block; font-size:12px; margin:8px 0 3px; opacity:.7; }
|
||||
.side input, .side select { width:100%; box-sizing:border-box; background:#1f2937; color:#fff; border:1px solid #374151; padding:6px 8px; border-radius:4px; font-size:13px; }
|
||||
.side .row { display:flex; gap:6px; }
|
||||
.side .row > * { flex:1; }
|
||||
.side .actions { display:flex; gap:6px; margin-top:14px; }
|
||||
.side button { flex:1; padding:8px; background:#2563eb; color:#fff; border:0; border-radius:6px; cursor:pointer; }
|
||||
.side button.danger { background:#dc2626; }
|
||||
.modal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:1000; }
|
||||
.modal.open { display:flex; }
|
||||
.modal .card { background:#111827; padding:20px; border-radius:8px; max-width:560px; width:90%; max-height:80vh; overflow:auto; }
|
||||
.tlist { display:flex; flex-direction:column; gap:6px; margin:10px 0; }
|
||||
.tlist .t { padding:10px; background:#1f2937; border-radius:6px; cursor:pointer; display:flex; justify-content:space-between; }
|
||||
.tlist .t.active { border:1px solid #10b981; }
|
||||
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1f2937; padding:8px 14px; border-radius:6px; opacity:0; transition:opacity .2s; }
|
||||
.toast.show { opacity:1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="status"><span id="dot" class="dot"></span><span id="statusTxt">connecting…</span></div>
|
||||
<label>Sensor: <input id="sensorInput" placeholder="sensor name" value=""></label>
|
||||
<button id="connectBtn">Connect</button>
|
||||
<div class="spacer"></div>
|
||||
<button id="loadBtn">Load template…</button>
|
||||
<button id="persistBtn">Save as new template</button>
|
||||
<button id="reloadBtn">Reload kiosk</button>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="stage"><div id="canvas" class="canvas"></div></div>
|
||||
<div class="side">
|
||||
<h3 id="sideTitle">No box selected</h3>
|
||||
<div id="form" style="display:none;">
|
||||
<label>Title</label><input id="fTitle">
|
||||
<label>SignalK path</label><input id="fPath">
|
||||
<div class="row">
|
||||
<div><label>Unit</label><input id="fUnit"></div>
|
||||
<div><label>Decimals</label><input id="fDec" type="number" min="0" max="6"></div>
|
||||
</div>
|
||||
<label>Multiplier</label><input id="fMul" type="number" step="any">
|
||||
<div class="row">
|
||||
<div><label>Color</label><input id="fColor" type="color"></div>
|
||||
<div><label>Text</label><input id="fText" type="color"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>X</label><input id="fX" type="number" step="0.5"></div>
|
||||
<div><label>Y</label><input id="fY" type="number" step="0.5"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>W</label><input id="fW" type="number" step="0.5"></div>
|
||||
<div><label>H</label><input id="fH" type="number" step="0.5"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="saveBox">Apply</button>
|
||||
<button id="delBox" class="danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="empty" style="opacity:.6;font-size:13px;">Click a box to edit it. Changes apply live to the kiosk.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal">
|
||||
<div class="card">
|
||||
<h3>Templates</h3>
|
||||
<div id="tlist" class="tlist"></div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:6px;">
|
||||
<button id="modalClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script>
|
||||
const API_URL = "{{ apiUrl }}";
|
||||
const WS_URL = "{{ realtimeWsUrl }}";
|
||||
|
||||
const canvasEl = document.getElementById('canvas');
|
||||
const dotEl = document.getElementById('dot');
|
||||
const stEl = document.getElementById('statusTxt');
|
||||
const toastEl = document.getElementById('toast');
|
||||
const COLS = 24, ROWS = 18;
|
||||
|
||||
let template = null, boxes = [], selected = null, ws = null, cmdSeq = 0;
|
||||
let sensorName = localStorage.getItem('kiosk_sensor') || '';
|
||||
document.getElementById('sensorInput').value = sensorName;
|
||||
|
||||
function toast(m){ toastEl.textContent = m; toastEl.classList.add('show'); setTimeout(()=>toastEl.classList.remove('show'), 1800); }
|
||||
function nextCmd(){ return 'c' + (++cmdSeq); }
|
||||
|
||||
function render() {
|
||||
canvasEl.innerHTML = '';
|
||||
if (!template) return;
|
||||
const W = canvasEl.clientWidth, H = canvasEl.clientHeight;
|
||||
const uw = W/COLS, uh = H/ROWS;
|
||||
for (const b of boxes) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'box' + (selected?.id === b.id ? ' selected':'');
|
||||
el.style.left = (b.x*uw)+'px'; el.style.top=(b.y*uh)+'px';
|
||||
el.style.width=(b.w*uw)+'px'; el.style.height=(b.h*uh)+'px';
|
||||
el.style.background = b.color || '#1e293b';
|
||||
el.style.color = b.textColor || '#fff';
|
||||
el.innerHTML = `<div class="bt">${b.title||b.path||''}</div><div class="bv">${b.path||''}${b.unit?' '+b.unit:''}</div>`;
|
||||
el.onclick = () => selectBox(b);
|
||||
canvasEl.appendChild(el);
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', render);
|
||||
|
||||
function selectBox(b) {
|
||||
selected = b;
|
||||
document.getElementById('sideTitle').textContent = 'Box: ' + (b.title||b.id);
|
||||
document.getElementById('form').style.display = 'block';
|
||||
document.getElementById('empty').style.display = 'none';
|
||||
for (const [id, key] of [['fTitle','title'],['fPath','path'],['fUnit','unit'],['fDec','decimals'],['fMul','multiplier'],['fColor','color'],['fText','textColor'],['fX','x'],['fY','y'],['fW','w'],['fH','h']]) {
|
||||
document.getElementById(id).value = b[key] ?? '';
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById('saveBox').onclick = () => {
|
||||
if (!selected) return;
|
||||
const patch = {
|
||||
title: document.getElementById('fTitle').value,
|
||||
path: document.getElementById('fPath').value,
|
||||
unit: document.getElementById('fUnit').value,
|
||||
decimals: +document.getElementById('fDec').value || 0,
|
||||
multiplier: +document.getElementById('fMul').value || 1,
|
||||
color: document.getElementById('fColor').value,
|
||||
textColor: document.getElementById('fText').value,
|
||||
x: +document.getElementById('fX').value,
|
||||
y: +document.getElementById('fY').value,
|
||||
w: +document.getElementById('fW').value,
|
||||
h: +document.getElementById('fH').value,
|
||||
};
|
||||
Object.assign(selected, patch);
|
||||
render();
|
||||
sendCmd({ t:'patch_box', boxId: selected.id, patch });
|
||||
};
|
||||
|
||||
document.getElementById('delBox').onclick = () => {
|
||||
if (!selected) return;
|
||||
const id = selected.id;
|
||||
boxes = boxes.filter(b => b.id !== id);
|
||||
selected = null;
|
||||
document.getElementById('form').style.display = 'none';
|
||||
document.getElementById('empty').style.display = '';
|
||||
render();
|
||||
sendCmd({ t:'remove_box', boxId: id });
|
||||
};
|
||||
|
||||
function sendCmd(obj) {
|
||||
if (!ws || ws.readyState !== 1) { toast('not connected'); return; }
|
||||
const cmdId = nextCmd();
|
||||
ws.send(JSON.stringify({ ...obj, cmdId }));
|
||||
}
|
||||
|
||||
async function fetchActive() {
|
||||
const r = await fetch(`${API_URL}/kiosk/template/active`, { credentials:'include' });
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
}
|
||||
async function fetchTemplate(id) {
|
||||
const r = await fetch(`${API_URL}/kiosk/templates/${id}`, { credentials:'include' });
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
}
|
||||
async function fetchList() {
|
||||
const r = await fetch(`${API_URL}/kiosk/templates`, { credentials:'include' });
|
||||
return r.ok ? r.json() : [];
|
||||
}
|
||||
|
||||
// ── Mapping fra il modello DB (kioskelements: label,x,y,width,height,color,font)
|
||||
// e il modello UI interno "box" (id,title,x,y,w,h,color,font + campi runtime
|
||||
// non persistiti come path/unit/decimals/multiplier/textColor che servono solo
|
||||
// per il rendering live sul device).
|
||||
function elementToBox(e) {
|
||||
return {
|
||||
id: String(e.id), // bigint dal DB → stringa per uso UI
|
||||
title: e.label || '',
|
||||
label: e.label || '',
|
||||
x: e.x, y: e.y,
|
||||
w: e.width, h: e.height,
|
||||
color: e.color || '#1e293b',
|
||||
font: e.font ?? 16,
|
||||
// campi runtime-only (non persistiti)
|
||||
path: e.sk_path || '',
|
||||
unit: e.unit || '',
|
||||
decimals: e.decimals ?? 1,
|
||||
multiplier: e.multiplier ?? 1,
|
||||
textColor: '#ffffff'
|
||||
};
|
||||
}
|
||||
|
||||
function boxToElement(b) {
|
||||
// Solo i campi che esistono come colonne in kioskelements
|
||||
return {
|
||||
label: (b.title || b.label || '').slice(0, 100),
|
||||
x: Math.max(0, Math.round(b.x || 0)),
|
||||
y: Math.max(0, Math.round(b.y || 0)),
|
||||
width: Math.max(1, Math.round(b.w || 1)),
|
||||
height: Math.max(1, Math.round(b.h || 1)),
|
||||
color: b.color || '#1e293b',
|
||||
font: parseInt(b.font, 10) || 16
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTemplate(tpl) {
|
||||
template = tpl;
|
||||
// Backward-compat: accetta sia il nuovo `elements` che il legacy `content.boxes`.
|
||||
const els = Array.isArray(tpl.elements) ? tpl.elements
|
||||
: (tpl.content?.boxes || []);
|
||||
boxes = els.map(e => (e.label !== undefined || e.width !== undefined)
|
||||
? elementToBox(e)
|
||||
: { ...e });
|
||||
selected = null;
|
||||
document.getElementById('form').style.display='none';
|
||||
document.getElementById('empty').style.display='';
|
||||
render();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
sensorName = document.getElementById('sensorInput').value.trim();
|
||||
if (!sensorName) { toast('sensor name required'); return; }
|
||||
localStorage.setItem('kiosk_sensor', sensorName);
|
||||
if (ws) { try { ws.close(); } catch {} }
|
||||
ws = new WebSocket(`${WS_URL}/kiosk?role=controller&sensor=${encodeURIComponent(sensorName)}`);
|
||||
stEl.textContent = 'connecting…'; dotEl.className = 'dot';
|
||||
ws.onopen = () => { stEl.textContent = 'connected'; };
|
||||
ws.onclose = () => { dotEl.className='dot off'; stEl.textContent='disconnected'; };
|
||||
ws.onerror = () => { stEl.textContent = 'error'; };
|
||||
ws.onmessage = (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
if (m.t === 'kiosk_status') {
|
||||
dotEl.className = 'dot ' + (m.online ? 'on':'off');
|
||||
stEl.textContent = m.online ? `online (tpl ${m.templateId||'?'})` : 'kiosk offline';
|
||||
} else if (m.t === 'ack') {
|
||||
if (!m.ok) toast('cmd failed: ' + (m.err||''));
|
||||
} else if (m.t === 'active_template_changed') {
|
||||
fetchTemplate(m.templateId).then(t => t && loadTemplate(t));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('connectBtn').onclick = connect;
|
||||
|
||||
document.getElementById('loadBtn').onclick = async () => {
|
||||
const list = await fetchList();
|
||||
const wrap = document.getElementById('tlist');
|
||||
wrap.innerHTML = '';
|
||||
for (const t of list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 't' + (t.active?' active':'');
|
||||
row.innerHTML = `<span>${t.name} ${t.active?'★':''}</span><span><button data-act data-id="${t.id}">Activate & send</button> <button data-prev data-id="${t.id}">Preview</button></span>`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
wrap.querySelectorAll('button[data-act]').forEach(b => b.onclick = async () => {
|
||||
const id = b.dataset.id;
|
||||
const r = await fetch(`${API_URL}/kiosk/templates/${id}/activate`, { method:'POST', credentials:'include' });
|
||||
if (r.ok) { toast('activated'); document.getElementById('modal').classList.remove('open'); sendCmd({ t:'load_template', templateId: id }); const tpl = await fetchTemplate(id); if (tpl) loadTemplate(tpl); }
|
||||
else toast('activate failed');
|
||||
});
|
||||
wrap.querySelectorAll('button[data-prev]').forEach(b => b.onclick = async () => {
|
||||
const tpl = await fetchTemplate(b.dataset.id);
|
||||
if (tpl) {
|
||||
loadTemplate(tpl);
|
||||
// Costruisci payload runtime per il device (boxes ricostruite).
|
||||
const content = { grid:{cols:COLS, rows:ROWS}, boxes: boxes.map(x => ({...x})) };
|
||||
sendCmd({ t:'apply_inline', content });
|
||||
document.getElementById('modal').classList.remove('open');
|
||||
}
|
||||
});
|
||||
document.getElementById('modal').classList.add('open');
|
||||
};
|
||||
document.getElementById('modalClose').onclick = () => document.getElementById('modal').classList.remove('open');
|
||||
|
||||
document.getElementById('persistBtn').onclick = async () => {
|
||||
if (!template) return toast('no template loaded');
|
||||
const name = prompt('New template name:', (template.name || 'Template') + ' (edited)');
|
||||
if (!name) return;
|
||||
const body = {
|
||||
name: String(name).slice(0, 50),
|
||||
tags: template.tags || [],
|
||||
elements: boxes.map(boxToElement)
|
||||
};
|
||||
const r = await fetch(`${API_URL}/kiosk/templates`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) toast('saved'); else toast('save failed');
|
||||
};
|
||||
|
||||
document.getElementById('reloadBtn').onclick = () => sendCmd({ t:'reload' });
|
||||
|
||||
// boot
|
||||
(async () => {
|
||||
const tpl = await fetchActive();
|
||||
if (tpl) loadTemplate(tpl);
|
||||
if (sensorName) connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,16 +11,6 @@
|
||||
.expanded-chart-container { display: none; }
|
||||
.comparison-sidebar { display: none; }
|
||||
</style>
|
||||
<script>
|
||||
// Detect and apply dark mode immediately to prevent flash
|
||||
const THEME_KEY = 'meb-console-theme';
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -60,7 +50,6 @@
|
||||
<p class="last-update" id="lastUpdateText">In attesa di dati...</p>
|
||||
</div>
|
||||
<div class="profile">
|
||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
||||
<p id="sensorName">Sensore</p>
|
||||
<button id="changeSessionBtn">Cambia sessione</button>
|
||||
</div>
|
||||
@@ -788,14 +777,3 @@ document.getElementById('saveSessionLabelBtn').onclick = async () => {
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<script src="/static/theme-toggle.js"></script>
|
||||
<script>
|
||||
// Theme toggle button event listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', toggleDarkMode);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
301
console/src/pages/marine.html
Normal file
301
console/src/pages/marine.html
Normal file
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Copernicus Marine — MEB Console</title>
|
||||
<link rel="stylesheet" href="../static/styles/style.css">
|
||||
<style>
|
||||
.m-tabs { display: flex; gap: .5rem; padding: 0 1.5rem; border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||
.m-tabs button { padding: .6rem 1.2rem; background: transparent; border: none; color: inherit; cursor: pointer; border-bottom: 2px solid transparent; opacity: .6; }
|
||||
.m-tabs button.active { opacity: 1; border-bottom-color: #3498db; color: #5dade2; }
|
||||
.m-panel { padding: 1.5rem; }
|
||||
.m-search { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||
.m-search input { flex: 1; padding: .6rem .9rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 6px; font-size: .9rem; }
|
||||
.m-search button { padding: .6rem 1.2rem; background: #3498db; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
|
||||
.m-results { display: grid; gap: .75rem; }
|
||||
.m-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 8px; padding: 1rem; }
|
||||
.m-card .title { font-weight: 600; margin-bottom: .35rem; }
|
||||
.m-card .id { font-size: .75rem; opacity: .55; font-family: monospace; margin-bottom: .5rem; }
|
||||
.m-card .desc { font-size: .85rem; opacity: .8; margin-bottom: .5rem; }
|
||||
.m-card .meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .75rem; }
|
||||
.m-card .chip { background: rgba(52,152,219,.15); color: #5dade2; padding: .15rem .5rem; border-radius: 10px; }
|
||||
.m-card .actions { margin-top: .75rem; display: flex; gap: .5rem; }
|
||||
.m-card .actions button { padding: .4rem .9rem; font-size: .8rem; background: rgba(255,255,255,.08); color: inherit; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; cursor: pointer; }
|
||||
.m-card .actions button.primary { background: #3498db; color: #fff; border-color: transparent; }
|
||||
.m-pagination { display: flex; gap: .5rem; justify-content: center; margin-top: 1rem; align-items: center; font-size: .85rem; opacity: .8; }
|
||||
.m-pagination button { padding: .3rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 4px; cursor: pointer; }
|
||||
.m-pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.m-modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 100; }
|
||||
.m-modal.show { display: flex; }
|
||||
.m-modal .box { background: #1a1f2b; border-radius: 10px; padding: 1.5rem; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; }
|
||||
.m-modal h3 { margin-top: 0; }
|
||||
.m-form label { display: block; margin-top: .75rem; font-size: .85rem; opacity: .8; }
|
||||
.m-form input, .m-form select, .m-form textarea { width: 100%; padding: .5rem .7rem; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); color: inherit; border-radius: 5px; margin-top: .25rem; font-size: .9rem; }
|
||||
.m-form .row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
|
||||
.m-form .vars { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .35rem; }
|
||||
.m-form .vars label { display: inline-flex; gap: .3rem; align-items: center; background: rgba(255,255,255,.05); padding: .25rem .5rem; border-radius: 4px; margin: 0; font-size: .8rem; }
|
||||
.m-form .actions { margin-top: 1.2rem; display: flex; gap: .5rem; justify-content: flex-end; }
|
||||
.m-form .actions button { padding: .5rem 1.2rem; border: none; border-radius: 5px; cursor: pointer; font-weight: 600; }
|
||||
.btn-cancel { background: rgba(255,255,255,.1); color: inherit; }
|
||||
.btn-go { background: #27ae60; color: #fff; }
|
||||
.progress { height: 8px; background: rgba(255,255,255,.1); border-radius: 4px; overflow: hidden; margin-top: .5rem; }
|
||||
.progress .bar { height: 100%; background: #3498db; transition: width .3s; }
|
||||
.m-datasets-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.m-datasets-table th, .m-datasets-table td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.m-datasets-table th { font-size: .75rem; opacity: .6; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.m-datasets-table tr:hover { background: rgba(255,255,255,.02); }
|
||||
.m-empty { text-align: center; padding: 3rem; opacity: .5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="contnent" style="padding:0;">
|
||||
<div class="header" style="padding: .5rem 1.5rem;">
|
||||
<h1 style="font-size:1.2rem;">Copernicus Marine</h1>
|
||||
<div class="profile"><a href="/dashboard">← Dashboard</a></div>
|
||||
</div>
|
||||
|
||||
<div class="m-tabs">
|
||||
<button class="active" data-tab="search">Ricerca catalogo</button>
|
||||
<button data-tab="saved">I miei dataset</button>
|
||||
</div>
|
||||
|
||||
<section id="tab-search" class="m-panel">
|
||||
<div class="m-search">
|
||||
<input type="search" id="searchInput" placeholder="Cerca dataset (es. 'currents', 'waves', 'mediterranean')…">
|
||||
<button id="searchBtn">Cerca</button>
|
||||
</div>
|
||||
<div id="results" class="m-results"><p class="m-empty">Inserisci una query per cercare nel catalogo Copernicus Marine.</p></div>
|
||||
<div class="m-pagination" id="pagination" style="display:none;">
|
||||
<button id="prevPage">‹ Precedente</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextPage">Successiva ›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-saved" class="m-panel" style="display:none;">
|
||||
<div id="saved" class="m-results"><p class="m-empty">Carico…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Modal download -->
|
||||
<div class="m-modal" id="downloadModal">
|
||||
<div class="box">
|
||||
<h3>Scarica dataset</h3>
|
||||
<div id="dsInfo" style="font-size:.85rem; opacity:.7; margin-bottom:.75rem;"></div>
|
||||
<form class="m-form" id="downloadForm" onsubmit="return false;">
|
||||
<label>Nome del dataset salvato</label>
|
||||
<input name="nome" required>
|
||||
<label>Variabili</label>
|
||||
<div class="vars" id="varsList"></div>
|
||||
<div class="row">
|
||||
<div><label>Min longitudine</label><input name="min_longitude" type="number" step="any" required></div>
|
||||
<div><label>Max longitudine</label><input name="max_longitude" type="number" step="any" required></div>
|
||||
<div><label>Min latitudine</label><input name="min_latitude" type="number" step="any" required></div>
|
||||
<div><label>Max latitudine</label><input name="max_latitude" type="number" step="any" required></div>
|
||||
<div><label>Data inizio</label><input name="start_date" type="date" required></div>
|
||||
<div><label>Data fine</label><input name="end_date" type="date" required></div>
|
||||
</div>
|
||||
<label>Formato</label>
|
||||
<select name="format"><option value="csv">CSV</option><option value="json">JSON</option></select>
|
||||
<label>Tag (separati da virgola)</label>
|
||||
<input name="tags" placeholder="marine, currents">
|
||||
<label>Note</label>
|
||||
<textarea name="notes" rows="2"></textarea>
|
||||
<label><input type="checkbox" id="downloadLocal"> Scarica anche sul mio computer</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-cancel" id="cancelDl">Annulla</button>
|
||||
<button type="button" class="btn-go" id="startDl">Scarica</button>
|
||||
</div>
|
||||
<div id="jobProgress" style="display:none; margin-top:1rem;">
|
||||
<div id="jobMessage" style="font-size:.85rem;">In attesa…</div>
|
||||
<div class="progress"><div class="bar" id="jobBar" style="width:0%"></div></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = "{{ apiUrl }}";
|
||||
const MARINE = "{{ marineUrl }}";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let currentSearch = '', currentOffset = 0, pageLimit = 20, lastTotal = 0;
|
||||
let currentDataset = null;
|
||||
|
||||
// ── Tabs ──
|
||||
document.querySelectorAll('.m-tabs button').forEach(b => b.addEventListener('click', () => {
|
||||
document.querySelectorAll('.m-tabs button').forEach(x => x.classList.toggle('active', x === b));
|
||||
$('tab-search').style.display = b.dataset.tab === 'search' ? '' : 'none';
|
||||
$('tab-saved').style.display = b.dataset.tab === 'saved' ? '' : 'none';
|
||||
if (b.dataset.tab === 'saved') loadSaved();
|
||||
}));
|
||||
|
||||
// ── Search ──
|
||||
async function runSearch(offset = 0) {
|
||||
const q = $('searchInput').value.trim();
|
||||
if (!q) { $('results').innerHTML = '<p class="m-empty">Inserisci una query.</p>'; $('pagination').style.display='none'; return; }
|
||||
currentSearch = q; currentOffset = offset;
|
||||
$('results').innerHTML = '<p class="m-empty">Cerco…</p>';
|
||||
try {
|
||||
const res = await fetch(`${MARINE}/catalog?search=${encodeURIComponent(q)}&limit=${pageLimit}&offset=${offset}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
lastTotal = data.total || 0;
|
||||
renderResults(data.datasets || []);
|
||||
renderPagination();
|
||||
} catch (e) { $('results').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
function renderResults(list) {
|
||||
if (!list.length) { $('results').innerHTML = '<p class="m-empty">Nessun risultato.</p>'; return; }
|
||||
$('results').innerHTML = list.map(d => `
|
||||
<div class="m-card">
|
||||
<div class="title">${escapeHtml(d.title || d.dataset_id)}</div>
|
||||
<div class="id">${escapeHtml(d.dataset_id)}</div>
|
||||
<div class="desc">${escapeHtml(d.description || '')}</div>
|
||||
<div class="meta">
|
||||
${d.variables ? d.variables.slice(0,6).map(v => `<span class="chip">${escapeHtml(v.short_name)}</span>`).join('') : ''}
|
||||
${d.variables && d.variables.length > 6 ? `<span class="chip">+${d.variables.length - 6}</span>` : ''}
|
||||
${d.start_datetime ? `<span class="chip">${d.start_datetime} → ${d.end_datetime || 'oggi'}</span>` : ''}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary" data-id="${escapeHtml(d.dataset_id)}">⇩ Scarica</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.querySelectorAll('#results button[data-id]').forEach(b => {
|
||||
b.addEventListener('click', () => openDownload(b.dataset.id, list.find(x => x.dataset_id === b.dataset.id)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const pag = $('pagination');
|
||||
if (lastTotal <= pageLimit) { pag.style.display='none'; return; }
|
||||
pag.style.display = 'flex';
|
||||
$('pageInfo').textContent = `${currentOffset + 1}–${Math.min(currentOffset + pageLimit, lastTotal)} di ${lastTotal}`;
|
||||
$('prevPage').disabled = currentOffset === 0;
|
||||
$('nextPage').disabled = currentOffset + pageLimit >= lastTotal;
|
||||
}
|
||||
|
||||
$('searchBtn').addEventListener('click', () => runSearch(0));
|
||||
$('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(0); });
|
||||
$('prevPage').addEventListener('click', () => runSearch(Math.max(0, currentOffset - pageLimit)));
|
||||
$('nextPage').addEventListener('click', () => runSearch(currentOffset + pageLimit));
|
||||
|
||||
// ── Download modal ──
|
||||
function openDownload(id, ds) {
|
||||
currentDataset = ds;
|
||||
$('dsInfo').innerHTML = `<strong>${escapeHtml(ds.title || id)}</strong><br><span style="font-family:monospace; font-size:.75rem;">${escapeHtml(id)}</span>`;
|
||||
const form = $('downloadForm');
|
||||
form.nome.value = (ds.title || id).slice(0, 60);
|
||||
form.min_longitude.value = ds.min_longitude ?? '';
|
||||
form.max_longitude.value = ds.max_longitude ?? '';
|
||||
form.min_latitude.value = ds.min_latitude ?? '';
|
||||
form.max_latitude.value = ds.max_latitude ?? '';
|
||||
form.start_date.value = ds.start_datetime || '';
|
||||
form.end_date.value = ds.end_datetime || '';
|
||||
$('varsList').innerHTML = (ds.variables || []).map(v =>
|
||||
`<label><input type="checkbox" value="${escapeHtml(v.short_name)}" checked>${escapeHtml(v.short_name)}</label>`
|
||||
).join('');
|
||||
$('jobProgress').style.display = 'none';
|
||||
$('downloadModal').classList.add('show');
|
||||
}
|
||||
|
||||
$('cancelDl').addEventListener('click', () => $('downloadModal').classList.remove('show'));
|
||||
|
||||
$('startDl').addEventListener('click', async () => {
|
||||
const form = $('downloadForm');
|
||||
const variables = [...$('varsList').querySelectorAll('input:checked')].map(x => x.value);
|
||||
if (!variables.length) return alert('Seleziona almeno una variabile');
|
||||
const payload = {
|
||||
dataset_id: currentDataset.dataset_id,
|
||||
variables,
|
||||
min_longitude: parseFloat(form.min_longitude.value),
|
||||
max_longitude: parseFloat(form.max_longitude.value),
|
||||
min_latitude: parseFloat(form.min_latitude.value),
|
||||
max_latitude: parseFloat(form.max_latitude.value),
|
||||
start_date: form.start_date.value,
|
||||
end_date: form.end_date.value,
|
||||
format: form.format.value,
|
||||
nome: form.nome.value,
|
||||
tags: form.tags.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
notes: form.notes.value,
|
||||
};
|
||||
try {
|
||||
$('startDl').disabled = true;
|
||||
const res = await fetch(`${MARINE}/jobs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const job = await res.json();
|
||||
pollJob(job.job_id);
|
||||
} catch (e) { alert('Errore: ' + e.message); $('startDl').disabled = false; }
|
||||
});
|
||||
|
||||
async function pollJob(jobId) {
|
||||
$('jobProgress').style.display = '';
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${MARINE}/jobs/${jobId}`, { credentials: 'include' });
|
||||
const j = await res.json();
|
||||
$('jobMessage').textContent = j.message || j.status;
|
||||
$('jobBar').style.width = (j.progress || 0) + '%';
|
||||
if (j.status === 'done') {
|
||||
clearInterval(timer);
|
||||
$('startDl').disabled = false;
|
||||
if ($('downloadLocal').checked && j.dataset_id) {
|
||||
window.open(`${API}/marine/datasets/${j.dataset_id}/raw`, '_blank');
|
||||
}
|
||||
setTimeout(() => { $('downloadModal').classList.remove('show'); loadSaved(); }, 1000);
|
||||
}
|
||||
if (j.status === 'error') { clearInterval(timer); $('startDl').disabled = false; }
|
||||
} catch (e) { clearInterval(timer); $('startDl').disabled = false; }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Saved datasets ──
|
||||
async function loadSaved() {
|
||||
$('saved').innerHTML = '<p class="m-empty">Carico…</p>';
|
||||
try {
|
||||
const res = await fetch(`${API}/marine/datasets`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { datasets = [] } = await res.json();
|
||||
if (!datasets.length) { $('saved').innerHTML = '<p class="m-empty">Nessun dataset salvato.</p>'; return; }
|
||||
$('saved').innerHTML = `
|
||||
<table class="m-datasets-table">
|
||||
<thead><tr><th>Nome</th><th>Type</th><th>Formato</th><th>Size</th><th>Tag</th><th>Creato</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${datasets.map(d => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(d.nome)}</strong></td>
|
||||
<td>${escapeHtml(d.type)}</td>
|
||||
<td>${escapeHtml(d.format)}</td>
|
||||
<td>${fmtBytes(d.size_bytes)}</td>
|
||||
<td>${(d.tags||[]).map(t => `<span class="chip">${escapeHtml(t)}</span>`).join(' ')}</td>
|
||||
<td style="font-size:.8rem; opacity:.7;">${new Date(d.created_at).toLocaleDateString('it-IT')}</td>
|
||||
<td>
|
||||
<button onclick="window.open('${API}/marine/datasets/${d.id}/raw','_blank')">⇩</button>
|
||||
<button onclick="deleteDs('${d.id}')">🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (e) { $('saved').innerHTML = `<p class="m-empty">Errore: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
window.deleteDs = async (id) => {
|
||||
if (!confirm('Eliminare il dataset?')) return;
|
||||
await fetch(`${API}/marine/datasets/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||
loadSaved();
|
||||
};
|
||||
|
||||
// ── utils ──
|
||||
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmtBytes = (b) => { if (!b) return '0 B'; const u = ['B','KB','MB','GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return b.toFixed(1) + ' ' + u[i]; };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,16 +4,6 @@
|
||||
<link rel="stylesheet" href="/static/styles/style.css">
|
||||
<link rel="stylesheet" href="/static/styles/rulesets.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>
|
||||
// Detect and apply dark mode immediately to prevent flash
|
||||
const THEME_KEY = 'meb-console-theme';
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -27,14 +17,14 @@
|
||||
</a>
|
||||
<h1>Rulesets</h1>
|
||||
<div class="rs-type-picker" id="typePicker">
|
||||
<button class="active" data-type="weather">Weather</button>
|
||||
<button data-type="laterforecasts">Forecasts</button>
|
||||
<button data-type="data">Data</button>
|
||||
<button data-type="logs">Logs</button>
|
||||
<button class="active" data-type="logs">Logs</button>
|
||||
<button data-type="forecast_current">Forecast · Current</button>
|
||||
<button data-type="forecast_hourly">Forecast · Hourly</button>
|
||||
<button data-type="marine_current">Marine · Current</button>
|
||||
<button data-type="marine_hourly">Marine · Hourly</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rs-header-right">
|
||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
||||
<span class="rs-saving" id="savingIndicator">Salvato</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +41,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="rs-toolbar-right">
|
||||
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Rule</button>
|
||||
<button class="rs-new-btn" id="newRuleBtn">+ Nuova Versione</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,9 +68,15 @@
|
||||
<div class="rs-section">
|
||||
<div class="rs-field-row">
|
||||
<span class="rs-field-label">Versione</span>
|
||||
<input class="rs-field-input" id="popupVersion" placeholder="1.0.0" />
|
||||
<div class="rs-version-inputs">
|
||||
<input class="rs-version-num" id="popupVMajor" type="number" min="1" max="100" placeholder="1" />
|
||||
<span class="rs-version-dot">.</span>
|
||||
<input class="rs-version-num" id="popupVBuild" type="number" min="0" max="100" placeholder="0" />
|
||||
<span class="rs-version-dot">.</span>
|
||||
<input class="rs-version-num" id="popupVPatch" type="number" min="0" max="100" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rs-field-row" id="popupDescRow" style="display:none">
|
||||
<div class="rs-field-row">
|
||||
<span class="rs-field-label">Descrizione</span>
|
||||
<textarea class="rs-field-textarea" id="popupDesc" placeholder="Descrizione opzionale..."></textarea>
|
||||
</div>
|
||||
@@ -113,6 +109,21 @@
|
||||
<div class="rs-item-labels" id="itemLabelsRow"></div>
|
||||
<div id="itemsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy -->
|
||||
<div class="rs-section">
|
||||
<div class="rs-section-title">Deploy ai sensori</div>
|
||||
<div class="rs-deploy-wrap">
|
||||
<div id="deploySensorsList" class="rs-deploy-sensors">
|
||||
<div class="rs-empty" style="padding:8px">Caricamento sensori...</div>
|
||||
</div>
|
||||
<div class="rs-deploy-actions">
|
||||
<button class="rs-action-btn" id="deployBtn">Invia versione ai selezionati</button>
|
||||
<span class="rs-deploy-result" id="deployResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,47 +144,89 @@
|
||||
const API_URL = '{{ apiUrl }}';
|
||||
|
||||
// --- State ---
|
||||
let currentType = 'weather';
|
||||
const TYPES = ['logs','forecast_current','forecast_hourly','marine_current','marine_hourly'];
|
||||
let currentType = 'logs';
|
||||
let currentFilter = 'all';
|
||||
let currentSort = 'created_at';
|
||||
let allRules = [];
|
||||
let openRule = null; // rule attualmente aperta nel popup
|
||||
let openRule = null;
|
||||
let saveTimers = {};
|
||||
let sensorsCache = [];
|
||||
let deploymentsForOpen = []; // deployments relativi alla rule aperta
|
||||
|
||||
// --- Item field definitions per tipo ---
|
||||
// Per ogni tipo, definiamo gli input visibili. Tutti finiscono nei campi
|
||||
// { ref, path, enabled, meta: {...} } del JSONB dell'item.
|
||||
//
|
||||
// - logs: ref, path (SK path), meta.measurement, meta.unit
|
||||
// - forecast_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
|
||||
// - marine_*: ref, path (codice openmeteo), meta.sk_path, meta.unit
|
||||
const ITEM_SCHEMA = {
|
||||
weather: [
|
||||
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'name', label: 'Nome', cls: 'wide' },
|
||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
laterforecasts: [
|
||||
{ key: 'group_name', label: 'Gruppo', cls: 'medium' },
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'name', label: 'Nome', cls: 'wide' },
|
||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
data: [
|
||||
{ key: 'category', label: 'Categoria', cls: 'medium' },
|
||||
{ key: 'path', label: 'Path', cls: 'wide' },
|
||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
logs: [
|
||||
{ key: 'path', label: 'Path', cls: 'wide' },
|
||||
{ key: 'ref', label: 'Ref', cls: 'narrow' },
|
||||
{ key: 'unit', label: 'Unita', cls: 'narrow' },
|
||||
{ key: 'measurement', label: 'Measurement', cls: 'medium' },
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'path', label: 'SK Path', cls: 'wide' },
|
||||
{ key: 'meta.measurement', label: 'Measurement', cls: 'medium' },
|
||||
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
forecast_current: [
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
forecast_hourly: [
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
marine_current: [
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
marine_hourly: [
|
||||
{ key: 'ref', label: 'Ref', cls: 'medium' },
|
||||
{ key: 'path', label: 'OpenMeteo', cls: 'medium' },
|
||||
{ key: 'meta.sk_path', label: 'SK Path', cls: 'wide' },
|
||||
{ key: 'meta.unit', label: 'Unita', cls: 'narrow' },
|
||||
],
|
||||
};
|
||||
|
||||
const HAS_DESC = { weather: true, laterforecasts: true, data: false, logs: true };
|
||||
// ========== Helpers ==========
|
||||
|
||||
function esc(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function getField(obj, dottedKey) {
|
||||
if (!dottedKey.includes('.')) return obj?.[dottedKey];
|
||||
const [a, b] = dottedKey.split('.');
|
||||
return obj?.[a]?.[b];
|
||||
}
|
||||
|
||||
function setField(obj, dottedKey, value) {
|
||||
if (!dottedKey.includes('.')) { obj[dottedKey] = value; return; }
|
||||
const [a, b] = dottedKey.split('.');
|
||||
if (!obj[a] || typeof obj[a] !== 'object') obj[a] = {};
|
||||
obj[a][b] = value;
|
||||
}
|
||||
|
||||
function versionStr(v) {
|
||||
if (!v) return '';
|
||||
if (v.str) return v.str;
|
||||
return `${v.major ?? 0}.${v.build ?? 0}.${v.patch ?? 0}`;
|
||||
}
|
||||
|
||||
// ========== API helpers ==========
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body) {
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
@@ -198,13 +251,27 @@ async function loadRules() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSensors() {
|
||||
if (sensorsCache.length) return sensorsCache;
|
||||
try {
|
||||
sensorsCache = await api('GET', '/rules/-/sensors');
|
||||
} catch (err) {
|
||||
console.error('Error loading sensors:', err);
|
||||
sensorsCache = [];
|
||||
}
|
||||
return sensorsCache;
|
||||
}
|
||||
|
||||
function filterAndSort(rules) {
|
||||
let filtered = rules;
|
||||
if (currentFilter === 'active') filtered = rules.filter(r => r.active && !r.archived);
|
||||
else if (currentFilter === 'archived') filtered = rules.filter(r => r.archived);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (currentSort === 'version') return (b.version || '').localeCompare(a.version || '', undefined, { numeric: true });
|
||||
if (currentSort === 'version') {
|
||||
const va = versionStr(a.version), vb = versionStr(b.version);
|
||||
return vb.localeCompare(va, undefined, { numeric: true });
|
||||
}
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
return filtered;
|
||||
@@ -215,7 +282,7 @@ function renderGrid() {
|
||||
const rules = filterAndSort(allRules);
|
||||
|
||||
if (rules.length === 0) {
|
||||
grid.innerHTML = '<div class="rs-empty">Nessuna rule trovata</div>';
|
||||
grid.innerHTML = '<div class="rs-empty">Nessuna versione trovata</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,13 +295,14 @@ function renderGrid() {
|
||||
const tags = (r.tags || []).map(t => `<span class="rs-tag">${esc(t)}</span>`).join('');
|
||||
const desc = r.description ? `<div class="rs-card-desc">${esc(r.description)}</div>` : '';
|
||||
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }) : '';
|
||||
const itemsCount = (r.items_count !== undefined ? r.items_count : (r.items?.length || 0));
|
||||
|
||||
return `
|
||||
<div class="rs-card" data-id="${r.id}" onclick="openRuleDetail('${r.id}')">
|
||||
<div class="rs-card" data-id="${esc(r.id)}" onclick="openRuleDetail('${esc(r.id)}')">
|
||||
<div class="rs-card-header">
|
||||
<div>
|
||||
<div class="rs-card-version">v${esc(r.version)}</div>
|
||||
<span class="rs-card-id">${esc(r.id)}</span>
|
||||
<div class="rs-card-version">v${esc(versionStr(r.version))}</div>
|
||||
<span class="rs-card-id" title="${esc(r.id)}">${esc(String(r.id).slice(0,8))}…</span>
|
||||
</div>
|
||||
<div class="rs-card-badges">${badges.join('')}</div>
|
||||
</div>
|
||||
@@ -242,18 +310,12 @@ function renderGrid() {
|
||||
${tags ? `<div class="rs-card-tags">${tags}</div>` : ''}
|
||||
<div class="rs-card-footer">
|
||||
<span class="rs-card-date">${date}</span>
|
||||
<span class="rs-card-items">${itemsCount} items</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ========== Type Picker ==========
|
||||
|
||||
document.querySelectorAll('#typePicker button').forEach(btn => {
|
||||
@@ -285,10 +347,20 @@ document.getElementById('sortSelect').onchange = (e) => {
|
||||
|
||||
document.getElementById('newRuleBtn').onclick = async () => {
|
||||
try {
|
||||
// Calcola una versione libera: prendi la maggiore esistente e incrementa patch
|
||||
let M = 1, B = 0, P = 0;
|
||||
if (allRules.length) {
|
||||
const sorted = [...allRules].sort((a,b) => {
|
||||
const va = a.version, vb = b.version;
|
||||
return (vb.major - va.major) || (vb.build - va.build) || (vb.patch - va.patch);
|
||||
});
|
||||
const top = sorted[0].version;
|
||||
M = top.major; B = top.build; P = Math.min(100, top.patch + 1);
|
||||
if (P === 100 && top.patch === 100) { P = 0; B = Math.min(100, B + 1); }
|
||||
}
|
||||
const rule = await api('POST', `/rules/${currentType}`, {
|
||||
version: '1.0.0',
|
||||
tags: [],
|
||||
description: HAS_DESC[currentType] ? '' : undefined
|
||||
version_major: M, version_build: B, version_patch: P,
|
||||
tags: [], description: '', items: []
|
||||
});
|
||||
allRules.unshift(rule);
|
||||
renderGrid();
|
||||
@@ -296,6 +368,7 @@ document.getElementById('newRuleBtn').onclick = async () => {
|
||||
flash('Salvato');
|
||||
} catch (err) {
|
||||
console.error('Error creating rule:', err);
|
||||
alert(`Errore: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,17 +378,23 @@ async function openRuleDetail(ruleId) {
|
||||
try {
|
||||
const data = await api('GET', `/rules/${currentType}/${ruleId}`);
|
||||
openRule = data;
|
||||
deploymentsForOpen = [];
|
||||
try {
|
||||
deploymentsForOpen = await api('GET', `/rules/${currentType}/${ruleId}/deployments`);
|
||||
} catch {}
|
||||
await loadSensors();
|
||||
renderPopup();
|
||||
document.getElementById('ruleOverlay').style.display = 'flex';
|
||||
} catch (err) {
|
||||
console.error('Error loading rule detail:', err);
|
||||
alert(`Errore: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
document.getElementById('ruleOverlay').style.display = 'none';
|
||||
openRule = null;
|
||||
loadRules(); // refresh grid
|
||||
loadRules();
|
||||
}
|
||||
|
||||
document.getElementById('popupClose').onclick = closePopup;
|
||||
@@ -325,31 +404,24 @@ document.getElementById('ruleOverlay').onclick = (e) => {
|
||||
|
||||
function renderPopup() {
|
||||
const r = openRule;
|
||||
document.getElementById('popupId').textContent = r.id;
|
||||
document.getElementById('popupVersion').value = r.version || '';
|
||||
document.getElementById('popupId').textContent = `${currentType} · ${String(r.id).slice(0,8)}`;
|
||||
document.getElementById('popupVMajor').value = r.version?.major ?? 1;
|
||||
document.getElementById('popupVBuild').value = r.version?.build ?? 0;
|
||||
document.getElementById('popupVPatch').value = r.version?.patch ?? 0;
|
||||
document.getElementById('popupDesc').value = r.description || '';
|
||||
|
||||
// Description
|
||||
const descRow = document.getElementById('popupDescRow');
|
||||
if (HAS_DESC[currentType]) {
|
||||
descRow.style.display = 'flex';
|
||||
document.getElementById('popupDesc').value = r.description || '';
|
||||
} else {
|
||||
descRow.style.display = 'none';
|
||||
}
|
||||
|
||||
// Tags
|
||||
renderTags();
|
||||
|
||||
// Action buttons state
|
||||
updateActionButtons();
|
||||
|
||||
// Items
|
||||
renderItems();
|
||||
renderDeploySensors();
|
||||
document.getElementById('deployResult').textContent = '';
|
||||
}
|
||||
|
||||
// --- Auto-save fields ---
|
||||
|
||||
document.getElementById('popupVersion').oninput = () => debounceFieldSave('version');
|
||||
['popupVMajor','popupVBuild','popupVPatch'].forEach(id => {
|
||||
document.getElementById(id).oninput = () => debounceFieldSave('version');
|
||||
});
|
||||
document.getElementById('popupDesc').oninput = () => debounceFieldSave('description');
|
||||
|
||||
function debounceFieldSave(field) {
|
||||
@@ -360,18 +432,22 @@ function debounceFieldSave(field) {
|
||||
async function saveRuleField(field) {
|
||||
if (!openRule) return;
|
||||
const body = {};
|
||||
if (field === 'version') body.version = document.getElementById('popupVersion').value.trim();
|
||||
if (field === 'description') body.description = document.getElementById('popupDesc').value.trim();
|
||||
if (field === 'version') {
|
||||
body.version_major = parseInt(document.getElementById('popupVMajor').value, 10) || 1;
|
||||
body.version_build = parseInt(document.getElementById('popupVBuild').value, 10) || 0;
|
||||
body.version_patch = parseInt(document.getElementById('popupVPatch').value, 10) || 0;
|
||||
}
|
||||
if (field === 'description') body.description = document.getElementById('popupDesc').value;
|
||||
|
||||
try {
|
||||
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}`, body);
|
||||
Object.assign(openRule, updated);
|
||||
// Update in allRules too
|
||||
const idx = allRules.findIndex(r => r.id === openRule.id);
|
||||
if (idx >= 0) Object.assign(allRules[idx], updated);
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error saving field:', err);
|
||||
flash('Errore: ' + err.message, 'popupSaving');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,9 +456,7 @@ async function saveRuleField(field) {
|
||||
function renderTags() {
|
||||
const wrap = document.getElementById('popupTags');
|
||||
const input = document.getElementById('tagInput');
|
||||
// Remove old chips
|
||||
wrap.querySelectorAll('.rs-tag-chip').forEach(c => c.remove());
|
||||
// Re-add chips before input
|
||||
(openRule.tags || []).forEach((tag, i) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'rs-tag-chip';
|
||||
@@ -404,9 +478,7 @@ document.getElementById('tagInput').onkeydown = async (e) => {
|
||||
openRule.tags = updated.tags;
|
||||
renderTags();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error adding tag:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,9 +491,7 @@ async function removeTag(idx) {
|
||||
openRule.tags = updated.tags;
|
||||
renderTags();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error removing tag:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
}
|
||||
|
||||
// --- Action Buttons ---
|
||||
@@ -430,10 +500,8 @@ function updateActionButtons() {
|
||||
const r = openRule;
|
||||
const activeBtn = document.getElementById('toggleActiveBtn');
|
||||
const archiveBtn = document.getElementById('toggleArchiveBtn');
|
||||
|
||||
activeBtn.textContent = r.active ? 'Disattiva' : 'Attiva';
|
||||
activeBtn.className = `rs-action-btn active-toggle${r.active ? '' : ' off'}`;
|
||||
|
||||
archiveBtn.textContent = r.archived ? 'Dearchivia' : 'Archivia';
|
||||
archiveBtn.className = `rs-action-btn archive-toggle${r.archived ? ' on' : ''}`;
|
||||
}
|
||||
@@ -443,10 +511,12 @@ document.getElementById('toggleActiveBtn').onclick = async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/active`);
|
||||
openRule.active = res.active;
|
||||
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||
updateActionButtons();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error toggling active:', err);
|
||||
console.error(err);
|
||||
alert(`Errore: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -455,23 +525,20 @@ document.getElementById('toggleArchiveBtn').onclick = async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/archive`);
|
||||
openRule.archived = res.archived;
|
||||
if (res.ruleset) Object.assign(openRule, res.ruleset);
|
||||
updateActionButtons();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error toggling archive:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||
};
|
||||
|
||||
document.getElementById('deleteRuleBtn').onclick = () => {
|
||||
if (!openRule) return;
|
||||
showConfirm('Elimina Rule', `Vuoi eliminare la rule ${openRule.id}? Questa azione e irreversibile.`, async () => {
|
||||
showConfirm('Elimina Versione', `Eliminare la versione v${versionStr(openRule.version)}? Questa azione e irreversibile.`, async () => {
|
||||
try {
|
||||
await api('DELETE', `/rules/${currentType}/${openRule.id}`);
|
||||
allRules = allRules.filter(r => r.id !== openRule.id);
|
||||
closePopup();
|
||||
} catch (err) {
|
||||
console.error('Error deleting rule:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); alert(`Errore: ${err.message}`); }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -481,95 +548,152 @@ function renderItems() {
|
||||
const schema = ITEM_SCHEMA[currentType];
|
||||
const items = openRule.items || [];
|
||||
|
||||
// Labels row
|
||||
const labelsRow = document.getElementById('itemLabelsRow');
|
||||
labelsRow.innerHTML = schema.map(f => `<span class="${f.cls}">${f.label}</span>`).join('') +
|
||||
'<span class="toggle-space">On</span><span class="delete-space"></span>';
|
||||
|
||||
// Items list
|
||||
const list = document.getElementById('itemsList');
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi" per crearne uno.</div>';
|
||||
list.innerHTML = '<div style="padding:12px;font-size:0.8rem;color:var(--text-tertiary);">Nessun item. Clicca "+ Aggiungi".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => {
|
||||
const fields = schema.map(f =>
|
||||
`<input class="rs-item-field ${f.cls}" value="${esc(String(item[f.key] || ''))}" data-field="${f.key}" data-item-id="${item.id}" onchange="saveItemField(this)" />`
|
||||
`<input class="rs-item-field ${f.cls}"
|
||||
value="${esc(getField(item, f.key) ?? '')}"
|
||||
data-field="${f.key}"
|
||||
data-ref="${esc(item.ref)}"
|
||||
onchange="saveItemField(this)" />`
|
||||
).join('');
|
||||
|
||||
const toggleCls = item.enabled ? 'on' : '';
|
||||
return `<div class="rs-item" data-item-id="${item.id}">
|
||||
const toggleCls = item.enabled !== false ? 'on' : '';
|
||||
return `<div class="rs-item" data-ref="${esc(item.ref)}">
|
||||
${fields}
|
||||
<div class="rs-toggle ${toggleCls}" onclick="toggleItem(${item.id})"></div>
|
||||
<button class="rs-item-delete" onclick="deleteItem(${item.id})">×</button>
|
||||
<div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
|
||||
<button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('addItemBtn').onclick = async () => {
|
||||
if (!openRule) return;
|
||||
const schema = ITEM_SCHEMA[currentType];
|
||||
const body = {};
|
||||
// Fill with empty/default values
|
||||
schema.forEach(f => { body[f.key] = ''; });
|
||||
|
||||
// Need at least non-empty values — open with placeholders
|
||||
// For now, create with placeholder values
|
||||
schema.forEach(f => { body[f.key] = f.key === 'enabled' ? true : '-'; });
|
||||
|
||||
const ref = prompt('Ref dell\'item (identificatore stabile, es. "batt_traction_soc"):');
|
||||
if (!ref) return;
|
||||
try {
|
||||
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, body);
|
||||
const item = await api('POST', `/rules/${currentType}/${openRule.id}/items`, {
|
||||
ref: ref.trim(), path: '', enabled: true, meta: {}
|
||||
});
|
||||
if (!openRule.items) openRule.items = [];
|
||||
openRule.items.push(item);
|
||||
renderItems();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error adding item:', err);
|
||||
console.error(err);
|
||||
alert(`Errore: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function saveItemField(input) {
|
||||
if (!openRule) return;
|
||||
const itemId = input.dataset.itemId;
|
||||
const ref = input.dataset.ref;
|
||||
const field = input.dataset.field;
|
||||
const value = input.value.trim();
|
||||
const value = input.value;
|
||||
|
||||
const item = (openRule.items || []).find(i => i.ref === ref);
|
||||
if (!item) return;
|
||||
|
||||
// costruisci body rispettando ref/path/enabled oppure meta.<x>
|
||||
const body = {};
|
||||
if (field === 'ref') body.ref = value.trim();
|
||||
else if (field === 'path') body.path = value;
|
||||
else if (field === 'enabled') body.enabled = !!value;
|
||||
else if (field.startsWith('meta.')) {
|
||||
const metaKey = field.slice(5);
|
||||
body.meta = { ...(item.meta || {}), [metaKey]: value };
|
||||
}
|
||||
|
||||
try {
|
||||
await api('PUT', `/rules/${currentType}/${openRule.id}/items/${itemId}`, { [field]: value });
|
||||
// Update local
|
||||
const item = openRule.items.find(i => String(i.id) === String(itemId));
|
||||
if (item) item[field] = value;
|
||||
const updated = await api('PUT', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`, body);
|
||||
// replace item in place
|
||||
const idx = openRule.items.findIndex(i => i.ref === ref);
|
||||
if (idx >= 0) openRule.items[idx] = updated;
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error saving item field:', err);
|
||||
console.error(err);
|
||||
flash('Errore', 'popupSaving');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleItem(itemId) {
|
||||
async function toggleItem(ref) {
|
||||
if (!openRule) return;
|
||||
try {
|
||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${itemId}/toggle`);
|
||||
const item = openRule.items.find(i => i.id === itemId);
|
||||
const res = await api('PATCH', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}/toggle`);
|
||||
const item = openRule.items.find(i => i.ref === ref);
|
||||
if (item) item.enabled = res.enabled;
|
||||
renderItems();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error toggling item:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
}
|
||||
|
||||
async function deleteItem(itemId) {
|
||||
async function deleteItem(ref) {
|
||||
if (!openRule) return;
|
||||
try {
|
||||
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${itemId}`);
|
||||
openRule.items = openRule.items.filter(i => i.id !== itemId);
|
||||
await api('DELETE', `/rules/${currentType}/${openRule.id}/items/${encodeURIComponent(ref)}`);
|
||||
openRule.items = openRule.items.filter(i => i.ref !== ref);
|
||||
renderItems();
|
||||
flash('Salvato', 'popupSaving');
|
||||
} catch (err) {
|
||||
console.error('Error deleting item:', err);
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
}
|
||||
|
||||
// --- Deploy ---
|
||||
|
||||
function renderDeploySensors() {
|
||||
const wrap = document.getElementById('deploySensorsList');
|
||||
if (!sensorsCache.length) {
|
||||
wrap.innerHTML = '<div class="rs-empty" style="padding:8px">Nessun sensore registrato</div>';
|
||||
return;
|
||||
}
|
||||
const byName = Object.fromEntries(deploymentsForOpen.map(d => [d.sensor_name, d]));
|
||||
wrap.innerHTML = sensorsCache.map(s => {
|
||||
const d = byName[s.name];
|
||||
let status = '';
|
||||
if (d) {
|
||||
status = d.acked_at
|
||||
? `<span class="rs-deploy-status ok">ACK ${new Date(d.acked_at).toLocaleString('it-IT')}</span>`
|
||||
: `<span class="rs-deploy-status pending">In attesa…</span>`;
|
||||
}
|
||||
return `<label class="rs-deploy-item">
|
||||
<input type="checkbox" class="rs-deploy-check" value="${esc(s.name)}" />
|
||||
<span class="rs-deploy-name">${esc(s.name)}</span>
|
||||
${status}
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('deployBtn').onclick = async () => {
|
||||
if (!openRule) return;
|
||||
if (openRule.archived) { alert('Non puoi deployare una versione archiviata'); return; }
|
||||
const checks = [...document.querySelectorAll('.rs-deploy-check:checked')];
|
||||
const sensors = checks.map(c => c.value);
|
||||
if (!sensors.length) { alert('Seleziona almeno un sensore'); return; }
|
||||
const resultEl = document.getElementById('deployResult');
|
||||
resultEl.textContent = 'Invio...';
|
||||
try {
|
||||
const res = await api('POST', `/rules/${currentType}/${openRule.id}/deploy`, { sensors });
|
||||
const parts = [];
|
||||
if (res.pushed?.length) parts.push(`${res.pushed.length} online`);
|
||||
if (res.offline?.length) parts.push(`${res.offline.length} offline`);
|
||||
if (res.errors?.length) parts.push(`${res.errors.length} errori`);
|
||||
resultEl.textContent = parts.join(' · ') || 'OK';
|
||||
// refresh deployments
|
||||
try { deploymentsForOpen = await api('GET', `/rules/${currentType}/${openRule.id}/deployments`); renderDeploySensors(); } catch {}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
resultEl.textContent = `Errore: ${err.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Confirm Dialog ==========
|
||||
|
||||
let confirmCallback = null;
|
||||
@@ -592,7 +716,7 @@ document.getElementById('confirmOk').onclick = async () => {
|
||||
confirmCallback = null;
|
||||
};
|
||||
|
||||
// ========== Flash "Salvato" indicator ==========
|
||||
// ========== Flash ==========
|
||||
|
||||
function flash(text, elId = 'savingIndicator') {
|
||||
const el = document.getElementById(elId);
|
||||
@@ -603,18 +727,10 @@ function flash(text, elId = 'savingIndicator') {
|
||||
|
||||
// ========== Init ==========
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => loadRules());
|
||||
|
||||
</script>
|
||||
|
||||
<script src="/static/theme-toggle.js"></script>
|
||||
<script>
|
||||
// Theme toggle button event listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', toggleDarkMode);
|
||||
}
|
||||
loadRules();
|
||||
loadSensors();
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -244,16 +244,6 @@
|
||||
.map-bar .filter button { color: #cbd5e1; }
|
||||
.map-bar .filter button.active { background: #3b82f6; color: #fff; }
|
||||
</style>
|
||||
<script>
|
||||
// Detect and apply dark mode immediately to prevent flash
|
||||
const THEME_KEY = 'meb-console-theme';
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
const isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -289,7 +279,6 @@
|
||||
<h1>Sessioni</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-toggle-btn" title="Dark Mode" style="padding: 8px 12px; font-size: 18px;">🌙</button>
|
||||
<span id="sessionNameDisplay"></span>
|
||||
<span id="sessionMetaDisplay"></span>
|
||||
</div>
|
||||
@@ -929,16 +918,5 @@ document.getElementById('changeSessionBtn').onclick = () => {
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', loadSessionsList);
|
||||
</script>
|
||||
|
||||
<script src="/static/theme-toggle.js"></script>
|
||||
<script>
|
||||
// Theme toggle button event listener
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', toggleDarkMode);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,4 +23,26 @@
|
||||
|
||||
.card[title="Live"]:hover::before {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.category {
|
||||
padding-bottom: 8%;
|
||||
}
|
||||
|
||||
.category h2 {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 20px 20px;
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 10px;
|
||||
font-weight:900
|
||||
}
|
||||
|
||||
.category section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-inline: 30px;
|
||||
}
|
||||
@@ -769,3 +769,81 @@
|
||||
.rs-back:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Version number inputs (major.build.patch) ── */
|
||||
.rs-version-inputs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.rs-version-num {
|
||||
width: 48px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
background: var(--input-bg, #fff);
|
||||
color: var(--text-primary);
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.rs-version-num::-webkit-outer-spin-button,
|
||||
.rs-version-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.rs-version-dot {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rs-card-items {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
/* ── Deploy section ── */
|
||||
.rs-deploy-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.rs-deploy-sensors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg, #fafafa);
|
||||
}
|
||||
.rs-deploy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.rs-deploy-item:hover { background: rgba(0,0,0,0.03); }
|
||||
.rs-deploy-name { flex: 1; font-weight: 500; color: var(--text-primary); }
|
||||
.rs-deploy-check { width: 16px; height: 16px; cursor: pointer; }
|
||||
.rs-deploy-status {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rs-deploy-status.ok { background: #dcfce7; color: #166534; }
|
||||
.rs-deploy-status.pending { background: #fef3c7; color: #92400e; }
|
||||
|
||||
.rs-deploy-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.rs-deploy-result {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
:root {
|
||||
--accent-color: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-light: #eff6ff;
|
||||
--accent-light: #dce6f3;
|
||||
--accent-border: #bfdbfe;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #4755698f;
|
||||
--text-tertiary: #94a3b8c0;
|
||||
|
||||
--surface: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
|
||||
--header-bg: rgba(255, 255, 255, 0.85);
|
||||
/* For Glassmorphism */
|
||||
--header-border: #e2e8f0;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
@@ -32,7 +31,7 @@
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
|
||||
--surface: #0f172a;
|
||||
--surface: #000000;
|
||||
|
||||
--header-bg: rgba(15, 23, 42, 0.85);
|
||||
--header-border: #334155;
|
||||
@@ -42,26 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Manual dark mode toggle */
|
||||
body.dark-mode {
|
||||
--accent-color: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-light: #1e3a8a;
|
||||
--accent-border: #1e40af;
|
||||
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
|
||||
--surface: #0f172a;
|
||||
|
||||
--header-bg: rgba(15, 23, 42, 0.85);
|
||||
--header-border: #334155;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Smooth transition for dark mode */
|
||||
body {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
@@ -192,7 +171,6 @@ button.prominent:active {
|
||||
.card p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.4;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -206,7 +184,34 @@ button.prominent:active {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card.disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
/* No global opacity on the container to allow badge to remain fully visible */
|
||||
}
|
||||
|
||||
/* Dim specific internals to 50% while keeping the badge fully opaque */
|
||||
.card.disabled h3,
|
||||
.card.disabled p,
|
||||
.card.disabled .page-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card.disabled .badge {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.card .badge {
|
||||
background-color: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Dark Mode Theme Toggle
|
||||
* Manages light/dark theme with localStorage persistence
|
||||
*/
|
||||
|
||||
const THEME_STORAGE_KEY = 'meb-console-theme';
|
||||
const DARK_MODE_CLASS = 'dark-mode';
|
||||
|
||||
/**
|
||||
* Initialize theme on page load
|
||||
*/
|
||||
function initializeTheme() {
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Use saved preference, or fallback to system preference
|
||||
const shouldBeDark = savedTheme ? savedTheme === 'dark' : prefersDarkMode;
|
||||
|
||||
if (shouldBeDark) {
|
||||
enableDarkMode();
|
||||
} else {
|
||||
disableDarkMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable dark mode
|
||||
*/
|
||||
function enableDarkMode() {
|
||||
document.documentElement.classList.add(DARK_MODE_CLASS);
|
||||
document.body.classList.add(DARK_MODE_CLASS);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'dark');
|
||||
updateThemeToggleButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable dark mode (light mode)
|
||||
*/
|
||||
function disableDarkMode() {
|
||||
document.documentElement.classList.remove(DARK_MODE_CLASS);
|
||||
document.body.classList.remove(DARK_MODE_CLASS);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, 'light');
|
||||
updateThemeToggleButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode
|
||||
*/
|
||||
function toggleDarkMode() {
|
||||
const isDarkMode = document.body.classList.contains(DARK_MODE_CLASS);
|
||||
|
||||
if (isDarkMode) {
|
||||
disableDarkMode();
|
||||
} else {
|
||||
enableDarkMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update theme toggle button appearance
|
||||
*/
|
||||
function updateThemeToggleButton() {
|
||||
const themeBtn = document.getElementById('theme-toggle-btn');
|
||||
if (themeBtn) {
|
||||
const isDarkMode = document.body.classList.contains(DARK_MODE_CLASS);
|
||||
themeBtn.textContent = isDarkMode ? '☀️' : '🌙';
|
||||
themeBtn.title = isDarkMode ? 'Light Mode' : 'Dark Mode';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for system theme preference changes
|
||||
*/
|
||||
function listenToSystemThemeChanges() {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
|
||||
// If no manual preference set, follow system preference
|
||||
e.matches ? enableDarkMode() : disableDarkMode();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', initializeTheme);
|
||||
listenToSystemThemeChanges();
|
||||
Reference in New Issue
Block a user