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:
Giuseppe Raffa
2026-04-28 09:24:38 +02:00
parent ee478e52ef
commit 0ce879aa44
81 changed files with 7491 additions and 746 deletions

View File

@@ -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

View 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>

View 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 &nbsp; <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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

View File

@@ -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})">&times;</button>
<div class="rs-toggle ${toggleCls}" onclick="toggleItem('${esc(item.ref)}')"></div>
<button class="rs-item-delete" onclick="deleteItem('${esc(item.ref)}')">&times;</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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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();