Add secure 6h timeline with CSV merge and timestamped serial events

This commit is contained in:
2026-02-22 02:11:31 +01:00
parent 680ed7044e
commit b5f34868d1
9 changed files with 1315 additions and 23 deletions

264
templates/timeline.html Normal file
View 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>