255 lines
50 KiB
HTML
255 lines
50 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|