Sicherheits-Härtung und Bugfixes
This commit is contained in:
+358
-145
@@ -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ü">☰</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">∞ 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">🕓 ${r.elapsed_display}</span>
|
||||
<span class="meta-item">💾 ${r.total_size_display}</span>
|
||||
${r.segment_count !== null ? `<span class="meta-item">📄 ${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">∞ Unbegrenzt</span>'}
|
||||
<span class="meta-item">🕓 ${esc(r.elapsed_display)}</span>
|
||||
<span class="meta-item">💾 ${esc(r.total_size_display)}</span>
|
||||
${r.segment_count != null ? `<span class="meta-item">📄 ${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">∞ 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">📤 Plik</span>' : ''}
|
||||
</div>
|
||||
<div class="recent-meta">
|
||||
<span class="rm-dur">🕓 ${r.elapsed_display}</span>
|
||||
<span class="rm-size">💾 ${r.total_size_display}</span>
|
||||
<span class="rm-date">${started}</span>
|
||||
<span class="rm-dur">🕓 ${esc(r.elapsed_display)}</span>
|
||||
<span class="rm-size">💾 ${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">📤 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">🔗 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">🔗 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">📻</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">📋</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 ? '📻 ' + esc(j.station_name) : esc(j.stream_url)}</span>
|
||||
${j.max_duration ? `<span class="meta-item">🕓 ${Math.round(j.max_duration/60)} Min</span>` : ''}
|
||||
${j.segment_duration ? `<span class="meta-item">📄 alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (−'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
|
||||
${j.max_duration ? `<span class="meta-item">🕓 ${esc(Math.round(j.max_duration/60))} Min</span>` : ''}
|
||||
${j.segment_duration ? `<span class="meta-item">📄 alle ${esc(Math.round(j.segment_duration/60))} Min</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${esc(j.schedule_once ? j.schedule_date : j.schedule_days)} ${esc(j.schedule_start)}${j.schedule_stop ? ' - ' + esc(j.schedule_stop) : ''}${j.pre_buffer ? ' (−' + esc(j.pre_buffer) + 'min)' : ''}${j.post_buffer ? ' (+' + esc(j.post_buffer) + 'min)' : ''}</span>` : ''}
|
||||
${j.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
||||
${j.plik_enabled ? `<span class="meta-item">📤 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user