265 lines
9.1 KiB
HTML
265 lines
9.1 KiB
HTML
<!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>
|