Files
2026-09-16 06:26:16 +02:00

255 lines
50 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notify Gateway · Konfiguration</title>
<style>
:root{color-scheme:light;--bg:#f4f6f8;--panel:#fff;--ink:#17212b;--muted:#637083;--line:#d9e0e7;--accent:#1769e0;--accent2:#0f55bb;--good:#147a4a;--warn:#ad6400;--bad:#b42318;--radius:12px;--shadow:0 2px 10px rgba(20,32,50,.07)}
*{box-sizing:border-box}body{margin:0;font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--ink)}button,input,select,textarea{font:inherit}button{cursor:pointer}.app{min-height:100vh;display:grid;grid-template-columns:245px 1fr}.side{background:#142238;color:#dbe6f5;padding:22px 16px;position:sticky;top:0;height:100vh}.brand{display:flex;gap:11px;align-items:center;padding:0 8px 24px;font-size:17px;font-weight:750}.logo{width:34px;height:34px;border-radius:9px;background:linear-gradient(145deg,#2c84ff,#1759bc);display:grid;place-items:center;color:#fff}.nav button{border:0;background:transparent;color:#b9c7d9;width:100%;text-align:left;padding:10px 12px;border-radius:8px;margin:2px 0}.nav button:hover,.nav button.active{background:#223652;color:#fff}.nav small{display:block;color:#7f93ad;margin:20px 12px 5px;text-transform:uppercase;letter-spacing:.09em;font-size:10px}.main{min-width:0}.topbar{height:66px;background:rgba(255,255,255,.94);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:20;backdrop-filter:blur(8px)}.topbar h1{font-size:18px;margin:0}.actions{display:flex;align-items:center;gap:9px}.unsaved{color:var(--warn);font-size:12px;display:none}.unsaved.show{display:inline}.content{padding:26px 28px 60px;max-width:1420px;margin:auto}.page{display:none}.page.active{display:block}.hero{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-bottom:22px}.hero h2{font-size:25px;margin:0 0 5px}.hero p{color:var(--muted);margin:0}.grid{display:grid;gap:16px}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}.g3{grid-template-columns:repeat(3,minmax(0,1fr))}.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px}.card h3{margin:0 0 4px;font-size:16px}.card .sub{color:var(--muted);margin-bottom:17px}.metric{font-size:30px;font-weight:750}.metric-label{color:var(--muted);font-size:12px}.statusline{display:flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:#9aa6b2}.dot.good{background:var(--good)}.dot.bad{background:var(--bad)}.field{margin-bottom:15px}.field label,.field .label{display:block;font-weight:650;margin-bottom:6px}.help{color:var(--muted);font-size:12px;margin-top:5px}.req{color:var(--bad)}input[type=text],input[type=password],input[type=number],select,textarea{width:100%;border:1px solid #cbd4de;border-radius:8px;padding:9px 10px;background:#fff;color:var(--ink);outline:none}input:focus,select:focus,textarea:focus{border-color:#6aa5f5;box-shadow:0 0 0 3px rgba(23,105,224,.1)}textarea{min-height:92px;resize:vertical}.inline{display:flex;gap:8px;align-items:center}.inline>*{min-width:0}.grow{flex:1}.btn{border:1px solid #c9d2dc;background:#fff;border-radius:8px;padding:8px 12px;color:#263445;font-weight:650}.btn:hover{background:#f5f7fa}.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{background:var(--accent2)}.btn.danger{color:var(--bad);border-color:#f0c8c5}.btn.ghost{border-color:transparent;background:transparent}.btn.small{font-size:12px;padding:6px 9px}.switch{display:inline-flex;align-items:center;gap:9px}.switch input{width:38px;height:21px;appearance:none;background:#cbd4de;border-radius:20px;position:relative;transition:.15s;margin:0}.switch input:after{content:"";position:absolute;width:17px;height:17px;background:#fff;border-radius:50%;left:2px;top:2px;box-shadow:0 1px 2px #999;transition:.15s}.switch input:checked{background:var(--accent)}.switch input:checked:after{left:19px}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:9px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:10px 11px;border-bottom:1px solid var(--line);vertical-align:middle}th{font-size:12px;color:var(--muted);background:#f7f9fb}tr:last-child td{border-bottom:0}.badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;background:#eef3f8;color:#425267;font-size:12px}.badge.good{background:#e9f7ef;color:#12643f}.badge.off{background:#f2f3f5;color:#77818e}.callout{padding:12px 14px;border:1px solid #b9d6fb;background:#eef6ff;border-radius:9px;color:#244969}.callout.warn{border-color:#f1d29c;background:#fff7e9;color:#714b13}.empty{padding:35px;text-align:center;color:var(--muted)}.tokenrow{display:grid;grid-template-columns:1fr 1.2fr auto;gap:8px;margin:8px 0}.mapping-row{display:grid;grid-template-columns:42px minmax(180px,1.5fr) .7fr 1.2fr .8fr auto;gap:12px;align-items:center;padding:13px 12px;border-bottom:1px solid var(--line)}.mapping-row:last-child{border-bottom:0}.mapping-name{font-weight:700}.mapping-desc{font-size:12px;color:var(--muted);margin-top:2px}.drag{color:#9ba6b2;text-align:center}.checkgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.check{display:flex;align-items:center;gap:8px;border:1px solid var(--line);padding:9px;border-radius:8px;background:#fff}.check input{width:auto}.catalogbox{border:1px solid var(--line);border-radius:9px;max-height:250px;overflow:auto;padding:8px;background:#fafbfd}.catitem{display:flex;align-items:flex-start;gap:8px;padding:7px;border-radius:7px}.catitem:hover{background:#eef4fb}.catitem input{margin-top:3px}.catitem strong{font-size:13px}.catitem small{display:block;color:var(--muted)}.unit-route{display:grid;grid-template-columns:1fr 180px auto;gap:8px;margin:7px 0}.modalback{position:fixed;inset:0;background:rgba(15,28,45,.48);z-index:50;display:none;padding:28px;overflow:auto}.modalback.show{display:block}.modal{background:#fff;max-width:1060px;margin:0 auto;border-radius:14px;box-shadow:0 24px 70px rgba(0,0,0,.25);overflow:hidden}.modalhead{padding:18px 22px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;position:sticky;top:-28px;background:#fff;z-index:3}.modalhead h2{margin:0;font-size:19px}.modalbody{padding:22px}.modalfoot{padding:15px 22px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px;background:#fafbfd}.section-title{font-size:14px;font-weight:750;margin:23px 0 11px;padding-top:5px;border-top:1px solid var(--line)}.section-title:first-child{margin-top:0;border-top:0}.preview{background:#111c2b;color:#d5e4f5;border-radius:8px;padding:12px;white-space:pre-wrap;word-break:break-word;min-height:100px;font:12px/1.5 ui-monospace,Consolas,monospace}.toast{position:fixed;right:22px;bottom:22px;z-index:90;padding:12px 15px;border-radius:9px;background:#182738;color:#fff;box-shadow:var(--shadow);display:none;max-width:420px}.toast.show{display:block}.toast.bad{background:#7f1d1d}.sep{height:1px;background:var(--line);margin:17px 0}.secret{font-family:ui-monospace,monospace}.mobile-nav{display:none}
@media(max-width:900px){.app{grid-template-columns:1fr}.side{display:none}.topbar{padding:0 15px}.content{padding:18px 14px 50px}.g2,.g3{grid-template-columns:1fr}.mobile-nav{display:block}.mapping-row{grid-template-columns:1fr;gap:4px}.drag{display:none}.tokenrow{grid-template-columns:1fr}.modalback{padding:0}.modal{border-radius:0;min-height:100vh}.modalhead{top:0}.unit-route{grid-template-columns:1fr}.hero{display:block}}
</style>
</head>
<body>
<div class="app">
<aside class="side">
<div class="brand"><div class="logo">NG</div><div>Notify Gateway</div></div>
<nav class="nav" id="nav">
<small>Konfiguration</small>
<button data-page="overview" class="active">Übersicht</button>
<button data-page="divera247">Divera247</button>
<button data-page="outbounds">Ausgänge</button>
<button data-page="ingress">Eingänge</button>
<button data-page="mappings">Zuordnungen</button>
<small>System</small>
<button data-page="history">Zustellhistorie</button>
<button data-page="system">Server & Sicherheit</button>
</nav>
</aside>
<main class="main">
<header class="topbar">
<div class="inline"><select class="mobile-nav" id="mobileNav"><option value="overview">Übersicht</option><option value="divera247">Divera247</option><option value="outbounds">Ausgänge</option><option value="ingress">Eingänge</option><option value="mappings">Zuordnungen</option><option value="history">Zustellhistorie</option><option value="system">Server & Sicherheit</option></select><h1 id="pageTitle">Übersicht</h1></div>
<div class="actions"><span class="unsaved" id="unsaved">Nicht gespeicherte Änderungen</span><button class="btn primary" id="saveAll">Speichern</button></div>
</header>
<div class="content">
<section class="page active" id="page-overview">
<div class="hero"><div><h2>Gateway konfigurieren</h2><p>Eingehende Meldungen prüfen, zuordnen und an Divera247, Discord oder HTTP-Webhooks weitergeben.</p></div></div>
<div class="grid g3">
<div class="card"><div class="metric" id="metricMappings">–</div><div class="metric-label">aktive Zuordnungen</div></div>
<div class="card"><div class="metric" id="metricInputs">5</div><div class="metric-label">Eingangstypen</div></div>
<div class="card"><div class="statusline"><span class="dot" id="diveraDot"></span><strong id="diveraStatus">Nicht geprüft</strong></div><div class="metric-label" style="margin-top:10px">Divera247</div></div>
</div>
<div class="grid g2" style="margin-top:16px">
<div class="card"><h3>Nächste Schritte</h3><div class="sub">Empfohlene Reihenfolge für die Einrichtung</div><ol><li>Divera247-Zugang hinterlegen und Stammdaten laden.</li><li>Tokens für ntfy, Webhook und Gotify anlegen.</li><li>Zuordnungsregeln erstellen und mit der Vorschau prüfen.</li><li>Dry-Run deaktivieren, sobald die Tests passen.</li></ol></div>
<div class="card"><h3>Endpunkte</h3><div class="sub">Diese URLs können von deinen Sendern verwendet werden.</div><table><tr><td><span class="badge">ntfy</span></td><td><code>/ntfy/{topic}</code></td></tr><tr><td><span class="badge">Gotify</span></td><td><code>/message</code></td></tr><tr><td><span class="badge">Webhook</span></td><td><code>/in/webhook/{channel}</code></td></tr></table></div>
</div>
</section>
<section class="page" id="page-divera247">
<div class="hero"><div><h2>Divera247</h2><p>Zugang, Ziel-Einheit und Versandverhalten für Divera247 konfigurieren.</p></div><button class="btn" id="loadCatalog">Verbindung prüfen & Stammdaten laden</button></div>
<div class="grid g2">
<div class="card">
<h3>Zugang</h3><div class="sub">Der Accesskey wird persistent in der lokalen Konfiguration gespeichert.</div>
<div class="field"><label>API-Basis-URL</label><input id="divBase" type="text" placeholder="https://app.divera247.com"></div>
<div class="field"><label>Accesskey <span class="req">*</span></label><div class="inline"><input id="divKey" class="secret grow" type="password" autocomplete="off"><button class="btn" type="button" id="toggleKey">Anzeigen</button></div></div>
<div class="grid g2"><div class="field"><label>UCR-ID</label><input id="divUcr" type="number" min="0" placeholder="optional"><div class="help">Optional: UserClusterRelation für API-Aufrufe.</div></div><div class="field"><label>Timeout (Sek.)</label><input id="divTimeout" type="number" min="1" max="300"></div></div>
<label class="switch"><input id="divDry" type="checkbox"><span>Dry-Run für schreibende Aufrufe</span></label><div class="help">GET-Aufrufe zum Laden von Stammdaten bleiben möglich.</div>
</div>
<div class="card">
<h3>Geladene Stammdaten</h3><div class="sub">Werden für komfortable Auswahlfelder in den Zuordnungen verwendet.</div>
<div class="grid g2"><div><div class="metric" id="countUnits">0</div><div class="metric-label">Einheiten</div></div><div><div class="metric" id="countGroups">0</div><div class="metric-label">Gruppen</div></div><div><div class="metric" id="countPersons">0</div><div class="metric-label">Personen</div></div><div><div class="metric" id="countVehicles">0</div><div class="metric-label">Fahrzeuge</div></div></div>
<div class="sep"></div><div id="catalogMessage" class="callout">Noch keine Stammdaten geladen.</div>
</div>
</div>
</section>
<section class="page" id="page-ingress">
<div class="hero"><div><h2>Eingänge</h2><p>Authentifizierung getrennt nach ntfy, Webhook und Gotify verwalten.</p></div></div>
<div class="card" style="margin-bottom:16px"><label class="switch"><input id="allowUnauth" type="checkbox"><span><strong>Unauthentifizierte Eingänge erlauben</strong></span></label><div class="help">Für produktiven Betrieb nicht empfohlen. Ist dies aus, muss ein passender Token vorhanden sein.</div></div>
<div class="grid g3">
<div class="card"><h3>ntfy</h3><div class="sub">Token pro Topic. <code>*</code> gilt als Fallback für alle Topics.</div><div id="ntfyTokens"></div><button class="btn small" data-add-token="ntfy">+ Token hinzufügen</button></div>
<div class="card"><h3>Webhook</h3><div class="sub">Token pro Kanal. <code>*</code> gilt als Fallback für alle Kanäle.</div><div id="webhookTokens"></div><button class="btn small" data-add-token="webhook">+ Token hinzufügen</button></div>
<div class="card"><h3>Gotify</h3><div class="sub">Ein oder mehrere akzeptierte App-Tokens.</div><div id="gotifyTokens"></div><button class="btn small" data-add-token="gotify">+ Token hinzufügen</button></div>
</div>
<div class="section-title">Mail-Eingänge (IMAP über TLS)</div>
<div class="callout" style="margin-bottom:12px">Standardmäßig werden nur neue Nachrichten ab dem ersten erfolgreichen Abruf verarbeitet. Alte Nachrichten lassen sich mit „Vorhandene Nachrichten importieren“ einbeziehen. Plain-Text-MIME, maximal 1 MiB; Anhänge werden nicht weitergeleitet.</div>
<button class="btn" id="addMail">+ Mail-Eingang</button><div class="grid g2" id="mailIngressList" style="margin-top:12px"></div>
<div class="section-title">Discord-Eingang</div><div class="card" id="discordIngress"></div>
</section>
<section class="page" id="page-outbounds">
<div class="hero"><div><h2>Ausgänge</h2><p>Discord, HTTP-Webhooks, Mail, ntfy und Gotify als benannte Ziele für Zuordnungen einrichten.</p></div><button class="btn primary" id="addOutbound">+ Ausgang</button></div>
<div class="callout" style="margin-bottom:16px">Neue Ausgänge starten im Dry-Run. Live-Versand wird pro Ausgang aktiviert und ist unabhängig vom Divera247-Dry-Run. Die Regelvorschau versendet niemals Nachrichten.</div>
<div class="grid g2" id="outboundList"></div>
</section>
<section class="page" id="page-mappings">
<div class="hero"><div><h2>Zuordnungen</h2><p>Regeln definieren, die Eingang, Kanal und Meldungsinhalt auf die gewünschten Ausgänge abbilden. Mehrere passende Regeln werden unabhängig voneinander ausgeführt.</p></div><button class="btn primary" id="addMapping">+ Zuordnung</button></div>
<div class="card" style="padding:0"><div class="mapping-row" style="background:#f7f9fb;font-size:12px;color:var(--muted);font-weight:700"><div></div><div>Name</div><div>Eingang</div><div>Bedingung</div><div>Ziel</div><div></div></div><div id="mappingList"></div></div>
</section>
<section class="page" id="page-history">
<div class="hero"><div><h2>Zustellhistorie</h2><p>Angenommene Nachrichten werden im Hintergrund zugestellt. Nach acht fehlgeschlagenen Versuchen oder einem dauerhaften Fehler bleibt die Zustellung zur Prüfung stehen.</p></div><button class="btn" id="refreshHistory">Aktualisieren</button></div>
<div class="callout" id="historyCounts"></div><div class="card" style="margin-top:16px;overflow:auto"><table><thead><tr><th>Zeit</th><th>Regel / Referenz</th><th>Ziel</th><th>Status</th><th>Versuche</th><th>Aktion</th></tr></thead><tbody id="historyRows"></tbody></table></div>
<div class="inline" style="margin-top:12px"><button class="btn" id="historyPrev">Zurück</button><button class="btn" id="historyNext">Weitere</button></div>
<pre class="preview" id="attemptHistory" style="margin-top:16px">Wähle eine Zustellung für Details.</pre><div class="callout" id="mailStatus" style="margin-top:16px"></div>
</section>
<section class="page" id="page-system">
<div class="hero"><div><h2>Server & Sicherheit</h2><p>Webserver und Administrationszugang verwalten.</p></div></div>
<div class="grid g2">
<div class="card"><h3>Server</h3><div class="field"><label>Listen-Adresse</label><input id="serverListen" type="text" placeholder=":8080"><div class="help">Änderungen am Listen-Port werden nach Neustart wirksam.</div></div><div class="field"><label>Admin-Benutzername</label><input id="adminUser" type="text"></div></div>
<div class="card"><h3>Admin-Passwort</h3><div class="sub">Passwort-Hash und Session-Secret werden nicht an den Browser übertragen.</div><form method="post" action="/ui/password"><div class="field"><label>Neues Passwort</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><button class="btn">Passwort ändern</button></form><div class="sep"></div><form method="post" action="/logout"><button class="btn danger">Abmelden</button></form></div>
</div>
</section>
</div>
</main>
</div>
<div class="modalback" id="mappingModal">
<div class="modal">
<div class="modalhead"><h2 id="mappingModalTitle">Zuordnung bearbeiten</h2><button class="btn ghost" id="closeMapping">✕</button></div>
<div class="modalbody">
<div class="section-title">Grunddaten</div>
<div class="grid g2"><div class="field"><label>Name <span class="req">*</span></label><input id="mName" type="text"></div><div class="field"><label>Interne ID <span class="req">*</span></label><input id="mId" type="text"><div class="help">Eindeutiger technischer Schlüssel, z. B. <code>leitstelle-feuer</code>.</div></div></div>
<label class="switch"><input id="mEnabled" type="checkbox"><span>Zuordnung aktiv</span></label>
<div class="section-title">Wann soll die Regel greifen?</div>
<div class="grid g2"><div class="field"><label>Eingang</label><select id="mSource"><option value="any">Beliebig</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="webhook">Webhook</option><option value="mail">Mail</option><option value="discord">Discord</option></select></div><div class="field"><label>Mindest-Priorität</label><input id="mPriority" type="number" min="0" placeholder="0 = egal"></div></div>
<div class="grid g3"><div class="field"><label>Kanal / Topic (Regex)</label><input id="mChannel" type="text" placeholder="^alarm$"></div><div class="field"><label>Eingangs-Titel (Regex)</label><input id="mTitleRegex" type="text" placeholder="optional"></div><div class="field"><label>Meldung (Regex)</label><input id="mMessageRegex" type="text" placeholder="optional"></div></div>
<div class="help">Leere Felder bedeuten „egal“. Reguläre Ausdrücke werden vor dem Speichern serverseitig geprüft.</div>
<div class="section-title">Wohin soll die Meldung gehen?</div>
<div class="grid g2"><div class="field"><label>Typ</label><select id="mTarget"><option value="alarm">Alarmierung</option><option value="news">Mitteilung</option><option value="event">Termin</option><option value="discord">Discord</option><option value="webhook">HTTP-Webhook</option><option value="smtp">Mail (SMTP)</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option></select></div><div class="field" id="diveraNotifyField"><label>Empfänger-Auswahl</label><select id="mNotifyType"><option value="2">Alle der aktuellen Einheit</option><option value="3">Ausgewählte Gruppen</option><option value="4">Ausgewählte Personen</option><option value="1">Einheitsübergreifend</option></select></div></div>
<div class="field" id="outboundSelectField" hidden><label>Ausgang</label><select id="mOutbound"></select><div class="help">Ausgänge werden unter „Ausgänge“ angelegt. Discord sendet Titel und Meldung als Text, ohne automatische Erwähnungen (max. 2000 Zeichen). HTTP-Webhooks erhalten die zugeordneten Nachrichtenfelder als JSON.</div></div>
<div class="field"><label>Titel / Stichwort <span class="req">*</span></label><input id="mKeyword" type="text" placeholder="z. B. {{.Title}}"><div class="help">Templates: <code>{{.Title}}</code>, <code>{{.Message}}</code>, <code>{{.Channel}}</code>, <code>{{.Priority}}</code>.</div></div>
<div class="field"><label>Meldung</label><textarea id="mText" placeholder="{{.Message}}"></textarea></div>
<div class="field" id="addressField"><label>Einsatzort / Adresse</label><input id="mAddress" type="text" placeholder="{{.Address}}"></div>
<div id="diveraMappingFields"><div class="section-title">Empfänger</div>
<div id="unitRoutesWrap" style="display:none"><div class="field"><div class="label">Einheiten und Empfängerart</div><div id="unitRoutes"></div><button class="btn small" id="addUnitRoute" type="button">+ Einheit hinzufügen</button><div class="help">Für jede ausgewählte Einheit wird festgelegt, ob dort alle, Gruppen oder Personen alarmiert werden.</div></div></div>
<div class="grid g3">
<div class="field"><div class="label">Gruppen</div><input class="catalogSearch" data-kind="groups" type="text" placeholder="Suchen …"><div class="catalogbox" id="cat-groups"></div><input id="manualGroups" type="text" placeholder="Zusätzliche IDs: 1,2,3" style="margin-top:7px"></div>
<div class="field"><div class="label">Personen</div><input class="catalogSearch" data-kind="persons" type="text" placeholder="Suchen …"><div class="catalogbox" id="cat-persons"></div><input id="manualPersons" type="text" placeholder="Zusätzliche IDs: 1,2,3" style="margin-top:7px"></div>
<div class="field"><div class="label">Fahrzeuge</div><input class="catalogSearch" data-kind="vehicles" type="text" placeholder="Suchen …"><div class="catalogbox" id="cat-vehicles"></div><input id="manualVehicles" type="text" placeholder="Zusätzliche IDs: 1,2,3" style="margin-top:7px"></div>
</div>
<div class="section-title">Versandwege</div>
<div class="checkgrid"><label class="check"><input id="mPush" type="checkbox"> Push</label><label class="check"><input id="mSms" type="checkbox"> SMS</label><label class="check"><input id="mCall" type="checkbox"> Sprachanruf</label><label class="check"><input id="mMail" type="checkbox"> E-Mail</label><label class="check"><input id="mPager" type="checkbox"> Pager</label><label class="check"><input id="mPrivate" type="checkbox"> Privatmodus</label></div>
</div>
<div class="section-title">Test & Vorschau</div>
<div class="grid g2"><div><div class="grid g2"><div class="field"><label>Quelle</label><select id="pSource"><option>ntfy</option><option>gotify</option><option>webhook</option><option>mail</option><option>discord</option></select></div><div class="field"><label>Kanal</label><input id="pChannel" value="alarm"></div></div><div class="field"><label>Eingangs-Titel</label><input id="pTitle" value="FEUER 2"></div><div class="field"><label>Meldung</label><textarea id="pMessage">Rauchentwicklung in Gebäude</textarea></div><div class="grid g2"><div class="field"><label>Adresse</label><input id="pAddress" value="Musterstraße 1"></div><div class="field"><label>Priorität</label><input id="pPriority" type="number" value="5"></div></div><button class="btn" id="runPreview" type="button">Regel prüfen</button></div><div><div class="label" style="margin-bottom:6px;font-weight:650">Ergebnis</div><div class="preview" id="previewOut">Noch nicht geprüft.</div></div></div>
</div>
<div class="modalfoot"><button class="btn" id="cancelMapping">Abbrechen</button><button class="btn primary" id="applyMapping">Zuordnung übernehmen</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
let cfg=null, catalog={units:[],groups:[],persons:[],vehicles:[]}, dirty=false, editingIndex=-1;
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
function toast(msg,bad=false){const t=$('#toast');t.textContent=msg;t.className='toast show'+(bad?' bad':'');setTimeout(()=>t.className='toast',3600)}
function markDirty(){dirty=true;$('#unsaved').classList.add('show')}
function clean(){dirty=false;$('#unsaved').classList.remove('show')}
let csrf='';
async function csrfValue(){if(!csrf){const r=await fetch('/ui/api/csrf');if(!r.ok)throw Error('Bitte neu anmelden');csrf=(await r.json()).token}return csrf}
async function jsonFetch(url,opt={}){const headers={'Content-Type':'application/json',...(opt.headers||{})};if(opt.method&&opt.method!=='GET')headers['X-CSRF-Token']=await csrfValue();const r=await fetch(url,{...opt,headers});const text=await r.text();let j={};try{j=JSON.parse(text)}catch(e){}if(!r.ok)throw new Error(j.error||text||('HTTP '+r.status));return j}
$$('form[method="post"]').forEach(f=>f.addEventListener('submit',async e=>{e.preventDefault();try{let i=f.querySelector('[name=csrf_token]');if(!i){i=document.createElement('input');i.type='hidden';i.name='csrf_token';f.append(i)}i.value=await csrfValue();f.submit()}catch(err){toast(err.message,true)}}));
async function init(){try{cfg=await jsonFetch('/ui/api/config');bindConfig();renderTokens();renderMappings();renderCatalog();updateMetrics();clean()}catch(e){toast('Konfiguration konnte nicht geladen werden: '+e.message,true)}}
function showPage(p){$$('.page').forEach(x=>x.classList.toggle('active',x.id==='page-'+p));$$('#nav button').forEach(x=>x.classList.toggle('active',x.dataset.page===p));$('#mobileNav').value=p;const names={overview:'Übersicht',divera247:'Divera247',ingress:'Eingänge',outbounds:'Ausgänge',mappings:'Zuordnungen',history:'Zustellhistorie',system:'Server & Sicherheit'};$('#pageTitle').textContent=names[p]||p;if(p==='history')loadHistory()}
$$('#nav button').forEach(b=>b.onclick=()=>showPage(b.dataset.page));$('#mobileNav').onchange=e=>showPage(e.target.value);
function bindConfig(){
renderMailIngress();renderDiscordIngress();
cfg.outbounds=cfg.outbounds||[];renderOutbounds();
$('#divBase').value=cfg.divera.base_url||'';$('#divKey').value=cfg.divera.access_key||'';$('#divUcr').value=cfg.divera.ucr||'';$('#divTimeout').value=cfg.divera.timeout_seconds||15;$('#divDry').checked=!!cfg.divera.dry_run;
$('#allowUnauth').checked=!!cfg.ingress.allow_unauthenticated;$('#serverListen').value=cfg.server.listen||':8080';$('#adminUser').value=cfg.server.admin_username||'admin';
['divBase','divKey','divUcr','divTimeout','divDry','allowUnauth','serverListen','adminUser'].forEach(id=>$('#'+id).addEventListener('input',markDirty));
}
function collectTop(){cfg.divera.base_url=$('#divBase').value.trim();cfg.divera.access_key=$('#divKey').value.trim();cfg.divera.ucr=Number($('#divUcr').value)||0;cfg.divera.timeout_seconds=Number($('#divTimeout').value)||15;cfg.divera.dry_run=$('#divDry').checked;cfg.ingress.allow_unauthenticated=$('#allowUnauth').checked;cfg.server.listen=$('#serverListen').value.trim();cfg.server.admin_username=$('#adminUser').value.trim();collectTokens()}
$('#toggleKey').onclick=()=>{const i=$('#divKey');i.type=i.type==='password'?'text':'password';$('#toggleKey').textContent=i.type==='password'?'Anzeigen':'Verbergen'};
$('#saveAll').onclick=async()=>{try{collectTop();const r=await jsonFetch('/ui/api/config',{method:'PUT',body:JSON.stringify(cfg)});cfg=r.config;bindConfig();renderTokens();renderMappings();updateMetrics();clean();toast('Konfiguration gespeichert')}catch(e){toast('Speichern fehlgeschlagen: '+e.message,true)}};
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue=''}});
function tokenInput(channel,token,kind){return `<div class="tokenrow"><input type="text" data-role="channel" value="${esc(channel)}" placeholder="${kind==='ntfy'?'Topic':'Kanal'}"><input type="password" class="secret" data-role="token" value="${esc(token)}" placeholder="Token"><button class="btn small danger" data-remove-token>Entfernen</button></div>`}
function renderTokens(){const n=$('#ntfyTokens'),w=$('#webhookTokens'),g=$('#gotifyTokens');n.innerHTML=Object.entries(cfg.ingress.ntfy_tokens||{}).map(([k,v])=>tokenInput(k,v,'ntfy')).join('')||'<div class="help">Noch kein Token.</div>';w.innerHTML=Object.entries(cfg.ingress.webhook_tokens||{}).map(([k,v])=>tokenInput(k,v,'webhook')).join('')||'<div class="help">Noch kein Token.</div>';g.innerHTML=(cfg.ingress.gotify_tokens||[]).map(v=>`<div class="tokenrow" style="grid-template-columns:1fr auto"><input type="password" class="secret" data-role="token" value="${esc(v)}" placeholder="Gotify-Token"><button class="btn small danger" data-remove-token>Entfernen</button></div>`).join('')||'<div class="help">Noch kein Token.</div>';wireTokenRows()}
function wireTokenRows(){$$('[data-remove-token]').forEach(b=>b.onclick=()=>{b.parentElement.remove();markDirty()});$$('#ntfyTokens input,#webhookTokens input,#gotifyTokens input').forEach(i=>i.oninput=markDirty)}
$$('[data-add-token]').forEach(b=>b.onclick=()=>{const kind=b.dataset.addToken,box=$('#'+(kind==='ntfy'?'ntfyTokens':kind==='webhook'?'webhookTokens':'gotifyTokens'));if(box.querySelector('.help'))box.innerHTML='';box.insertAdjacentHTML('beforeend',kind==='gotify'?`<div class="tokenrow" style="grid-template-columns:1fr auto"><input type="password" class="secret" data-role="token" placeholder="Gotify-Token"><button class="btn small danger" data-remove-token>Entfernen</button></div>`:tokenInput('','',kind));wireTokenRows();markDirty()});
function collectTokens(){cfg.ingress.ntfy_tokens={};$$('#ntfyTokens .tokenrow').forEach(r=>{const k=r.querySelector('[data-role=channel]').value.trim(),v=r.querySelector('[data-role=token]').value.trim();if(k&&v)cfg.ingress.ntfy_tokens[k]=v});cfg.ingress.webhook_tokens={};$$('#webhookTokens .tokenrow').forEach(r=>{const k=r.querySelector('[data-role=channel]').value.trim(),v=r.querySelector('[data-role=token]').value.trim();if(k&&v)cfg.ingress.webhook_tokens[k]=v});cfg.ingress.gotify_tokens=$$('#gotifyTokens [data-role=token]').map(i=>i.value.trim()).filter(Boolean)}
$('#loadCatalog').onclick=async()=>{try{collectTop();await jsonFetch('/ui/api/config',{method:'PUT',body:JSON.stringify(cfg)});const r=await jsonFetch('/ui/api/divera247/catalog');catalog=r.catalog||catalog;renderCatalog();const warnings=r.warnings||[],counts=`${catalog.units?.length||0} Einheiten, ${catalog.groups?.length||0} Gruppen, ${catalog.persons?.length||0} Personen, ${catalog.vehicles?.length||0} Fahrzeuge`,diag=r.diagnostics||{},source=diag.person_source?` Personenquelle: ${diag.person_source}.`:'';$('#diveraDot').className=warnings.length?'dot':'dot good';$('#diveraStatus').textContent=warnings.length?'Verbunden mit Hinweisen':'Verbindung OK';$('#catalogMessage').className=warnings.length?'callout warn':'callout';$('#catalogMessage').textContent=`Stammdaten geladen: ${counts}.${source}`+(warnings.length?' Hinweise: '+warnings.join(' | '):'');clean();toast(warnings.length?'Divera247 geladen – mit Hinweisen':'Divera247-Stammdaten erfolgreich geladen',warnings.length)}catch(e){$('#diveraDot').className='dot bad';$('#diveraStatus').textContent='Fehler';$('#catalogMessage').className='callout warn';$('#catalogMessage').textContent=e.message;toast('Divera247: '+e.message,true)}};
function renderCatalog(){for(const k of ['units','groups','persons','vehicles'])$('#count'+k[0].toUpperCase()+k.slice(1)).textContent=(catalog[k]||[]).length;for(const k of ['groups','persons','vehicles'])renderCatalogBox(k,'');if($('#unitRoutesWrap'))renderUnitRoutes()}
function renderCatalogBox(kind,filter){const box=$('#cat-'+kind);if(!box)return;const field={groups:'groups',persons:'users',vehicles:'vehicles'}[kind];const selected=new Set((editingMapping()?.[field]||[]).map(Number));const list=(catalog[kind]||[]).filter(x=>(x.label+' '+(x.subtitle||'')+' '+x.id).toLowerCase().includes((filter||'').toLowerCase()));const idLabel=kind==='persons'?'UCR-ID':'ID';box.innerHTML=list.length?list.map(x=>`<label class="catitem"><input type="checkbox" data-cat-kind="${kind}" value="${x.id}" ${selected.has(Number(x.id))?'checked':''}><span><strong>${esc(x.label)}</strong><small>${idLabel} ${x.id}${x.subtitle?' · '+esc(x.subtitle):''}</small></span></label>`).join(''):'<div class="empty" style="padding:18px">Keine Stammdaten. Lade sie unter Divera247 oder trage IDs manuell ein.</div>';$$(`[data-cat-kind="${kind}"]`).forEach(cb=>cb.onchange=()=>{if(!draft)return;const id=Number(cb.value),set=new Set((draft[field]||[]).map(Number));cb.checked?set.add(id):set.delete(id);draft[field]=[...set]})}
$$('.catalogSearch').forEach(i=>i.oninput=()=>renderCatalogBox(i.dataset.kind,i.value));
function mappingCondition(m){const a=[];if(m.channel_regex)a.push('Kanal '+m.channel_regex);if(m.title_regex)a.push('Titel '+m.title_regex);if(m.message_regex)a.push('Meldung '+m.message_regex);if(m.min_priority)a.push('Prio ≥ '+m.min_priority);return a.join(' · ')||'immer'}
function targetLabel(t){return ({alarm:'Alarmierung',news:'Mitteilung',event:'Termin',discord:'Discord',webhook:'HTTP-Webhook',smtp:'Mail (SMTP)',ntfy:'ntfy',gotify:'Gotify'})[t]||t}
function renderMappings(){const box=$('#mappingList');if(!cfg.mappings?.length){box.innerHTML='<div class="empty">Noch keine Zuordnung vorhanden. Erstelle die erste Regel über „+ Zuordnung“.</div>';return}box.innerHTML=cfg.mappings.map((m,i)=>`<div class="mapping-row"><div class="drag">⋮⋮</div><div><div class="mapping-name">${esc(m.name||m.id)}</div><div class="mapping-desc">${esc(m.id)}</div></div><div><span class="badge">${esc(m.source||'any')}</span></div><div class="mapping-desc">${esc(mappingCondition(m))}</div><div><span class="badge ${m.enabled?'good':'off'}">${m.enabled?esc(targetLabel(m.target)+(m.outbound_id?' · '+m.outbound_id:'')):'deaktiviert'}</span></div><div class="inline"><button class="btn small" data-edit="${i}">Bearbeiten</button><button class="btn small danger" data-del="${i}">Löschen</button></div></div>`).join('');$$('[data-edit]').forEach(b=>b.onclick=()=>openMapping(Number(b.dataset.edit)));$$('[data-del]').forEach(b=>b.onclick=()=>{if(confirm('Zuordnung wirklich löschen?')){cfg.mappings.splice(Number(b.dataset.del),1);renderMappings();updateMetrics();markDirty()}})}
function blankMapping(){return {id:'',name:'',enabled:true,source:'any',channel_regex:'',title_regex:'',message_regex:'',min_priority:0,target:'alarm',title_template:'{{.Title}}',text_template:'{{.Message}}',address_template:'{{.Address}}',notification_type:2,clusters:[],cluster_routes:{},groups:[],users:[],vehicles:[],send_push:true,send_sms:false,send_call:false,send_mail:false,send_pager:false,private_mode:false,extra:{}}}
let draft=null;function editingMapping(){return draft}
$('#addMapping').onclick=()=>openMapping(-1);$('#closeMapping').onclick=$('#cancelMapping').onclick=closeMapping;
function openMapping(i){editingIndex=i;draft=structuredClone(i>=0?cfg.mappings[i]:blankMapping());$('#mappingModalTitle').textContent=i>=0?'Zuordnung bearbeiten':'Neue Zuordnung';$('#mName').value=draft.name||'';$('#mId').value=draft.id||'';$('#mEnabled').checked=!!draft.enabled;$('#mSource').value=draft.source||'any';$('#mPriority').value=draft.min_priority||0;$('#mChannel').value=draft.channel_regex||'';$('#mTitleRegex').value=draft.title_regex||'';$('#mMessageRegex').value=draft.message_regex||'';$('#mTarget').value=draft.target||'alarm';updateMappingTarget();$('#mNotifyType').value=String(draft.notification_type||2);$('#mKeyword').value=draft.title_template||'';$('#mText').value=draft.text_template||'';$('#mAddress').value=draft.address_template||'';$('#mPush').checked=!!draft.send_push;$('#mSms').checked=!!draft.send_sms;$('#mCall').checked=!!draft.send_call;$('#mMail').checked=!!draft.send_mail;$('#mPager').checked=!!draft.send_pager;$('#mPrivate').checked=!!draft.private_mode;$('#manualGroups').value='';$('#manualPersons').value='';$('#manualVehicles').value='';$('#previewOut').textContent='Noch nicht geprüft.';renderCatalog();toggleUnitRoutes();$('#mappingModal').classList.add('show');document.body.style.overflow='hidden'}
function closeMapping(){draft=null;editingIndex=-1;$('#mappingModal').classList.remove('show');document.body.style.overflow=''}
$('#mNotifyType').onchange=toggleUnitRoutes;function toggleUnitRoutes(){const on=$('#mNotifyType').value==='1';$('#unitRoutesWrap').style.display=on?'block':'none';if(on)renderUnitRoutes()}
function renderUnitRoutes(){const box=$('#unitRoutes');if(!draft)return;draft.cluster_routes=draft.cluster_routes||{};const entries=Object.entries(draft.cluster_routes);box.innerHTML=entries.map(([id,mode],idx)=>unitRouteHTML(id,mode,idx)).join('')||'<div class="help">Noch keine Einheit ausgewählt.</div>';$$('[data-unit-id]').forEach(s=>s.onchange=e=>{const row=e.target.closest('.unit-route'),old=row.dataset.key,newId=e.target.value,mode=Number(row.querySelector('[data-unit-mode]').value);delete draft.cluster_routes[old];if(newId)draft.cluster_routes[newId]=mode;renderUnitRoutes()});$$('[data-unit-mode]').forEach(s=>s.onchange=e=>{const row=e.target.closest('.unit-route');if(row.dataset.key)draft.cluster_routes[row.dataset.key]=Number(e.target.value)});$$('[data-unit-del]').forEach(b=>b.onclick=()=>{delete draft.cluster_routes[b.closest('.unit-route').dataset.key];renderUnitRoutes()})}
function unitRouteHTML(id,mode,idx){const opts=(catalog.units||[]).map(x=>`<option value="${x.id}" ${String(x.id)===String(id)?'selected':''}>${esc(x.label)} (ID ${x.id})</option>`).join('');const fallback=id&&!catalog.units?.some(x=>String(x.id)===String(id))?`<option value="${esc(id)}" selected>ID ${esc(id)}</option>`:'';return `<div class="unit-route" data-key="${esc(id)}"><select data-unit-id><option value="">Einheit wählen …</option>${fallback}${opts}</select><select data-unit-mode><option value="2" ${Number(mode)===2?'selected':''}>Alle</option><option value="3" ${Number(mode)===3?'selected':''}>Gruppen</option><option value="4" ${Number(mode)===4?'selected':''}>Personen</option></select><button class="btn small danger" type="button" data-unit-del>Entfernen</button></div>`}
$('#addUnitRoute').onclick=()=>{draft.cluster_routes=draft.cluster_routes||{};let id='';for(const x of catalog.units||[])if(!(String(x.id) in draft.cluster_routes)){id=String(x.id);break}if(!id){id=prompt('Divera247 Einheiten-ID eingeben:','')||''}if(id){draft.cluster_routes[id]=2;renderUnitRoutes()}};
function parseIDs(s){return [...new Set(String(s||'').split(/[;,\s]+/).map(Number).filter(n=>Number.isInteger(n)&&n>0))]}
function checkedIDs(kind){return $$(`[data-cat-kind="${kind}"]:checked`).map(x=>Number(x.value))}
function collectDraft(){draft.id=$('#mId').value.trim();draft.name=$('#mName').value.trim();draft.enabled=$('#mEnabled').checked;draft.source=$('#mSource').value;draft.min_priority=Number($('#mPriority').value)||0;draft.channel_regex=$('#mChannel').value.trim();draft.title_regex=$('#mTitleRegex').value.trim();draft.message_regex=$('#mMessageRegex').value.trim();draft.target=$('#mTarget').value;draft.outbound_id=['discord','webhook','smtp','ntfy','gotify'].includes(draft.target)?$('#mOutbound').value:'';draft.notification_type=Number($('#mNotifyType').value);draft.title_template=$('#mKeyword').value;draft.text_template=$('#mText').value;draft.address_template=$('#mAddress').value;draft.groups=[...new Set([...(draft.groups||[]),...checkedIDs('groups'),...parseIDs($('#manualGroups').value)])];draft.users=[...new Set([...(draft.users||[]),...checkedIDs('persons'),...parseIDs($('#manualPersons').value)])];draft.vehicles=[...new Set([...(draft.vehicles||[]),...checkedIDs('vehicles'),...parseIDs($('#manualVehicles').value)])];draft.send_push=$('#mPush').checked;draft.send_sms=$('#mSms').checked;draft.send_call=$('#mCall').checked;draft.send_mail=$('#mMail').checked;draft.send_pager=$('#mPager').checked;draft.private_mode=$('#mPrivate').checked;if(draft.notification_type!==1)draft.cluster_routes={};return draft}
$('#applyMapping').onclick=()=>{const m=collectDraft();if(['discord','webhook','smtp','ntfy','gotify'].includes(m.target)&&!m.outbound_id){toast('Bitte einen passenden Ausgang anlegen und auswählen.',true);return}if(!m.name||!m.id||!m.title_template){toast('Name, interne ID und Stichwort sind Pflichtfelder.',true);return}if(cfg.mappings.some((x,i)=>x.id===m.id&&i!==editingIndex)){toast('Diese interne ID ist bereits vergeben.',true);return}if(editingIndex>=0)cfg.mappings[editingIndex]=structuredClone(m);else cfg.mappings.push(structuredClone(m));renderMappings();updateMetrics();markDirty();closeMapping()};
$('#runPreview').onclick=async()=>{try{const m=collectDraft();const body={mapping:m,message:{source:$('#pSource').value,channel:$('#pChannel').value,title:$('#pTitle').value,message:$('#pMessage').value,address:$('#pAddress').value,priority:Number($('#pPriority').value)||0}};const r=await jsonFetch('/ui/api/preview',{method:'POST',body:JSON.stringify(body)});$('#previewOut').textContent=r.matched?JSON.stringify(r.payload,null,2):'Regel passt NICHT auf diese Testmeldung.'}catch(e){$('#previewOut').textContent='Fehler: '+e.message}};
function inputField(label,key,value,type='text',help=''){return `<div class="field"><label>${esc(label)}</label><input type="${type}" data-field="${key}" value="${esc(value??'')}" autocomplete="off">${help?'<div class="help">'+esc(help)+'</div>':''}</div>`}
function readField(el){return el.type==='checkbox'?el.checked:el.type==='number'?Number(el.value):el.value}
function csv(value){return String(value||'').split(/[,;\n]/).map(x=>x.trim()).filter(Boolean)}
function renderOutbounds(){
const box=$('#outboundList');cfg.outbounds=cfg.outbounds||[];
box.innerHTML=cfg.outbounds.map((o,i)=>`<div class="card" data-output="${i}">
${inputField('Name','name',o.name)}${inputField('Interne ID','id',o.id)}
<div class="field"><label>Provider</label><select data-field="provider">${['discord','webhook','smtp','ntfy','gotify'].map(k=>`<option value="${k}" ${o.provider===k?'selected':''}>${targetLabel(k)}</option>`).join('')}</select></div>
${o.provider==='smtp'?inputField('SMTP-Host','smtp_host',o.smtp_host)+inputField('Port','smtp_port',o.smtp_port||587,'number')+`<div class="field"><label>Verschlüsselung</label><select data-field="tls_mode"><option value="starttls" ${o.tls_mode!=='tls'?'selected':''}>STARTTLS (meist 587)</option><option value="tls" ${o.tls_mode==='tls'?'selected':''}>TLS (meist 465)</option></select></div>`+inputField('Benutzer','username',o.username)+inputField('Passwort / env:VARIABLE','password',o.password,'password')+inputField('Absenderadresse','from',o.from)+inputField('Empfänger, mit Komma getrennt','to',(o.to||[]).join(', ')):inputField('URL','url',o.url,'text',o.provider==='ntfy'?'Server-Basis-URL, z. B. https://ntfy.sh':o.provider==='gotify'?'Vollständiger Endpunkt, z. B. https://gotify.example/message':'Vollständige Webhook-URL')+(o.provider==='discord'?'':inputField(o.provider==='gotify'?'App-Token / env:VARIABLE':'Bearer-Token / env:VARIABLE','bearer_token',o.bearer_token,'password'))+(o.provider==='ntfy'?inputField('Topic','topic',o.topic):'')}
${inputField('Timeout (Sekunden, 0 = 15)','timeout_seconds',o.timeout_seconds||0,'number')}
<label class="switch"><input type="checkbox" data-field="live" ${o.live?'checked':''}> Live-Versand aktivieren</label>
<button class="btn small danger" data-remove-output="${i}" style="margin-top:14px">Entfernen</button></div>`).join('')||'<div class="empty">Noch keine zusätzlichen Ausgänge.</div>';
$$('[data-output] [data-field]').forEach(el=>el.oninput=()=>{const o=cfg.outbounds[Number(el.closest('[data-output]').dataset.output)];o[el.dataset.field]=el.dataset.field==='to'?csv(el.value):readField(el);if(el.dataset.field==='provider'){if(o.provider==='smtp'){o.smtp_port=o.smtp_port||587;o.tls_mode=o.tls_mode||'starttls'}if(o.provider==='discord')o.bearer_token='';renderOutbounds()}markDirty()});
$$('[data-remove-output]').forEach(b=>b.onclick=()=>{const i=Number(b.dataset.removeOutput),o=cfg.outbounds[i];if(cfg.mappings.some(m=>m.outbound_id===o.id)){toast('Dieser Ausgang wird noch verwendet.',true);return}cfg.outbounds.splice(i,1);renderOutbounds();markDirty()});
}
$('#addOutbound').onclick=()=>{cfg.outbounds.push({id:'out-'+Date.now(),name:'Neuer Ausgang',provider:'discord',url:'',timeout_seconds:15,live:false});renderOutbounds();markDirty()};
function renderMailIngress(){
cfg.ingress.mail=cfg.ingress.mail||[];
$('#mailIngressList').innerHTML=cfg.ingress.mail.map((m,i)=>`<div class="card" data-mail="${i}">${inputField('Interne ID','id',m.id)}<label class="switch"><input type="checkbox" data-field="enabled" ${m.enabled?'checked':''}> Eingang aktiv</label>${inputField('IMAP-Adresse (Host:Port)','address',m.address)}${inputField('Benutzer','username',m.username)}${inputField('Passwort / env:VARIABLE','password',m.password,'password')}${inputField('Ordner','folder',m.folder||'INBOX')}${inputField('Kanal für Zuordnungen','channel',m.channel)}${inputField('Abfrageintervall (Sekunden)','poll_seconds',m.poll_seconds||60,'number')}${inputField('Absenderfilter (optional, Kommaliste)','from',(m.from||[]).join(', '))}${inputField('Empfängerfilter (optional, Kommaliste)','to',(m.to||[]).join(', '))}<label class="switch"><input type="checkbox" data-field="import_existing" ${m.import_existing?'checked':''}> Vorhandene Nachrichten importieren (bei erstmaliger Einrichtung)</label><button class="btn small danger" data-remove-mail="${i}">Entfernen</button></div>`).join('');
$$('[data-mail] [data-field]').forEach(el=>el.oninput=()=>{const m=cfg.ingress.mail[Number(el.closest('[data-mail]').dataset.mail)];m[el.dataset.field]=['from','to'].includes(el.dataset.field)?csv(el.value):readField(el);markDirty()});
$$('[data-remove-mail]').forEach(b=>b.onclick=()=>{cfg.ingress.mail.splice(Number(b.dataset.removeMail),1);renderMailIngress();markDirty()});
}
$('#addMail').onclick=()=>{cfg.ingress.mail.push({id:'mail-'+Date.now(),enabled:false,address:'',username:'',password:'',folder:'INBOX',channel:'mail',poll_seconds:60,import_existing:false});renderMailIngress();markDirty()};
function renderDiscordIngress(){
const d=cfg.ingress.discord=cfg.ingress.discord||{};
$('#discordIngress').innerHTML=`<div class="help">Endpoint: /in/discord. Die Discord-Anwendung benötigt einen Slash-Command mit der Pflichtoption message und optional title/priority. Der Public Key prüft die Signatur; Guild und Channel müssen freigegeben sein.</div><label class="switch"><input type="checkbox" data-field="enabled" ${d.enabled?'checked':''}> Discord-Eingang aktiv</label>${inputField('Application-ID','application_id',d.application_id)}${inputField('Public Key (Hex)','public_key',d.public_key)}${inputField('Command-Name','command',d.command||'notify')}${inputField('Guild-IDs (Kommaliste)','guild_ids',(d.guild_ids||[]).join(', '))}${inputField('Channel-IDs (Kommaliste)','channel_ids',(d.channel_ids||[]).join(', '))}`;
$$('#discordIngress [data-field]').forEach(el=>el.oninput=()=>{d[el.dataset.field]=['guild_ids','channel_ids'].includes(el.dataset.field)?csv(el.value):readField(el);markDirty()});
if(!d.command)d.command='notify';
}
let historyOffset=0;
const stateLabels={pending:'Wartet',sending:'Wird gesendet',succeeded:'Zugestellt',dry_run:'Dry-Run',dead:'Fehlgeschlagen'};
async function loadHistory(){try{
const r=await jsonFetch('/ui/api/deliveries?offset='+historyOffset);
$('#historyCounts').textContent=Object.entries(r.counts).map(([k,v])=>(stateLabels[k]||k)+': '+v).join(' · ');
$('#historyRows').innerHTML=r.deliveries.map(j=>`<tr><td>${esc(new Date(j.created_at*1000).toLocaleString())}</td><td>${esc(j.mapping_id)}<br><small>${esc(j.receipt_id)}</small></td><td>${esc(targetLabel(j.target))}</td><td>${esc(stateLabels[j.state]||j.state)}<br>${esc(j.error||'')}</td><td>${j.attempts}</td><td><button class="btn small" data-attempts="${esc(j.id)}">Details</button>${j.state==='dead'?`<button class="btn small" data-retry="${esc(j.id)}">Erneut versuchen</button>`:''}</td></tr>`).join('');
$('#historyPrev').disabled=historyOffset===0;$('#historyNext').disabled=r.deliveries.length<50;
$('#mailStatus').textContent=(r.mail||[]).map(m=>m.id+': '+(m.error||'Abruf erfolgreich')+' ('+new Date(m.at).toLocaleString()+')').join(' · ')||'Noch keine Mail-Abfragen.';
$$('[data-attempts]').forEach(b=>b.onclick=async()=>{try{$('#attemptHistory').textContent=JSON.stringify(await jsonFetch('/ui/api/deliveries/'+encodeURIComponent(b.dataset.attempts)+'/attempts'),null,2)}catch(e){toast(e.message,true)}});
$$('[data-retry]').forEach(b=>b.onclick=async()=>{if(!confirm('Diese Zustellung mit dem ursprünglich gespeicherten Ziel und Inhalt erneut versuchen? Bei unklarer Provider-Antwort kann eine Nachricht bereits angekommen sein.'))return;try{await jsonFetch('/ui/api/deliveries/'+encodeURIComponent(b.dataset.retry)+'/retry',{method:'POST'});loadHistory()}catch(e){toast(e.message,true)}});
}catch(e){toast(e.message,true)}}
$('#refreshHistory').onclick=loadHistory;$('#historyPrev').onclick=()=>{historyOffset=Math.max(0,historyOffset-50);loadHistory()};$('#historyNext').onclick=()=>{historyOffset+=50;loadHistory()};
function updateMappingTarget(){
const target=$('#mTarget').value,external=['discord','webhook','smtp','ntfy','gotify'].includes(target);
$('#outboundSelectField').hidden=!external;$('#diveraMappingFields').hidden=external;$('#diveraNotifyField').hidden=external;$('#addressField').hidden=target==='discord';
$('#mOutbound').innerHTML='<option value="">Ausgang wählen …</option>'+(cfg.outbounds||[]).filter(o=>o.provider===target).map(o=>`<option value="${esc(o.id)}">${esc(o.name||o.id)} (${o.live?'Live':'Dry-Run'})</option>`).join('');
$('#mOutbound').value=draft?.outbound_id||'';
}
$('#mTarget').onchange=updateMappingTarget;
function updateMetrics(){$('#metricMappings').textContent=(cfg.mappings||[]).filter(x=>x.enabled).length}
init();
</script>
</body>
</html>