Add secure 6h timeline with CSV merge and timestamped serial events
This commit is contained in:
@@ -34,7 +34,8 @@
|
||||
|
||||
<section class="card">
|
||||
<h2>Serial Monitor</h2>
|
||||
<a href="/serial">Zur Live-Serial-Seite</a>
|
||||
<p><a href="/serial">Zur Live-Serial-Seite</a></p>
|
||||
<p><a href="/timeline">Zur 6h Timeline + CSV Merge</a></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>ESP32 Serial Live</h1>
|
||||
<p><a href="/">Zurück zum WLAN-Portal</a></p>
|
||||
<p><a href="/">Zurueck zum WLAN-Portal</a> | <a href="/timeline">Zur Timeline</a></p>
|
||||
<pre id="terminal" class="terminal"></pre>
|
||||
</main>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
events.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(evt.data);
|
||||
appendLine(payload.line || '');
|
||||
const ts = payload.ts_hms ? `[${payload.ts_hms}] ` : '';
|
||||
appendLine(`${ts}${payload.line || ''}`);
|
||||
} catch (e) {
|
||||
appendLine(evt.data || '');
|
||||
}
|
||||
|
||||
264
templates/timeline.html
Normal file
264
templates/timeline.html
Normal file
@@ -0,0 +1,264 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Timeline</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container timeline-page">
|
||||
<h1>Timeline der letzten 6 Stunden</h1>
|
||||
<p><a href="/">Zurueck zum WLAN-Portal</a> | <a href="/serial">Zur Live-Serial-Seite</a></p>
|
||||
|
||||
<section class="card">
|
||||
<h2>CSV Upload</h2>
|
||||
<form id="uploadForm" class="timeline-upload-row">
|
||||
<input id="csvFile" type="file" accept=".csv,text/csv" required>
|
||||
<button type="submit">CSV hochladen</button>
|
||||
</form>
|
||||
<p class="hint">Limit: {{ max_upload_mib }} MiB pro Datei, maximal {{ max_upload_files }} Uploads.</p>
|
||||
<div id="uploadMsg" class="hint"></div>
|
||||
|
||||
<label for="uploadSelect">Aktive CSV fuer Merge</label>
|
||||
<select id="uploadSelect"></select>
|
||||
|
||||
<div class="button-row timeline-actions">
|
||||
<button id="refreshBtn" type="button">Timeline aktualisieren</button>
|
||||
<button id="deleteBtn" type="button" class="btn-danger">Ausgewaehlte CSV loeschen</button>
|
||||
</div>
|
||||
|
||||
<div class="download-links">
|
||||
<a id="downloadSerial" href="#">Serial Timeline herunterladen</a>
|
||||
<a id="downloadMerged" href="#">Merged Timeline herunterladen</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="timeline-grid">
|
||||
<section class="card timeline-panel">
|
||||
<h2>Serial</h2>
|
||||
<div id="serialCount" class="hint"></div>
|
||||
<pre id="serialPane" class="terminal timeline-terminal"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card timeline-panel">
|
||||
<h2>CSV</h2>
|
||||
<div id="csvCount" class="hint"></div>
|
||||
<div id="csvPane" class="timeline-table-wrap"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
const csvFileInput = document.getElementById('csvFile');
|
||||
const uploadMsg = document.getElementById('uploadMsg');
|
||||
const uploadSelect = document.getElementById('uploadSelect');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const deleteBtn = document.getElementById('deleteBtn');
|
||||
const downloadSerial = document.getElementById('downloadSerial');
|
||||
const downloadMerged = document.getElementById('downloadMerged');
|
||||
const serialPane = document.getElementById('serialPane');
|
||||
const csvPane = document.getElementById('csvPane');
|
||||
const serialCount = document.getElementById('serialCount');
|
||||
const csvCount = document.getElementById('csvCount');
|
||||
|
||||
function selectedUploadId() {
|
||||
return uploadSelect.value || '';
|
||||
}
|
||||
|
||||
function updateDownloadLinks() {
|
||||
const uploadId = selectedUploadId();
|
||||
const serialUrl = new URL('/api/timeline/download', window.location.origin);
|
||||
serialUrl.searchParams.set('kind', 'serial');
|
||||
serialUrl.searchParams.set('hours', '6');
|
||||
serialUrl.searchParams.set('csrf_token', csrfToken);
|
||||
downloadSerial.href = serialUrl.toString();
|
||||
|
||||
const mergedUrl = new URL('/api/timeline/download', window.location.origin);
|
||||
mergedUrl.searchParams.set('kind', 'merged');
|
||||
mergedUrl.searchParams.set('hours', '6');
|
||||
if (uploadId) {
|
||||
mergedUrl.searchParams.set('upload_id', uploadId);
|
||||
}
|
||||
mergedUrl.searchParams.set('csrf_token', csrfToken);
|
||||
downloadMerged.href = mergedUrl.toString();
|
||||
}
|
||||
|
||||
async function fetchUploads() {
|
||||
const resp = await fetch('/api/timeline/uploads', { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
uploadSelect.innerHTML = '';
|
||||
|
||||
const none = document.createElement('option');
|
||||
none.value = '';
|
||||
none.textContent = 'Keine CSV ausgewaehlt';
|
||||
uploadSelect.appendChild(none);
|
||||
|
||||
if (data.ok && Array.isArray(data.uploads)) {
|
||||
for (const item of data.uploads) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.id;
|
||||
const kb = Math.round((item.size || 0) / 1024);
|
||||
opt.textContent = `${item.filename} (${kb} KB, ${item.time_column})`;
|
||||
uploadSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
updateDownloadLinks();
|
||||
}
|
||||
|
||||
function renderSerial(rows) {
|
||||
const serialLines = rows.map(row => `[${row.ts_hms}] ${row.line || ''}`);
|
||||
serialPane.textContent = serialLines.join('\n');
|
||||
serialPane.scrollTop = serialPane.scrollHeight;
|
||||
serialCount.textContent = `${rows.length} Serial-Ereignisse in den letzten 6 Stunden`;
|
||||
}
|
||||
|
||||
function renderCsvTable(columns, rows) {
|
||||
csvCount.textContent = `${rows.length} CSV-Ereignisse in den letzten 6 Stunden`;
|
||||
if (!rows.length) {
|
||||
csvPane.innerHTML = '<p class="hint">Keine CSV-Daten im Zeitraum vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'timeline-table';
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
|
||||
const tsHead = document.createElement('th');
|
||||
tsHead.textContent = 'Zeit';
|
||||
headRow.appendChild(tsHead);
|
||||
for (const col of columns) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = col;
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const tsCell = document.createElement('td');
|
||||
tsCell.textContent = row.ts_hms || '';
|
||||
tr.appendChild(tsCell);
|
||||
|
||||
const values = row.csv_values || {};
|
||||
for (const col of columns) {
|
||||
const td = document.createElement('td');
|
||||
const value = values[col];
|
||||
td.textContent = value == null ? '' : String(value);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
|
||||
csvPane.innerHTML = '';
|
||||
csvPane.appendChild(table);
|
||||
}
|
||||
|
||||
async function refreshTimeline() {
|
||||
const uploadId = selectedUploadId();
|
||||
const url = new URL('/api/timeline', window.location.origin);
|
||||
url.searchParams.set('hours', '6');
|
||||
if (uploadId) {
|
||||
url.searchParams.set('upload_id', uploadId);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error(data.message || 'Timeline konnte nicht geladen werden');
|
||||
}
|
||||
renderSerial(data.serial || []);
|
||||
renderCsvTable(data.csv_columns || [], data.csv || []);
|
||||
} catch (err) {
|
||||
serialPane.textContent = '';
|
||||
csvPane.innerHTML = '';
|
||||
serialCount.textContent = '';
|
||||
csvCount.textContent = '';
|
||||
uploadMsg.textContent = err.message || 'Timeline konnte nicht geladen werden';
|
||||
}
|
||||
updateDownloadLinks();
|
||||
}
|
||||
|
||||
uploadForm.addEventListener('submit', async (evt) => {
|
||||
evt.preventDefault();
|
||||
const file = csvFileInput.files && csvFileInput.files[0];
|
||||
if (!file) {
|
||||
uploadMsg.textContent = 'Bitte zuerst eine CSV-Datei auswaehlen.';
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('csrf_token', csrfToken);
|
||||
|
||||
uploadMsg.textContent = 'Upload laeuft...';
|
||||
try {
|
||||
const resp = await fetch('/api/timeline/uploads', {
|
||||
method: 'POST',
|
||||
body,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error(data.message || 'Upload fehlgeschlagen');
|
||||
}
|
||||
uploadMsg.textContent = `Upload erfolgreich: ${data.upload.filename}`;
|
||||
await fetchUploads();
|
||||
uploadSelect.value = data.upload.id;
|
||||
await refreshTimeline();
|
||||
} catch (err) {
|
||||
uploadMsg.textContent = err.message || 'Upload fehlgeschlagen';
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const uploadId = selectedUploadId();
|
||||
if (!uploadId) {
|
||||
uploadMsg.textContent = 'Bitte zuerst eine CSV-Datei waehlen.';
|
||||
return;
|
||||
}
|
||||
const yes = window.confirm('Die ausgewaehlte CSV wirklich loeschen?');
|
||||
if (!yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/timeline/uploads/${encodeURIComponent(uploadId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
throw new Error(data.message || 'Loeschen fehlgeschlagen');
|
||||
}
|
||||
uploadMsg.textContent = 'CSV wurde geloescht.';
|
||||
await fetchUploads();
|
||||
await refreshTimeline();
|
||||
} catch (err) {
|
||||
uploadMsg.textContent = err.message || 'Loeschen fehlgeschlagen';
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', refreshTimeline);
|
||||
uploadSelect.addEventListener('change', refreshTimeline);
|
||||
|
||||
(async () => {
|
||||
await fetchUploads();
|
||||
await refreshTimeline();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user