- Gitea CI-Workflows fuer Multi-Arch Docker Builds (amd64 + arm64) - Beschreibungsfeld und mehrtaegige Zeitplanung fuer Jobs - Benachrichtigungen auf Deutsch - docker-compose nutzt Registry-Image Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1132 lines
46 KiB
HTML
1132 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>stream-recorder</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--bg-card: #1a1d27;
|
|
--bg-card-hover: #222636;
|
|
--bg-input: #12141c;
|
|
--border: #2a2e3d;
|
|
--text: #e4e6ed;
|
|
--text-dim: #8b8fa3;
|
|
--accent: #6c7bff;
|
|
--accent-hover: #8490ff;
|
|
--success: #34d399;
|
|
--warning: #fbbf24;
|
|
--error: #f87171;
|
|
--recording: #ef4444;
|
|
--radius: 8px;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
line-height: 1.5;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.app { max-width: 1100px; margin: 0 auto; padding: 16px; flex: 1; width: 100%; }
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 1.3rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
header h1 .rec-dot {
|
|
width: 10px; height: 10px;
|
|
background: var(--recording);
|
|
border-radius: 50%;
|
|
animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
background: var(--bg-card);
|
|
padding: 4px;
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.tab {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.tab:hover { color: var(--text); }
|
|
.tab.active { background: var(--accent); color: #fff; }
|
|
|
|
.panel { display: none; }
|
|
.panel.active { display: block; }
|
|
|
|
.section-title {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.card:hover { background: var(--bg-card-hover); }
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.card-title {
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.badge-running { background: rgba(239,68,68,0.15); color: var(--recording); }
|
|
.badge-completed { background: rgba(52,211,153,0.15); color: var(--success); }
|
|
.badge-stopped { background: rgba(139,143,163,0.15); color: var(--text-dim); }
|
|
.badge-error { background: rgba(248,113,113,0.15); color: var(--error); }
|
|
.badge-starting { background: rgba(251,191,36,0.15); color: var(--warning); }
|
|
.badge-scheduled { background: rgba(108,123,255,0.15); color: var(--accent); }
|
|
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.meta-item { display: flex; align-items: center; gap: 4px; }
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 6px 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent); }
|
|
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
.btn-primary:hover { background: var(--accent-hover); }
|
|
.btn-danger { border-color: var(--error); color: var(--error); }
|
|
.btn-danger:hover { background: rgba(248,113,113,0.1); }
|
|
.btn-success { border-color: var(--success); color: var(--success); }
|
|
.btn-success:hover { background: rgba(52,211,153,0.1); }
|
|
.btn-sm { padding: 4px 10px; font-size: 0.75rem; }
|
|
|
|
.empty {
|
|
text-align: center;
|
|
padding: 48px 16px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.empty-icon { font-size: 2rem; margin-bottom: 8px; }
|
|
|
|
/* Forms */
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.form-group { margin-bottom: 12px; }
|
|
.form-group.full { grid-column: 1 / -1; }
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
color: var(--text-dim);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
input, select, textarea {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.checkbox-group input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
|
|
|
/* Logs */
|
|
.log-container {
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 12px;
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 0.75rem;
|
|
line-height: 1.8;
|
|
}
|
|
|
|
.log-entry { display: flex; gap: 8px; }
|
|
.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.ERROR { color: var(--error); }
|
|
.log-level.DEBUG { color: var(--text-dim); }
|
|
.log-msg { color: var(--text); word-break: break-all; }
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
z-index: 100;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-overlay.active { display: flex; }
|
|
|
|
.modal {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
width: 90%;
|
|
max-width: 600px;
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
/* Extend popup */
|
|
.extend-options {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
/* Duration display */
|
|
.remaining {
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.remaining.low { color: var(--warning); }
|
|
.remaining.critical { color: var(--error); }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 640px) {
|
|
.app { padding: 10px; }
|
|
.form-grid { grid-template-columns: 1fr; }
|
|
.tabs { flex-wrap: wrap; }
|
|
.tab { padding: 6px 12px; font-size: 0.8rem; }
|
|
header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
|
header h1 { font-size: 1.1rem; }
|
|
.card { padding: 12px; }
|
|
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
|
.card-title { font-size: 0.9rem; }
|
|
.meta { gap: 8px; font-size: 0.75rem; }
|
|
.meta-item { word-break: break-all; }
|
|
.actions { width: 100%; }
|
|
.actions .btn { flex: 1; text-align: center; }
|
|
.modal { width: 95%; padding: 16px; max-height: 90vh; }
|
|
.extend-options { justify-content: center; }
|
|
.remaining { font-size: 1rem; }
|
|
.settings-section .form-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
/* Settings section */
|
|
.settings-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.settings-section h3 {
|
|
font-size: 0.9rem;
|
|
margin-bottom: 12px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<header>
|
|
<h1><span class="rec-dot" id="recDot"></span> stream-recorder</h1>
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
|
|
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
|
|
<button class="tab" onclick="switchTab('logs')">Logs</button>
|
|
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Dashboard -->
|
|
<div class="panel active" id="panel-dashboard">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
|
<p class="section-title" style="margin:0">Aktive Aufnahmen</p>
|
|
<button class="btn btn-primary btn-sm" onclick="openQuickRecord()">+ Schnellaufnahme</button>
|
|
</div>
|
|
<div id="activeRecordings"></div>
|
|
|
|
<p class="section-title" style="margin-top:24px">Letzte Aufnahmen</p>
|
|
<div id="recentRecordings"></div>
|
|
</div>
|
|
|
|
<!-- Jobs -->
|
|
<div class="panel" id="panel-jobs">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
|
<p class="section-title" style="margin:0">Geplante Jobs</p>
|
|
<button class="btn btn-primary btn-sm" onclick="openJobModal()">+ Neuer Job</button>
|
|
</div>
|
|
<div id="jobsList"></div>
|
|
</div>
|
|
|
|
<!-- Logs -->
|
|
<div class="panel" id="panel-logs">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
|
<p class="section-title" style="margin:0">Protokoll</p>
|
|
<div style="display:flex;gap:8px">
|
|
<select id="logLevel" onchange="loadLogs()" style="width:auto">
|
|
<option value="">Alle Level</option>
|
|
<option value="ERROR">Error</option>
|
|
<option value="WARN">Warn</option>
|
|
<option value="INFO">Info</option>
|
|
<option value="DEBUG">Debug</option>
|
|
</select>
|
|
<button class="btn btn-sm" onclick="loadLogs()">Aktualisieren</button>
|
|
</div>
|
|
</div>
|
|
<div class="log-container" id="logContainer"></div>
|
|
</div>
|
|
|
|
<!-- Settings -->
|
|
<div class="panel" id="panel-settings">
|
|
<p class="section-title">Konfiguration</p>
|
|
<div id="settingsForm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Record Modal -->
|
|
<div class="modal-overlay" id="quickRecordModal">
|
|
<div class="modal">
|
|
<div class="modal-title">Schnellaufnahme</div>
|
|
<div class="form-group">
|
|
<label>Stream-URL *</label>
|
|
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Name (optional)</label>
|
|
<input type="text" id="qr-name" placeholder="Meine Aufnahme">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Typ</label>
|
|
<select id="qr-type">
|
|
<option value="auto">Automatisch</option>
|
|
<option value="rtmp">RTMP</option>
|
|
<option value="hls">HLS (m3u8)</option>
|
|
<option value="mp3">MP3</option>
|
|
<option value="aac">AAC</option>
|
|
<option value="ogg">OGG</option>
|
|
<option value="http">HTTP</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Maximale Dauer (Minuten, leer = unbegrenzt)</label>
|
|
<input type="number" id="qr-duration" placeholder="z.B. 120">
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn" onclick="closeModal('quickRecordModal')">Abbrechen</button>
|
|
<button class="btn btn-primary" onclick="startQuickRecord()">Aufnahme starten</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Job Modal -->
|
|
<div class="modal-overlay" id="jobModal">
|
|
<div class="modal">
|
|
<div class="modal-title" id="jobModalTitle">Neuer Job</div>
|
|
<input type="hidden" id="job-edit-id">
|
|
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<label>Stream-URL *</label>
|
|
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Name *</label>
|
|
<input type="text" id="job-name" placeholder="Mein Stream">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Beschreibung (optional)</label>
|
|
<input type="text" id="job-description" placeholder="z.B. Freitagssendung, Live-Event...">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Typ</label>
|
|
<select id="job-type">
|
|
<option value="auto">Automatisch</option>
|
|
<option value="rtmp">RTMP</option>
|
|
<option value="hls">HLS</option>
|
|
<option value="mp3">MP3</option>
|
|
<option value="aac">AAC</option>
|
|
<option value="ogg">OGG</option>
|
|
<option value="http">HTTP</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Ausgabeformat</label>
|
|
<input type="text" id="job-format" placeholder="leer = automatisch (mp4, mp3, ...)">
|
|
</div>
|
|
<div class="form-group" id="durationGroup">
|
|
<label>Max. Dauer (Minuten)</label>
|
|
<input type="number" id="job-duration" placeholder="leer = unbegrenzt">
|
|
</div>
|
|
<div class="form-group" id="durationAutoGroup" style="display:none">
|
|
<label>Aufnahmedauer</label>
|
|
<div style="padding:8px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:0.85rem" id="durationAutoText">wird aus Zeitplan berechnet</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Segment-Dauer (Minuten)</label>
|
|
<input type="number" id="job-segment" placeholder="leer = deaktiviert">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Extra ffmpeg Argumente</label>
|
|
<input type="text" id="job-ffmpeg" placeholder="z.B. -b:a 192k">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="job-section" style="margin-top:16px; border-top:1px solid var(--border); padding-top:12px">
|
|
<div class="checkbox-group" style="margin-bottom:12px">
|
|
<input type="checkbox" id="job-schedule-enabled" onchange="toggleSchedule()">
|
|
<label for="job-schedule-enabled" style="margin:0">Zeitplan</label>
|
|
</div>
|
|
<div id="scheduleFields" style="display:none">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<div class="checkbox-group">
|
|
<input type="checkbox" id="job-schedule-once" onchange="toggleScheduleOnce()">
|
|
<label for="job-schedule-once" style="margin:0">Einmalig</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group" id="scheduleDateGroup" style="display:none">
|
|
<label>Startdatum</label>
|
|
<input type="date" id="job-schedule-date">
|
|
</div>
|
|
<div class="form-group" id="scheduleEndDateGroup" style="display:none">
|
|
<label>Enddatum (leer = gleicher Tag)</label>
|
|
<input type="date" id="job-schedule-end-date" oninput="updateDurationField()">
|
|
</div>
|
|
<div class="form-group" id="scheduleDaysGroup">
|
|
<label>Tage (z.B. Mo-Fr, Sa,So, *)</label>
|
|
<input type="text" id="job-schedule-days" value="*" placeholder="*">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Startzeit</label>
|
|
<input type="time" id="job-schedule-start" oninput="updateDurationField()">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Endzeit</label>
|
|
<input type="time" id="job-schedule-stop" oninput="updateDurationField()">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
|
|
<div class="checkbox-group" style="margin-bottom:8px">
|
|
<input type="checkbox" id="job-ntfy-enabled">
|
|
<label for="job-ntfy-enabled" style="margin:0">Benachrichtigungen (NTFY)</label>
|
|
</div>
|
|
<div style="font-size:12px; color:var(--text-dim); margin-left:24px">Start, Stop und Fehler per Push-Nachricht</div>
|
|
</div>
|
|
|
|
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
|
|
<div class="checkbox-group" style="margin-bottom:8px">
|
|
<input type="checkbox" id="job-plik-enabled" onchange="togglePlik()">
|
|
<label for="job-plik-enabled" style="margin:0">Plik-Upload</label>
|
|
</div>
|
|
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
|
|
<div class="checkbox-group">
|
|
<input type="checkbox" id="job-delete-after">
|
|
<label for="job-delete-after" style="margin:0">Nach Upload löschen</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
|
|
<div class="checkbox-group">
|
|
<input type="checkbox" id="job-metadata-enabled" onchange="toggleMetadata()">
|
|
<label for="job-metadata-enabled" style="margin:0">Euer-Radio Metadata-Monitor</label>
|
|
</div>
|
|
<div id="metadataFields" style="display:none; margin-top:8px" class="form-grid">
|
|
<div class="form-group full">
|
|
<label>Show-Pattern (z.B. Sendungsname)</label>
|
|
<input type="text" id="job-metadata-pattern" placeholder="Donnerwolke">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Karenzzeit (Min.)</label>
|
|
<input type="number" id="job-metadata-grace" value="5" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Poll-Intervall (Sek.)</label>
|
|
<input type="number" id="job-metadata-poll" value="30" min="10">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="btn" onclick="closeModal('jobModal')">Abbrechen</button>
|
|
<button class="btn btn-primary" onclick="saveJob()">Speichern</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Extend Modal -->
|
|
<div class="modal-overlay" id="extendModal">
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<button class="btn btn-primary" onclick="doExtendCustom()">Verlängern</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn" onclick="closeModal('extendModal')">Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = '';
|
|
|
|
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);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// Tabs
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
document.querySelector(`#panel-${name}`).classList.add('active');
|
|
document.querySelectorAll('.tab').forEach(t => {
|
|
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name))
|
|
t.classList.add('active');
|
|
});
|
|
if (name === 'logs') loadLogs();
|
|
if (name === 'jobs') loadJobs();
|
|
if (name === 'settings') loadSettings();
|
|
}
|
|
|
|
// Dashboard
|
|
async function loadDashboard() {
|
|
try {
|
|
const active = await api('/api/recordings?active_only=true');
|
|
const recent = await api('/api/recordings?limit=20');
|
|
|
|
const dot = document.getElementById('recDot');
|
|
dot.style.background = active.length > 0 ? 'var(--recording)' : 'var(--text-dim)';
|
|
dot.style.animation = active.length > 0 ? 'pulse 2s ease-in-out infinite' : 'none';
|
|
|
|
renderActive(active);
|
|
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function renderActive(recs) {
|
|
const el = document.getElementById('activeRecordings');
|
|
if (!recs.length) {
|
|
el.innerHTML = '<div class="empty"><div class="empty-icon">⚪</div>Keine aktiven Aufnahmen</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = recs.map(r => {
|
|
const remainClass = r.remaining !== null
|
|
? (r.remaining < 300 ? 'critical' : r.remaining < 900 ? 'low' : '')
|
|
: '';
|
|
return `
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
<span style="color:var(--recording)">●</span>
|
|
${esc(r.job_name)}
|
|
<span class="badge badge-${r.status}">${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>
|
|
</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>'}
|
|
</div>
|
|
<div class="meta" style="margin-top:4px">
|
|
<span class="meta-item" style="opacity:0.6">${esc(r.stream_url)}</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderRecent(recs) {
|
|
const el = document.getElementById('recentRecordings');
|
|
if (!recs.length) {
|
|
el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>';
|
|
return;
|
|
}
|
|
el.innerHTML = recs.slice(0, 10).map(r => `
|
|
<div class="card" style="padding:12px">
|
|
<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>
|
|
</div>
|
|
<div class="meta">
|
|
<span class="meta-item">🕓 ${r.elapsed_display}</span>
|
|
<span class="meta-item">💾 ${r.total_size_display}</span>
|
|
<span class="meta-item">${r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Jobs
|
|
async function loadJobs() {
|
|
try {
|
|
const jobs = await api('/api/jobs');
|
|
const el = document.getElementById('jobsList');
|
|
if (!jobs.length) {
|
|
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 => `
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
${esc(j.name)}
|
|
${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>
|
|
</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">${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 : ''}</span>` : ''}
|
|
${j.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
|
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (löschen)' : ''}</span>` : ''}
|
|
${j.metadata_monitor_enabled ? `<span class="meta-item">📡 ${esc(j.metadata_pattern)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function openJobModal(job = null) {
|
|
document.getElementById('job-edit-id').value = job ? job.id : '';
|
|
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
|
|
document.getElementById('job-url').value = job ? job.stream_url : '';
|
|
document.getElementById('job-name').value = job ? job.name : '';
|
|
document.getElementById('job-type').value = job ? job.stream_type : 'auto';
|
|
document.getElementById('job-description').value = job ? job.description || '' : '';
|
|
document.getElementById('job-format').value = job ? job.output_format || '' : '';
|
|
document.getElementById('job-duration').value = job && job.max_duration ? Math.round(job.max_duration / 60) : '';
|
|
document.getElementById('job-segment').value = job && job.segment_duration ? Math.round(job.segment_duration / 60) : '';
|
|
document.getElementById('job-ffmpeg').value = job ? job.extra_ffmpeg_args || '' : '';
|
|
document.getElementById('job-schedule-enabled').checked = job ? job.schedule_enabled : false;
|
|
document.getElementById('job-schedule-once').checked = job ? job.schedule_once : false;
|
|
document.getElementById('job-schedule-date').value = job ? job.schedule_date || '' : '';
|
|
document.getElementById('job-schedule-end-date').value = job ? job.schedule_end_date || '' : '';
|
|
document.getElementById('job-schedule-days').value = job ? job.schedule_days || '*' : '*';
|
|
document.getElementById('job-schedule-start').value = job ? job.schedule_start || '' : '';
|
|
document.getElementById('job-schedule-stop').value = job ? job.schedule_stop || '' : '';
|
|
document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false;
|
|
document.getElementById('job-plik-enabled').checked = job ? job.plik_enabled : false;
|
|
document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : false;
|
|
document.getElementById('job-metadata-enabled').checked = job ? job.metadata_monitor_enabled : false;
|
|
document.getElementById('job-metadata-pattern').value = job ? job.metadata_pattern || '' : '';
|
|
document.getElementById('job-metadata-grace').value = job ? Math.round((job.metadata_grace_period || 300) / 60) : 5;
|
|
document.getElementById('job-metadata-poll').value = job ? job.metadata_poll_interval || 30 : 30;
|
|
toggleSchedule();
|
|
toggleScheduleOnce();
|
|
togglePlik();
|
|
toggleMetadata();
|
|
openModal('jobModal');
|
|
}
|
|
|
|
async function editJob(id) {
|
|
const job = await api(`/api/jobs/${id}`);
|
|
openJobModal(job);
|
|
}
|
|
|
|
async function saveJob() {
|
|
const editId = document.getElementById('job-edit-id').value;
|
|
const durMin = document.getElementById('job-duration').value;
|
|
const segMin = document.getElementById('job-segment').value;
|
|
const data = {
|
|
name: document.getElementById('job-name').value,
|
|
stream_url: document.getElementById('job-url').value,
|
|
stream_type: document.getElementById('job-type').value,
|
|
output_format: document.getElementById('job-format').value,
|
|
max_duration: durMin ? parseInt(durMin) * 60 : null,
|
|
extra_ffmpeg_args: document.getElementById('job-ffmpeg').value,
|
|
segment_duration: segMin ? parseInt(segMin) * 60 : null,
|
|
schedule_enabled: document.getElementById('job-schedule-enabled').checked,
|
|
schedule_once: document.getElementById('job-schedule-once').checked,
|
|
schedule_date: document.getElementById('job-schedule-date').value,
|
|
schedule_days: document.getElementById('job-schedule-days').value || '*',
|
|
schedule_start: document.getElementById('job-schedule-start').value,
|
|
schedule_stop: document.getElementById('job-schedule-stop').value,
|
|
schedule_end_date: document.getElementById('job-schedule-end-date').value,
|
|
description: document.getElementById('job-description').value,
|
|
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
|
|
plik_enabled: document.getElementById('job-plik-enabled').checked,
|
|
delete_after_upload: document.getElementById('job-delete-after').checked,
|
|
metadata_monitor_enabled: document.getElementById('job-metadata-enabled').checked,
|
|
metadata_pattern: document.getElementById('job-metadata-pattern').value,
|
|
metadata_grace_period: parseInt(document.getElementById('job-metadata-grace').value || '5') * 60,
|
|
metadata_poll_interval: parseInt(document.getElementById('job-metadata-poll').value || '30'),
|
|
};
|
|
|
|
if (!data.name || !data.stream_url) {
|
|
alert('Name und URL sind Pflichtfelder');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (editId) {
|
|
await api(`/api/jobs/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
|
|
} else {
|
|
await api('/api/jobs', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
closeModal('jobModal');
|
|
loadJobs();
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteJob(id) {
|
|
if (!confirm('Job wirklich löschen?')) return;
|
|
await api(`/api/jobs/${id}`, { method: 'DELETE' });
|
|
loadJobs();
|
|
}
|
|
|
|
async function startJob(id) {
|
|
try {
|
|
await api(`/api/jobs/${id}/record`, { method: 'POST' });
|
|
loadDashboard();
|
|
loadJobs();
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function toggleSchedule() {
|
|
document.getElementById('scheduleFields').style.display =
|
|
document.getElementById('job-schedule-enabled').checked ? 'block' : 'none';
|
|
updateDurationField();
|
|
}
|
|
|
|
function updateDurationField() {
|
|
const scheduleOn = document.getElementById('job-schedule-enabled').checked;
|
|
const start = document.getElementById('job-schedule-start').value;
|
|
const stop = document.getElementById('job-schedule-stop').value;
|
|
const once = document.getElementById('job-schedule-once').checked;
|
|
const startDate = document.getElementById('job-schedule-date').value;
|
|
const endDate = document.getElementById('job-schedule-end-date').value;
|
|
const hasScheduleStop = scheduleOn && start && stop;
|
|
|
|
document.getElementById('durationGroup').style.display = hasScheduleStop ? 'none' : '';
|
|
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
|
|
|
|
if (hasScheduleStop) {
|
|
let text;
|
|
if (once && startDate && endDate && endDate !== startDate) {
|
|
const startDt = new Date(`${startDate}T${start}`);
|
|
const endDt = new Date(`${endDate}T${stop}`);
|
|
let diffMin = Math.round((endDt - startDt) / 60000);
|
|
if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; }
|
|
else {
|
|
const d = Math.floor(diffMin / 1440);
|
|
const h = Math.floor((diffMin % 1440) / 60);
|
|
const m = diffMin % 60;
|
|
const parts = [];
|
|
if (d > 0) parts.push(`${d} Tag${d > 1 ? 'e' : ''}`);
|
|
if (h > 0) parts.push(`${h} Std.`);
|
|
if (m > 0) parts.push(`${m} Min.`);
|
|
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
|
|
}
|
|
} else {
|
|
const [sh, sm] = start.split(':').map(Number);
|
|
const [eh, em] = stop.split(':').map(Number);
|
|
let diff = (eh * 60 + em) - (sh * 60 + sm);
|
|
if (diff <= 0) diff += 1440;
|
|
const h = Math.floor(diff / 60);
|
|
const m = diff % 60;
|
|
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (automatisch aus Zeitplan)';
|
|
}
|
|
document.getElementById('durationAutoText').textContent = text;
|
|
}
|
|
}
|
|
|
|
function toggleScheduleOnce() {
|
|
const once = document.getElementById('job-schedule-once').checked;
|
|
document.getElementById('scheduleDateGroup').style.display = once ? 'block' : 'none';
|
|
document.getElementById('scheduleEndDateGroup').style.display = once ? 'block' : 'none';
|
|
document.getElementById('scheduleDaysGroup').style.display = once ? 'none' : 'block';
|
|
updateDurationField();
|
|
}
|
|
|
|
function togglePlik() {
|
|
document.getElementById('plikFields').style.display =
|
|
document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
|
|
}
|
|
|
|
function toggleMetadata() {
|
|
document.getElementById('metadataFields').style.display =
|
|
document.getElementById('job-metadata-enabled').checked ? 'grid' : 'none';
|
|
}
|
|
|
|
// Quick Record
|
|
function openQuickRecord() {
|
|
document.getElementById('qr-url').value = '';
|
|
document.getElementById('qr-name').value = '';
|
|
document.getElementById('qr-type').value = 'auto';
|
|
document.getElementById('qr-duration').value = '';
|
|
openModal('quickRecordModal');
|
|
}
|
|
|
|
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;
|
|
try {
|
|
await api('/api/quick-record', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
url,
|
|
name: document.getElementById('qr-name').value,
|
|
stream_type: document.getElementById('qr-type').value,
|
|
max_duration: durMin ? parseInt(durMin) * 60 : null,
|
|
}),
|
|
});
|
|
closeModal('quickRecordModal');
|
|
switchTab('dashboard');
|
|
loadDashboard();
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Recording actions
|
|
async function stopRec(id) {
|
|
if (!confirm('Aufnahme wirklich stoppen?')) return;
|
|
await api(`/api/recordings/${id}/stop`, { method: 'POST' });
|
|
loadDashboard();
|
|
}
|
|
|
|
function openExtend(id) {
|
|
document.getElementById('extend-rec-id').value = id;
|
|
document.getElementById('extend-custom').value = '';
|
|
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();
|
|
}
|
|
|
|
async function doExtendCustom() {
|
|
const min = document.getElementById('extend-custom').value;
|
|
if (!min || min <= 0) 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();
|
|
}
|
|
|
|
// Logs
|
|
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;
|
|
}
|
|
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
|
|
async function loadSettings() {
|
|
const cfg = await api('/api/config');
|
|
const el = document.getElementById('settingsForm');
|
|
el.innerHTML = `
|
|
<div class="settings-section">
|
|
<h3>Aufnahme</h3>
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<label>Download-Pfad</label>
|
|
<input type="text" id="cfg-download-path" value="${esc(cfg.recording?.download_path || '/recordings')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Max. Wiederholungen</label>
|
|
<input type="number" id="cfg-max-retries" value="${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}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="settings-section">
|
|
<h3>Authentifizierung</h3>
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<div class="checkbox-group">
|
|
<input type="checkbox" id="cfg-auth-enabled" ${cfg.auth?.enabled ? 'checked' : ''}>
|
|
<label for="cfg-auth-enabled" style="margin:0">Basic Auth aktivieren</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Benutzername</label>
|
|
<input type="text" id="cfg-auth-user" value="${esc(cfg.auth?.username || 'admin')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Passwort</label>
|
|
<input type="password" id="cfg-auth-pass" placeholder="***">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="settings-section">
|
|
<h3>NTFY-Benachrichtigungen</h3>
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<label>NTFY URL</label>
|
|
<input type="text" id="cfg-ntfy-url" value="${esc(cfg.ntfy?.url || '')}" placeholder="https://ntfy.sh/mein-topic">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Token</label>
|
|
<input type="text" id="cfg-ntfy-token" value="${esc(cfg.ntfy?.token || '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Events (kommasepariert)</label>
|
|
<input type="text" id="cfg-ntfy-events" value="${esc(cfg.ntfy?.events || 'error')}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="settings-section">
|
|
<h3>Plik Upload</h3>
|
|
<div class="form-grid">
|
|
<div class="form-group full">
|
|
<label>Plik URL</label>
|
|
<input type="text" id="cfg-plik-url" value="${esc(cfg.plik?.url || '')}" placeholder="https://plik.example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>API Key</label>
|
|
<input type="text" id="cfg-plik-key" value="${esc(cfg.plik?.api_key || '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>TTL</label>
|
|
<input type="text" id="cfg-plik-ttl" value="${esc(cfg.plik?.ttl || '30d')}">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:16px">
|
|
<button class="btn btn-primary" onclick="saveSettings()">Einstellungen speichern</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function saveSettings() {
|
|
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;
|
|
|
|
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,
|
|
},
|
|
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,
|
|
},
|
|
};
|
|
|
|
try {
|
|
await api('/api/config', { method: 'PUT', body: JSON.stringify({ data }) });
|
|
alert('Einstellungen gespeichert');
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 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; }
|
|
|
|
// Close modals on overlay click
|
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
overlay.addEventListener('click', e => {
|
|
if (e.target === overlay) overlay.classList.remove('active');
|
|
});
|
|
});
|
|
|
|
// Auto-refresh
|
|
loadDashboard();
|
|
setInterval(loadDashboard, 5000);
|
|
</script>
|
|
<footer style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem">
|
|
stream-recorder © <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> · <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
|
|
</footer>
|
|
</body>
|
|
</html>
|