Sicherheits-Härtung und Bugfixes

This commit is contained in:
Patrick Asmus
2026-10-11 17:39:03 +02:00
parent c3f68ace96
commit 65b0b5ac0f
25 changed files with 2577 additions and 686 deletions
+358 -145
View File
@@ -256,7 +256,7 @@
.log-time { color: var(--text-dim); white-space: nowrap; }
.log-level { font-weight: 600; min-width: 42px; }
.log-level.INFO { color: var(--accent); }
.log-level.WARN { color: var(--warning); }
.log-level.WARN, .log-level.WARNING { color: var(--warning); }
.log-level.ERROR { color: var(--error); }
.log-level.DEBUG { color: var(--text-dim); }
.log-msg { color: var(--text); word-break: break-all; }
@@ -437,11 +437,11 @@
<h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
<button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">&#9776;</button>
<div class="tabs" id="navTabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" data-tab="stations" onclick="switchTab('stations')">Sender</button>
<button class="tab" data-tab="logs" onclick="switchTab('logs')">Logs</button>
<button class="tab" data-tab="settings" onclick="switchTab('settings')">Einstellungen</button>
</div>
</header>
@@ -727,15 +727,16 @@
<div class="modal" style="max-width:400px">
<div class="modal-title">Aufnahme verlängern</div>
<input type="hidden" id="extend-rec-id">
<p style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p>
<div class="extend-options">
<p id="extend-question" style="color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">Wie lange soll die Aufnahme verlängert werden?</p>
<p id="extend-unlimited-info" style="display:none;color:var(--text-dim);font-size:0.85rem;margin-bottom:12px">&#8734; Diese Aufnahme ist bereits unbegrenzt und kann nicht weiter verlängert werden.</p>
<div class="extend-options" id="extend-options">
<button class="btn" onclick="doExtend(15)">+15 Min</button>
<button class="btn" onclick="doExtend(30)">+30 Min</button>
<button class="btn" onclick="doExtend(60)">+1 Std</button>
<button class="btn" onclick="doExtend(120)">+2 Std</button>
<button class="btn btn-success" onclick="doUnlimited()">Unbegrenzt</button>
</div>
<div style="margin-top:16px">
<div style="margin-top:16px" id="extend-custom-group">
<label style="font-size:0.8rem;color:var(--text-dim)">Eigene Dauer (Minuten)</label>
<div style="display:flex;gap:8px;margin-top:4px">
<input type="number" id="extend-custom" placeholder="z.B. 45" style="flex:1">
@@ -766,16 +767,46 @@
const API = '';
let stationsCache = [];
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
let authLost = false;
function formatErrorDetail(detail) {
if (Array.isArray(detail)) {
return detail.map(item => {
if (item && typeof item === 'object') {
const loc = Array.isArray(item.loc) ? item.loc.slice(1).join('.') : '';
const msg = item.msg != null ? String(item.msg) : '';
return loc ? `${loc}: ${msg}` : msg;
}
return String(item);
}).join('\n');
}
return res.json();
if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object') return detail.msg ? String(detail.msg) : JSON.stringify(detail);
return '';
}
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (opts.body !== undefined && opts.body !== null && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
let res;
try {
res = await fetch(API + path, { ...opts, headers });
} catch (e) {
throw new Error('Server nicht erreichbar');
}
if (!res.ok) {
if (res.status === 401) {
authLost = true;
throw new Error('Nicht angemeldet – Seite neu laden');
}
if (res.status === 429) throw new Error('Zu viele Fehlversuche, bitte später erneut versuchen');
const err = await res.json().catch(() => ({}));
throw new Error(formatErrorDetail(err.detail) || res.statusText || ('HTTP ' + res.status));
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
// Burger menu
@@ -786,20 +817,11 @@ function toggleBurger() {
// Tabs
function switchTab(name) {
document.getElementById('navTabs').classList.remove('open');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
const panel = document.getElementById('panel-' + name);
if (!panel) return;
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active');
const tabLabel = {
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(tabLabel))
t.classList.add('active');
});
panel.classList.add('active');
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
@@ -807,18 +829,35 @@ function switchTab(name) {
}
// Dashboard
async function loadDashboard() {
try {
const active = await api('/api/recordings?active_only=true');
const recent = await api('/api/recordings?limit=20');
let dashInFlight = false;
let dashPending = false;
renderActive(active);
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
async function loadDashboard() {
if (dashInFlight) { dashPending = true; return; }
dashInFlight = true;
try {
const [active, recent] = await Promise.all([
api('/api/recordings?active_only=true'),
api('/api/recordings?limit=20'),
]);
renderActive(active || []);
renderRecent((recent || []).filter(r => r.status !== 'running' && r.status !== 'starting'));
} catch (e) {
console.error(e);
} finally {
dashInFlight = false;
if (dashPending) {
dashPending = false;
loadDashboard();
}
}
}
function pollDashboard() {
if (document.hidden || authLost) return;
loadDashboard();
}
function renderActive(recs) {
const el = document.getElementById('activeRecordings');
if (!recs.length) {
@@ -826,8 +865,11 @@ function renderActive(recs) {
return;
}
el.innerHTML = recs.map(r => {
const remainClass = r.remaining !== null
? (r.remaining < 300 ? 'critical' : r.remaining < 900 ? 'low' : '')
const id = intId(r.id);
const hasLimit = r.remaining !== null && r.remaining !== undefined;
const remaining = Number(r.remaining);
const remainClass = hasLimit
? (remaining < 300 ? 'critical' : remaining < 900 ? 'low' : '')
: '';
return `
<div class="card">
@@ -835,19 +877,19 @@ function renderActive(recs) {
<div class="card-title">
<span class="rec-dot"></span>
${esc(r.job_name)}
<span class="badge badge-${r.status}">${r.status}</span>
<span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div>
<div class="actions">
<button class="btn btn-sm btn-success" onclick="openExtend(${r.id})">Verlängern</button>
<button class="btn btn-sm btn-danger" onclick="stopRec(${r.id})">Stoppen</button>
</div>
${id !== null ? `<div class="actions">
<button class="btn btn-sm btn-success" onclick="openExtend(${id}, ${hasLimit ? 'true' : 'false'})">Verlängern</button>
<button class="btn btn-sm btn-danger" onclick="stopRec(${id})">Stoppen</button>
</div>` : ''}
</div>
<div class="meta">
<span class="meta-item">&#128339; ${r.elapsed_display}</span>
<span class="meta-item">&#128190; ${r.total_size_display}</span>
${r.segment_count !== null ? `<span class="meta-item">&#128196; ${r.segment_count} Segmente</span>` : ''}
${r.remaining !== null ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${r.remaining_display}</span></span>` : '<span class="meta-item">&#8734; Unbegrenzt</span>'}
<span class="meta-item">&#128339; ${esc(r.elapsed_display)}</span>
<span class="meta-item">&#128190; ${esc(r.total_size_display)}</span>
${r.segment_count != null ? `<span class="meta-item">&#128196; ${esc(r.segment_count)} Segmente</span>` : ''}
${hasLimit ? `<span class="meta-item">Verbleibend: <span class="remaining ${remainClass}">${esc(r.remaining_display)}</span></span>` : '<span class="meta-item">&#8734; Unbegrenzt</span>'}
</div>
<div class="meta" style="margin-top:4px">
<span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span>
@@ -863,19 +905,20 @@ function renderRecent(recs) {
return;
}
el.innerHTML = recs.slice(0, 10).map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '';
const started = fmtDate(r.started_at, '');
const id = intId(r.id);
return `
<div class="card card-recent" style="padding:12px" onclick="openRecDetail(${r.id})">
<div class="card card-recent" style="padding:12px"${id !== null ? ` onclick="openRecDetail(${id})"` : ''}>
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-weight:500">${esc(r.job_name)}</span>
<span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</span>
<span class="badge ${statusClass(r.status)}" style="margin-left:8px">${esc(r.status)}</span>
${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">&#128228; Plik</span>' : ''}
</div>
<div class="recent-meta">
<span class="rm-dur">&#128339; ${r.elapsed_display}</span>
<span class="rm-size">&#128190; ${r.total_size_display}</span>
<span class="rm-date">${started}</span>
<span class="rm-dur">&#128339; ${esc(r.elapsed_display)}</span>
<span class="rm-size">&#128190; ${esc(r.total_size_display)}</span>
<span class="rm-date">${esc(started)}</span>
</div>
</div>
</div>`;
@@ -884,22 +927,28 @@ function renderRecent(recs) {
async function openRecDetail(recId) {
try {
const r = await api('/api/recordings/' + recId);
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—';
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—';
const fileName = r.output_file ? r.output_file.split('/').pop() : null;
const id = intId(recId);
if (id === null) return;
const r = await api('/api/recordings/' + id);
const started = fmtDate(r.started_at, '—');
const stopped = fmtDate(r.stopped_at, '—');
const fileName = r.output_file ? String(r.output_file).split('/').pop() : null;
document.getElementById('recDetailTitle').textContent = r.job_name;
document.getElementById('recDetailTitle').textContent = r.job_name || '';
document.getElementById('recDetailBadges').innerHTML =
`<span class="badge badge-${r.status}">${r.status}</span>` +
`<span class="badge ${statusClass(r.status)}">${esc(r.status)}</span>` +
(r.plik_url ? ' <span class="badge badge-completed">&#128228; Plik</span>' : '');
let html = '';
if (r.plik_url) {
const plikUrl = String(r.plik_url);
const plikValue = /^https?:\/\//i.test(plikUrl)
? `<a class="rec-plik-link" href="${esc(plikUrl)}" target="_blank" rel="noopener noreferrer">&#128279; Im Browser öffnen</a>`
: esc(plikUrl);
html += `<div class="rec-detail-item full">
<span class="rec-detail-label">Plik-Download</span>
<span class="rec-detail-value"><a class="rec-plik-link" href="${esc(r.plik_url)}" target="_blank" rel="noopener">&#128279; Im Browser öffnen</a></span>
<span class="rec-detail-value">${plikValue}</span>
</div>
<hr class="rec-detail-divider">`;
}
@@ -907,19 +956,19 @@ async function openRecDetail(recId) {
html += `
<div class="rec-detail-item">
<span class="rec-detail-label">Gestartet</span>
<span class="rec-detail-value">${started}</span>
<span class="rec-detail-value">${esc(started)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Beendet</span>
<span class="rec-detail-value">${stopped}</span>
<span class="rec-detail-value">${esc(stopped)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dauer</span>
<span class="rec-detail-value">${r.elapsed_display}</span>
<span class="rec-detail-value">${esc(r.elapsed_display)}</span>
</div>
<div class="rec-detail-item">
<span class="rec-detail-label">Dateigröße</span>
<span class="rec-detail-value">${r.total_size_display}</span>
<span class="rec-detail-value">${esc(r.total_size_display)}</span>
</div>`;
if (r.error_message) {
@@ -952,7 +1001,7 @@ async function openRecDetail(recId) {
document.getElementById('recDetailBody').innerHTML = html;
openModal('recDetailModal');
} catch (e) {
console.error(e);
alert('Fehler: ' + e.message);
}
}
@@ -971,9 +1020,11 @@ function populateStationSelects() {
const el = document.getElementById(id);
if (!el) return;
const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
).join('');
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s => {
const id = intId(s.id);
if (id === null) return '';
return `<option value="${id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`;
}).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
});
}
@@ -986,31 +1037,34 @@ async function loadStations() {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return;
}
el.innerHTML = stationsCache.map(s => `
el.innerHTML = stationsCache.map(s => {
const id = intId(s.id);
return `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div>
<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
</div>
${id !== null ? `<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${id})">Löschen</button>
</div>` : ''}
</div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span>
</div>
</div>
`).join('');
`;
}).join('');
} catch (e) {
console.error(e);
}
}
function openStationModal(id = null) {
const station = id ? stationsCache.find(s => s.id === id) : null;
const station = id ? stationsCache.find(s => intId(s.id) === intId(id)) : null;
document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : '';
@@ -1048,8 +1102,12 @@ async function saveStation() {
async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return;
await api(`/api/stations/${id}`, { method: 'DELETE' });
loadStations();
try {
await api(`/api/stations/${intId(id)}`, { method: 'DELETE' });
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function applyStationToQuickRecord() {
@@ -1087,7 +1145,9 @@ async function loadJobs() {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128203;</div>Keine Jobs konfiguriert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openJobModal()">Ersten Job erstellen</button></div>';
return;
}
el.innerHTML = jobs.map(j => `
el.innerHTML = jobs.map(j => {
const id = intId(j.id);
return `
<div class="card">
<div class="card-header">
<div class="card-title">
@@ -1095,24 +1155,25 @@ async function loadJobs() {
${j.is_recording ? '<span class="badge badge-running">läuft</span>' : ''}
${j.schedule_enabled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
</div>
<div class="actions">
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${j.id})">Aufnehmen</button>` : ''}
<button class="btn btn-sm" onclick="editJob(${j.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteJob(${j.id})">Löschen</button>
</div>
${id !== null ? `<div class="actions">
${!j.is_recording ? `<button class="btn btn-sm btn-success" onclick="startJob(${id})">Aufnehmen</button>` : ''}
<button class="btn btn-sm" onclick="editJob(${id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteJob(${id})">Löschen</button>
</div>` : ''}
</div>
${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(j.stream_type || 'auto')}</span>
<span class="meta-item">${j.station_name ? '&#128251; ' + esc(j.station_name) : esc(j.stream_url)}</span>
${j.max_duration ? `<span class="meta-item">&#128339; ${Math.round(j.max_duration/60)} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (&#8722;'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
${j.max_duration ? `<span class="meta-item">&#128339; ${esc(Math.round(j.max_duration/60))} Min</span>` : ''}
${j.segment_duration ? `<span class="meta-item">&#128196; alle ${esc(Math.round(j.segment_duration/60))} Min</span>` : ''}
${j.schedule_enabled ? `<span class="meta-item">&#128197; ${esc(j.schedule_once ? j.schedule_date : j.schedule_days)} ${esc(j.schedule_start)}${j.schedule_stop ? ' - ' + esc(j.schedule_stop) : ''}${j.pre_buffer ? ' (&#8722;' + esc(j.pre_buffer) + 'min)' : ''}${j.post_buffer ? ' (+' + esc(j.post_buffer) + 'min)' : ''}</span>` : ''}
${j.ntfy_enabled ? `<span class="meta-item">&#128276; NTFY</span>` : ''}
${j.plik_enabled ? `<span class="meta-item">&#128228; Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
</div>
</div>
`).join('');
`;
}).join('');
} catch (e) {
console.error(e);
}
@@ -1155,10 +1216,16 @@ function openJobModal(job = null) {
}
async function editJob(id) {
const job = await api(`/api/jobs/${id}`);
openJobModal(job);
try {
const job = await api(`/api/jobs/${intId(id)}`);
openJobModal(job);
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function isUInt(v) { return /^\d+$/.test(String(v).trim()); }
async function saveJob() {
const editId = document.getElementById('job-edit-id').value;
const durMin = document.getElementById('job-duration').value;
@@ -1191,10 +1258,40 @@ async function saveJob() {
alert('Name und URL sind Pflichtfelder');
return;
}
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Max. Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (segMin && (!isUInt(segMin) || parseInt(segMin) < 1)) {
alert('Segment-Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
if (data.schedule_enabled) {
if (!data.schedule_start) {
alert('Bei aktivem Zeitplan ist eine Startzeit erforderlich');
return;
}
if (data.schedule_once && !data.schedule_date) {
alert('Bei einmaligem Zeitplan ist ein Startdatum erforderlich');
return;
}
if (data.schedule_once && data.schedule_end_date && data.schedule_date && data.schedule_end_date < data.schedule_date) {
alert('Enddatum darf nicht vor dem Startdatum liegen');
return;
}
}
for (const [key, label] of [['pre', 'Vorlaufzeit'], ['post', 'Nachlaufzeit']]) {
if (!document.getElementById(`job-${key}-buffer-enabled`).checked) continue;
const v = document.getElementById(`job-${key}-buffer`).value;
if (v && (!isUInt(v) || parseInt(v) > 120)) {
alert(`${label} muss eine ganze Zahl zwischen 0 und 120 Minuten sein`);
return;
}
}
try {
if (editId) {
await api(`/api/jobs/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
await api(`/api/jobs/${intId(editId)}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
}
@@ -1207,13 +1304,17 @@ async function saveJob() {
async function deleteJob(id) {
if (!confirm('Job wirklich löschen?')) return;
await api(`/api/jobs/${id}`, { method: 'DELETE' });
loadJobs();
try {
await api(`/api/jobs/${intId(id)}`, { method: 'DELETE' });
loadJobs();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function startJob(id) {
try {
await api(`/api/jobs/${id}/record`, { method: 'POST' });
await api(`/api/jobs/${intId(id)}/record`, { method: 'POST' });
loadDashboard();
loadJobs();
} catch (e) {
@@ -1323,6 +1424,10 @@ async function startQuickRecord() {
const url = document.getElementById('qr-url').value;
if (!url) { alert('URL ist ein Pflichtfeld'); return; }
const durMin = document.getElementById('qr-duration').value;
if (durMin && (!isUInt(durMin) || parseInt(durMin) < 1)) {
alert('Maximale Dauer muss eine ganze Zahl ≥ 1 (Minuten) sein');
return;
}
try {
await api('/api/quick-record', {
method: 'POST',
@@ -1344,54 +1449,97 @@ async function startQuickRecord() {
// Recording actions
async function stopRec(id) {
if (!confirm('Aufnahme wirklich stoppen?')) return;
await api(`/api/recordings/${id}/stop`, { method: 'POST' });
loadDashboard();
try {
await api(`/api/recordings/${intId(id)}/stop`, { method: 'POST' });
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
function openExtend(id) {
document.getElementById('extend-rec-id').value = id;
function openExtend(id, hasLimit = true) {
document.getElementById('extend-rec-id').value = intId(id) ?? '';
document.getElementById('extend-custom').value = '';
document.getElementById('extend-question').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-options').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-custom-group').style.display = hasLimit ? '' : 'none';
document.getElementById('extend-unlimited-info').style.display = hasLimit ? 'none' : '';
openModal('extendModal');
}
async function doExtend(minutes) {
const id = document.getElementById('extend-rec-id').value;
await api(`/api/recordings/${id}/extend?minutes=${minutes}`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
const id = intId(document.getElementById('extend-rec-id').value);
const min = Number(minutes);
if (id === null) return;
if (!Number.isInteger(min) || min < 1 || min > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
try {
await api(`/api/recordings/${id}/extend?minutes=${min}`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function doExtendCustom() {
const min = document.getElementById('extend-custom').value;
if (!min || min <= 0) return;
if (!isUInt(min) || parseInt(min) < 1 || parseInt(min) > 1440) {
alert('Bitte eine ganze Zahl zwischen 1 und 1440 Minuten eingeben');
return;
}
await doExtend(parseInt(min));
}
async function doUnlimited() {
const id = document.getElementById('extend-rec-id').value;
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
const id = intId(document.getElementById('extend-rec-id').value);
if (id === null) return;
try {
await api(`/api/recordings/${id}/unlimited`, { method: 'POST' });
closeModal('extendModal');
loadDashboard();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
// Logs
const LOG_LEVELS = new Set(['DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR']);
async function loadLogs() {
const level = document.getElementById('logLevel').value;
const logs = await api(`/api/logs?limit=200${level ? '&level='+level : ''}`);
const el = document.getElementById('logContainer');
if (!logs.length) {
el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>';
return;
try {
const level = document.getElementById('logLevel').value;
const logs = await api(`/api/logs?limit=200${level ? '&level=' + encodeURIComponent(level) : ''}`) || [];
const el = document.getElementById('logContainer');
if (!logs.length) {
el.innerHTML = '<span style="color:var(--text-dim)">Keine Logeinträge</span>';
return;
}
el.innerHTML = logs.map(l => {
const t = fmtDate(l.timestamp, '');
const lvlClass = LOG_LEVELS.has(l.level) ? l.level : '';
return `<div class="log-entry"><span class="log-time">${esc(t)}</span><span class="log-level ${lvlClass}">${esc(l.level)}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
} catch (e) {
alert('Fehler: ' + e.message);
}
el.innerHTML = logs.map(l => {
const t = l.timestamp ? new Date(l.timestamp).toLocaleString('de-DE') : '';
return `<div class="log-entry"><span class="log-time">${t}</span><span class="log-level ${l.level}">${l.level}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
}
// Settings
let settingsCfg = null;
async function loadSettings() {
const cfg = await api('/api/config');
let cfg;
try {
cfg = await api('/api/config');
} catch (e) {
alert('Fehler: ' + e.message);
return;
}
settingsCfg = cfg;
const secretPh = isSet => isSet ? '•••••••• (gespeichert)' : 'nicht gesetzt';
const el = document.getElementById('settingsForm');
el.innerHTML = `
<div class="settings-section">
@@ -1403,11 +1551,11 @@ async function loadSettings() {
</div>
<div class="form-group">
<label>Max. Wiederholungen</label>
<input type="number" id="cfg-max-retries" value="${cfg.recording?.max_retries ?? 5}">
<input type="number" id="cfg-max-retries" value="${esc(cfg.recording?.max_retries ?? 5)}">
</div>
<div class="form-group">
<label>Retry-Delay (Sek.)</label>
<input type="number" id="cfg-retry-delay" value="${cfg.recording?.retry_delay ?? 5}">
<input type="number" id="cfg-retry-delay" value="${esc(cfg.recording?.retry_delay ?? 5)}">
</div>
</div>
</div>
@@ -1426,7 +1574,7 @@ async function loadSettings() {
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="cfg-auth-pass" placeholder="***">
<input type="password" id="cfg-auth-pass" autocomplete="new-password" placeholder="${esc(secretPh(cfg.auth?.password_set))}">
</div>
</div>
</div>
@@ -1435,11 +1583,11 @@ async function loadSettings() {
<div class="form-grid">
<div class="form-group">
<label>Standard-Vorlaufzeit (Min.)</label>
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${cfg.schedule?.default_pre_buffer ?? 5}">
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_pre_buffer ?? 5)}">
</div>
<div class="form-group">
<label>Standard-Nachlaufzeit (Min.)</label>
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${cfg.schedule?.default_post_buffer ?? 5}">
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${esc(cfg.schedule?.default_post_buffer ?? 5)}">
</div>
</div>
</div>
@@ -1452,7 +1600,11 @@ async function loadSettings() {
</div>
<div class="form-group">
<label>Token</label>
<input type="text" id="cfg-ntfy-token" value="${esc(cfg.ntfy?.token || '')}">
<input type="password" id="cfg-ntfy-token" autocomplete="new-password" placeholder="${esc(secretPh(cfg.ntfy?.token_set))}">
${cfg.ntfy?.token_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-ntfy-token-clear">
<label for="cfg-ntfy-token-clear" style="margin:0">Token entfernen</label>
</div>` : ''}
</div>
<div class="form-group">
<label>Events (kommasepariert)</label>
@@ -1469,7 +1621,11 @@ async function loadSettings() {
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" id="cfg-plik-key" value="${esc(cfg.plik?.api_key || '')}">
<input type="password" id="cfg-plik-key" autocomplete="new-password" placeholder="${esc(secretPh(cfg.plik?.api_key_set))}">
${cfg.plik?.api_key_set ? `<div class="checkbox-group" style="margin-top:6px">
<input type="checkbox" id="cfg-plik-key-clear">
<label for="cfg-plik-key-clear" style="margin:0">API-Key entfernen</label>
</div>` : ''}
</div>
<div class="form-group">
<label>TTL</label>
@@ -1484,42 +1640,68 @@ async function loadSettings() {
}
async function saveSettings() {
const prev = settingsCfg || {};
const passVal = document.getElementById('cfg-auth-pass').value;
const authData = {
enabled: document.getElementById('cfg-auth-enabled').checked,
username: document.getElementById('cfg-auth-user').value,
};
if (passVal && passVal !== '***') authData.password = passVal;
if (passVal && passVal.length < 8) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (authData.enabled && !prev.auth?.password_set && !passVal) {
alert('Bitte ein Passwort (mind. 8 Zeichen) setzen');
return;
}
if (passVal) authData.password = passVal;
const authChanged = authData.enabled !== !!prev.auth?.enabled
|| authData.username !== (prev.auth?.username || '')
|| !!passVal;
window._defaultPreBuffer = parseInt(document.getElementById('cfg-pre-buffer').value) || 5;
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5;
const ntfyData = {
url: document.getElementById('cfg-ntfy-url').value,
events: document.getElementById('cfg-ntfy-events').value,
};
const ntfyToken = document.getElementById('cfg-ntfy-token').value;
const ntfyClear = document.getElementById('cfg-ntfy-token-clear');
if (ntfyClear && ntfyClear.checked) ntfyData.token = '';
else if (ntfyToken) ntfyData.token = ntfyToken;
const plikData = {
url: document.getElementById('cfg-plik-url').value,
ttl: document.getElementById('cfg-plik-ttl').value,
};
const plikKey = document.getElementById('cfg-plik-key').value;
const plikClear = document.getElementById('cfg-plik-key-clear');
if (plikClear && plikClear.checked) plikData.api_key = '';
else if (plikKey) plikData.api_key = plikKey;
const data = {
recording: {
download_path: document.getElementById('cfg-download-path').value,
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5,
max_retries: intOr(document.getElementById('cfg-max-retries').value, 5),
retry_delay: intOr(document.getElementById('cfg-retry-delay').value, 5),
},
schedule: {
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5,
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5,
default_pre_buffer: intOr(document.getElementById('cfg-pre-buffer').value, 5),
default_post_buffer: intOr(document.getElementById('cfg-post-buffer').value, 5),
},
auth: authData,
ntfy: {
url: document.getElementById('cfg-ntfy-url').value,
token: document.getElementById('cfg-ntfy-token').value,
events: document.getElementById('cfg-ntfy-events').value,
},
plik: {
url: document.getElementById('cfg-plik-url').value,
api_key: document.getElementById('cfg-plik-key').value,
ttl: document.getElementById('cfg-plik-ttl').value,
},
ntfy: ntfyData,
plik: plikData,
};
try {
await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) });
alert('Einstellungen gespeichert');
window._defaultPreBuffer = data.schedule.default_pre_buffer;
window._defaultPostBuffer = data.schedule.default_post_buffer;
let msg = 'Einstellungen gespeichert';
if (authData.enabled && authChanged) {
msg += '\n\nBei aktivierter Anmeldung fragt der Browser beim nächsten Laden nach Benutzername/Passwort';
}
alert(msg);
loadSettings();
} catch (e) {
alert('Fehler: ' + e.message);
}
@@ -1528,7 +1710,28 @@ async function saveSettings() {
// Helpers
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function intOr(v, fallback) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; }
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function intId(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
const STATUS_CLASSES = new Set(['running', 'starting', 'stopped', 'completed', 'error']);
function statusClass(status) {
return STATUS_CLASSES.has(status) ? 'badge-' + status : 'badge-stopped';
}
function fmtDate(s, fallback) {
return s ? new Date(s).toLocaleString('de-DE') : fallback;
}
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
@@ -1537,6 +1740,13 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
});
});
// Close open modal on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(o => o.classList.remove('active'));
}
});
// Close burger menu on outside click
document.addEventListener('click', e => {
const tabs = document.getElementById('navTabs');
@@ -1559,7 +1769,10 @@ async function loadDefaults() {
loadStationOptions();
loadDefaults();
loadDashboard();
setInterval(loadDashboard, 5000);
setInterval(pollDashboard, 5000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !authLost) loadDashboard();
});
api('/api/health').then(h => {
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;