|
@@ -0,0 +1,542 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+<title>Dis2Hook — Relay Console</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ :root{
|
|
|
|
|
+ --ink:#14161C; --panel:#1C1F28; --panel-2:#22263100; --well:#171A22;
|
|
|
|
|
+ --line:#2C3040; --line-soft:#242836;
|
|
|
|
|
+ --text:#E8E6DF; --muted:#8B8FA0; --faint:#5A5F70;
|
|
|
|
|
+ --amber:#F0A93B; --amber-dim:#7A5B26;
|
|
|
|
|
+ --blurple:#5865F2; --blurple-dim:#3A4290;
|
|
|
|
|
+ --ok:#57C08A; --fail:#E06060; --warn:#E0B060;
|
|
|
|
|
+ --mono:ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
|
|
|
|
|
+ --sans:system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
|
|
|
+ }
|
|
|
|
|
+ *{box-sizing:border-box;margin:0;padding:0}
|
|
|
|
|
+ html{color-scheme:dark}
|
|
|
|
|
+ body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:15px;line-height:1.5;min-height:100vh}
|
|
|
|
|
+ body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
|
|
|
|
|
+ background:repeating-linear-gradient(0deg,transparent 0 3px,rgba(255,255,255,.012) 3px 4px)}
|
|
|
|
|
+ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
|
|
|
+ input,select,textarea{font-family:var(--mono);font-size:13px;color:var(--text);
|
|
|
|
|
+ background:var(--well);border:1px solid var(--line);border-radius:4px;padding:7px 9px;width:100%}
|
|
|
|
|
+ input:focus,select:focus,textarea:focus,button:focus-visible{outline:2px solid var(--amber);outline-offset:1px}
|
|
|
|
|
+ input::placeholder,textarea::placeholder{color:var(--faint)}
|
|
|
|
|
+ textarea{resize:vertical;min-height:72px;line-height:1.55}
|
|
|
|
|
+ ::selection{background:var(--amber-dim);color:var(--text)}
|
|
|
|
|
+
|
|
|
|
|
+ .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- rack header ---------- */
|
|
|
|
|
+ header{position:relative;z-index:1;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#1E212B,#181B23);
|
|
|
|
|
+ display:flex;align-items:center;gap:22px;padding:14px 22px;flex-wrap:wrap}
|
|
|
|
|
+ .plate{display:flex;align-items:baseline;gap:10px}
|
|
|
|
|
+ .plate h1{font-family:var(--sans);font-weight:800;font-size:19px;letter-spacing:.28em;text-transform:uppercase}
|
|
|
|
|
+ .plate h1 .two{color:var(--amber)}
|
|
|
|
|
+ .plate .ver{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
|
|
|
+ .lamp{width:9px;height:9px;border-radius:50%;background:var(--faint);flex:none;box-shadow:0 0 0 3px rgba(255,255,255,.03)}
|
|
|
|
|
+ .lamp.ok{background:var(--ok);box-shadow:0 0 8px rgba(87,192,138,.7)}
|
|
|
|
|
+ .lamp.fail{background:var(--fail);box-shadow:0 0 8px rgba(224,96,96,.7)}
|
|
|
|
|
+ .meter{display:flex;gap:20px;margin-left:auto;align-items:center;flex-wrap:wrap}
|
|
|
|
|
+ .gauge{text-align:right}
|
|
|
|
|
+ .gauge b{display:block;font-family:var(--mono);font-size:16px;font-weight:600}
|
|
|
|
|
+ .gauge span{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
|
|
|
|
|
+ .gauge.relayed b{color:var(--ok)} .gauge.failed b{color:var(--fail)}
|
|
|
|
|
+ #botLabel{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:var(--muted)}
|
|
|
|
|
+
|
|
|
|
|
+ main{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:26px 22px 90px;
|
|
|
|
|
+ display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:26px}
|
|
|
|
|
+ @media(max-width:960px){main{grid-template-columns:1fr}}
|
|
|
|
|
+
|
|
|
|
|
+ section h2{display:flex;align-items:center;gap:10px;margin-bottom:14px;
|
|
|
|
|
+ font-size:12px;font-weight:700;letter-spacing:.2em;text-transform:uppercase;color:var(--muted)}
|
|
|
|
|
+ section h2::after{content:"";flex:1;height:1px;background:var(--line-soft)}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- patch strips (source cards) ---------- */
|
|
|
|
|
+ .strip{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-bottom:14px;overflow:hidden}
|
|
|
|
|
+ .strip.disabled{opacity:.55}
|
|
|
|
|
+ .strip-head{display:flex;align-items:center;gap:14px;padding:14px 16px;cursor:pointer}
|
|
|
|
|
+ .strip-head:hover{background:rgba(255,255,255,.02)}
|
|
|
|
|
+ .strip-name{font-weight:600;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
|
|
|
+
|
|
|
|
|
+ /* signature: the relay line */
|
|
|
|
|
+ .relay{flex:1;display:flex;align-items:center;min-width:120px;height:22px}
|
|
|
|
|
+ .jack{width:10px;height:10px;border-radius:50%;flex:none;border:2px solid}
|
|
|
|
|
+ .jack.src{border-color:var(--amber);background:var(--amber-dim)}
|
|
|
|
|
+ .jack.dst{border-color:var(--blurple);background:var(--blurple-dim)}
|
|
|
|
|
+ .wire{flex:1;height:2px;background:linear-gradient(90deg,var(--amber-dim),var(--blurple-dim));position:relative;overflow:visible}
|
|
|
|
|
+ .pulse{position:absolute;top:-2px;left:0;width:14px;height:6px;border-radius:3px;background:var(--amber);
|
|
|
|
|
+ filter:drop-shadow(0 0 5px var(--amber));opacity:0}
|
|
|
|
|
+ .strip.firing .pulse{animation:travel .9s ease-in-out}
|
|
|
|
|
+ @keyframes travel{0%{opacity:1;left:0;background:var(--amber)}100%{opacity:1;left:calc(100% - 14px);background:var(--blurple);filter:drop-shadow(0 0 6px var(--blurple))}}
|
|
|
|
|
+ @media(prefers-reduced-motion:reduce){.strip.firing .pulse{animation:none}}
|
|
|
|
|
+ .chan-tag{font-family:var(--mono);font-size:11px;color:var(--blurple);flex:none;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
+ .caret{color:var(--faint);flex:none;transition:transform .15s}
|
|
|
|
|
+ .strip.open .caret{transform:rotate(90deg)}
|
|
|
|
|
+
|
|
|
|
|
+ .strip-body{display:none;border-top:1px solid var(--line-soft);padding:16px;background:var(--well)}
|
|
|
|
|
+ .strip.open .strip-body{display:block}
|
|
|
|
|
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
|
|
|
+ @media(max-width:640px){.grid2{grid-template-columns:1fr}}
|
|
|
|
|
+ .field{margin-bottom:12px;min-width:0}
|
|
|
|
|
+ .field label{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
|
|
|
|
|
+ .field .hint{font-size:11px;color:var(--faint);margin-top:4px;font-family:var(--sans)}
|
|
|
|
|
+ .inline{display:flex;gap:6px}
|
|
|
|
|
+ .inline input{flex:1;min-width:0}
|
|
|
|
|
+
|
|
|
|
|
+ .hookurl{display:flex;gap:6px;align-items:center;background:var(--panel);border:1px dashed var(--line);
|
|
|
|
|
+ border-radius:4px;padding:7px 9px;font-family:var(--mono);font-size:12px;color:var(--amber);overflow:hidden}
|
|
|
|
|
+ .hookurl code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
+
|
|
|
|
|
+ .filters .filter-row{display:grid;grid-template-columns:1fr 110px 1fr 30px;gap:6px;margin-bottom:6px}
|
|
|
|
|
+ @media(max-width:640px){.filters .filter-row{grid-template-columns:1fr 90px 1fr 30px}}
|
|
|
|
|
+
|
|
|
|
|
+ .strip-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px;align-items:center}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- buttons ---------- */
|
|
|
|
|
+ .btn{font-family:var(--mono);font-size:12px;padding:7px 13px;border-radius:4px;border:1px solid var(--line);
|
|
|
|
|
+ background:var(--panel);color:var(--text);letter-spacing:.04em}
|
|
|
|
|
+ .btn:hover{border-color:var(--muted)}
|
|
|
|
|
+ .btn.primary{background:var(--amber);border-color:var(--amber);color:#1A150A;font-weight:700}
|
|
|
|
|
+ .btn.primary:hover{filter:brightness(1.08)}
|
|
|
|
|
+ .btn.ghost{border-color:transparent;color:var(--muted)} .btn.ghost:hover{color:var(--text)}
|
|
|
|
|
+ .btn.danger{color:var(--fail);border-color:transparent} .btn.danger:hover{border-color:var(--fail)}
|
|
|
|
|
+ .btn.tiny{padding:4px 8px;font-size:11px}
|
|
|
|
|
+ .btn:disabled{opacity:.45;cursor:default}
|
|
|
|
|
+
|
|
|
|
|
+ .toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--mono);font-size:11px;
|
|
|
|
|
+ letter-spacing:.1em;text-transform:uppercase;color:var(--muted);user-select:none}
|
|
|
|
|
+ .toggle input{display:none}
|
|
|
|
|
+ .toggle .track{width:30px;height:16px;border-radius:8px;background:var(--line);position:relative;transition:background .15s;flex:none}
|
|
|
|
|
+ .toggle .track::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--muted);transition:all .15s}
|
|
|
|
|
+ .toggle input:checked+.track{background:var(--amber-dim)}
|
|
|
|
|
+ .toggle input:checked+.track::after{left:16px;background:var(--amber)}
|
|
|
|
|
+
|
|
|
|
|
+ .empty{border:1px dashed var(--line);border-radius:6px;padding:34px 20px;text-align:center;color:var(--muted)}
|
|
|
|
|
+ .empty p{margin-bottom:14px}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- side column ---------- */
|
|
|
|
|
+ .panelbox{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px;margin-bottom:22px}
|
|
|
|
|
+ .panelbox h3{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}
|
|
|
|
|
+ .log{list-style:none;max-height:420px;overflow:auto;font-family:var(--mono);font-size:11.5px}
|
|
|
|
|
+ .log li{display:flex;gap:8px;padding:6px 0;border-bottom:1px solid var(--line-soft);align-items:baseline}
|
|
|
|
|
+ .log time{color:var(--faint);flex:none}
|
|
|
|
|
+ .log .badge{flex:none;font-size:9px;letter-spacing:.12em;text-transform:uppercase;padding:1px 6px;border-radius:3px}
|
|
|
|
|
+ .badge.relayed{color:var(--ok);border:1px solid var(--ok)}
|
|
|
|
|
+ .badge.failed,.badge.rejected{color:var(--fail);border:1px solid var(--fail)}
|
|
|
|
|
+ .badge.filtered{color:var(--warn);border:1px solid var(--warn)}
|
|
|
|
|
+ .log .who{color:var(--text)} .log .what{color:var(--muted);overflow-wrap:anywhere}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- save bar ---------- */
|
|
|
|
|
+ #savebar{position:fixed;left:0;right:0;bottom:0;z-index:5;display:none;justify-content:center;padding:12px;
|
|
|
|
|
+ background:linear-gradient(180deg,transparent,rgba(10,11,15,.92) 40%)}
|
|
|
|
|
+ #savebar.show{display:flex}
|
|
|
|
|
+ #savebar .inner{display:flex;gap:10px;align-items:center;background:var(--panel);border:1px solid var(--amber);
|
|
|
|
|
+ border-radius:6px;padding:10px 14px;box-shadow:0 8px 30px rgba(0,0,0,.5)}
|
|
|
|
|
+ #savebar .msg{font-family:var(--mono);font-size:12px;color:var(--amber)}
|
|
|
|
|
+
|
|
|
|
|
+ #toast{position:fixed;top:16px;right:16px;z-index:9;display:none;font-family:var(--mono);font-size:12.5px;
|
|
|
|
|
+ background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:4px;padding:10px 14px;max-width:340px}
|
|
|
|
|
+ #toast.err{border-left-color:var(--fail)}
|
|
|
|
|
+
|
|
|
|
|
+ /* ---------- login ---------- */
|
|
|
|
|
+ #login{position:fixed;inset:0;z-index:10;background:var(--ink);display:flex;align-items:center;justify-content:center;padding:20px}
|
|
|
|
|
+ #login .plate2{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:30px}
|
|
|
|
|
+ #login h1{font-size:17px;font-weight:800;letter-spacing:.3em;text-transform:uppercase;text-align:center;margin-bottom:4px}
|
|
|
|
|
+ #login h1 .two{color:var(--amber)}
|
|
|
|
|
+ #login .sub{text-align:center;font-family:var(--mono);font-size:11px;color:var(--faint);margin-bottom:22px;letter-spacing:.1em}
|
|
|
|
|
+ #login .err{color:var(--fail);font-family:var(--mono);font-size:12px;margin-top:10px;min-height:16px}
|
|
|
|
|
+ .hidden{display:none!important}
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<div id="login">
|
|
|
|
|
+ <div class="plate2">
|
|
|
|
|
+ <h1>DIS<span class="two">2</span>HOOK</h1>
|
|
|
|
|
+ <div class="sub">RELAY CONSOLE · ADMIN KEY REQUIRED</div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="keyInput">Admin key</label>
|
|
|
|
|
+ <input id="keyInput" type="password" autocomplete="off" placeholder="from token.json / install output">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="btn primary" style="width:100%" id="loginBtn">Unlock console</button>
|
|
|
|
|
+ <div class="err" id="loginErr"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<header class="hidden" id="hdr">
|
|
|
|
|
+ <div class="plate">
|
|
|
|
|
+ <h1>DIS<span class="two">2</span>HOOK</h1>
|
|
|
|
|
+ <span class="ver" id="ver"></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="botLabel"><span class="lamp" id="botLamp"></span><span id="botText">checking bot…</span></div>
|
|
|
|
|
+ <div class="meter">
|
|
|
|
|
+ <div class="gauge"><b id="mRecv">0</b><span>received</span></div>
|
|
|
|
|
+ <div class="gauge relayed"><b id="mRelay">0</b><span>relayed</span></div>
|
|
|
|
|
+ <div class="gauge"><b id="mFilt">0</b><span>filtered</span></div>
|
|
|
|
|
+ <div class="gauge failed"><b id="mFail">0</b><span>failed</span></div>
|
|
|
|
|
+ <div class="gauge"><b id="mUp">—</b><span>uptime</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<main class="hidden" id="mainEl">
|
|
|
|
|
+ <section>
|
|
|
|
|
+ <h2>Webhook sources <button class="btn tiny" id="addSrc" style="margin-left:auto">+ add source</button></h2>
|
|
|
|
|
+ <div id="strips"></div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <aside>
|
|
|
|
|
+ <section>
|
|
|
|
|
+ <h2>Relay settings</h2>
|
|
|
|
|
+ <div class="panelbox">
|
|
|
|
|
+ <h3><span class="lamp ok" style="width:7px;height:7px"></span> Heartbeat</h3>
|
|
|
|
|
+ <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="hbOn"><span class="track"></span>Post a periodic pulse</label>
|
|
|
|
|
+ <div class="field"><label for="hbChan">Channel ID</label><input id="hbChan" placeholder="e.g. 1123456789012345678"></div>
|
|
|
|
|
+ <div class="field"><label for="hbInt">Interval (minutes)</label><input id="hbInt" type="number" min="1" max="10080" value="60"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="panelbox">
|
|
|
|
|
+ <h3><span class="lamp" style="width:7px;height:7px;background:var(--blurple)"></span> Status updates</h3>
|
|
|
|
|
+ <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="stOn"><span class="track"></span>Announce online / offline</label>
|
|
|
|
|
+ <div class="field"><label for="stChan">Channel ID</label><input id="stChan" placeholder="e.g. 1123456789012345678"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ <section>
|
|
|
|
|
+ <h2>Recent activity</h2>
|
|
|
|
|
+ <div class="panelbox"><ul class="log" id="log"><li style="color:var(--faint)">Nothing yet — waiting for signal.</li></ul></div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+</main>
|
|
|
|
|
+
|
|
|
|
|
+<div id="savebar"><div class="inner">
|
|
|
|
|
+ <span class="msg">Unsaved changes</span>
|
|
|
|
|
+ <button class="btn ghost" id="discardBtn">Discard</button>
|
|
|
|
|
+ <button class="btn primary" id="saveBtn">Save changes</button>
|
|
|
|
|
+</div></div>
|
|
|
|
|
+<div id="toast" role="status"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+"use strict";
|
|
|
|
|
+const $ = s => document.querySelector(s);
|
|
|
|
|
+let ADMIN_KEY = "";
|
|
|
|
|
+let cfg = null; // working copy (edited by UI)
|
|
|
|
|
+let dirty = false;
|
|
|
|
|
+let statusTimer = null;
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- storage guard (falls back to memory-only) ---------- */
|
|
|
|
|
+const store = {
|
|
|
|
|
+ get(k){ try { return sessionStorage.getItem(k); } catch(e){ return null; } },
|
|
|
|
|
+ set(k,v){ try { sessionStorage.setItem(k,v); } catch(e){} },
|
|
|
|
|
+ del(k){ try { sessionStorage.removeItem(k); } catch(e){} }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- api ---------- */
|
|
|
|
|
+async function api(path, opts={}){
|
|
|
|
|
+ const r = await fetch(path, {...opts, headers:{
|
|
|
|
|
+ "Content-Type":"application/json", "X-Admin-Key":ADMIN_KEY, ...(opts.headers||{})}});
|
|
|
|
|
+ if (r.status === 401) { logout(); throw new Error("unauthorized"); }
|
|
|
|
|
+ const data = await r.json().catch(()=>({}));
|
|
|
|
|
+ if (!r.ok) throw new Error(data.error || ("HTTP "+r.status));
|
|
|
|
|
+ return data;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- toast ---------- */
|
|
|
|
|
+let toastT;
|
|
|
|
|
+function toast(msg, err=false){
|
|
|
|
|
+ const t = $("#toast");
|
|
|
|
|
+ t.textContent = msg; t.className = err ? "err" : ""; t.style.display = "block";
|
|
|
|
|
+ clearTimeout(toastT); toastT = setTimeout(()=> t.style.display="none", 3500);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- login ---------- */
|
|
|
|
|
+async function tryLogin(key){
|
|
|
|
|
+ ADMIN_KEY = key;
|
|
|
|
|
+ await api("/api/login", {method:"POST"});
|
|
|
|
|
+ store.set("d2h_key", key);
|
|
|
|
|
+ $("#login").classList.add("hidden");
|
|
|
|
|
+ $("#hdr").classList.remove("hidden");
|
|
|
|
|
+ $("#mainEl").classList.remove("hidden");
|
|
|
|
|
+ await loadConfig();
|
|
|
|
|
+ await refreshStatus();
|
|
|
|
|
+ statusTimer = setInterval(refreshStatus, 10000);
|
|
|
|
|
+}
|
|
|
|
|
+function logout(){
|
|
|
|
|
+ store.del("d2h_key"); ADMIN_KEY = "";
|
|
|
|
|
+ clearInterval(statusTimer);
|
|
|
|
|
+ $("#login").classList.remove("hidden");
|
|
|
|
|
+ $("#hdr").classList.add("hidden");
|
|
|
|
|
+ $("#mainEl").classList.add("hidden");
|
|
|
|
|
+}
|
|
|
|
|
+$("#loginBtn").addEventListener("click", async ()=>{
|
|
|
|
|
+ $("#loginErr").textContent = "";
|
|
|
|
|
+ try { await tryLogin($("#keyInput").value.trim()); }
|
|
|
|
|
+ catch(e){ $("#loginErr").textContent = "That key was not accepted."; }
|
|
|
|
|
+});
|
|
|
|
|
+$("#keyInput").addEventListener("keydown", e=>{ if(e.key==="Enter") $("#loginBtn").click(); });
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- status ---------- */
|
|
|
|
|
+function fmtUptime(s){
|
|
|
|
|
+ const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60);
|
|
|
|
|
+ return (d?d+"d ":"")+(h||d?h+"h ":"")+m+"m";
|
|
|
|
|
+}
|
|
|
|
|
+async function refreshStatus(){
|
|
|
|
|
+ try {
|
|
|
|
|
+ const st = await api("/api/status");
|
|
|
|
|
+ $("#ver").textContent = "v"+st.version;
|
|
|
|
|
+ $("#mRecv").textContent = st.stats.received;
|
|
|
|
|
+ $("#mRelay").textContent = st.stats.relayed;
|
|
|
|
|
+ $("#mFilt").textContent = st.stats.filtered;
|
|
|
|
|
+ $("#mFail").textContent = st.stats.failed;
|
|
|
|
|
+ $("#mUp").textContent = fmtUptime(st.uptime_seconds);
|
|
|
|
|
+ const lamp = $("#botLamp");
|
|
|
|
|
+ if (st.bot.ok){ lamp.className="lamp ok"; $("#botText").textContent = "bot: "+st.bot.name; }
|
|
|
|
|
+ else { lamp.className="lamp fail"; $("#botText").textContent = "bot token not verified"; }
|
|
|
|
|
+ renderLog(st.activity);
|
|
|
|
|
+ } catch(e){ /* transient; keep last values */ }
|
|
|
|
|
+}
|
|
|
|
|
+function renderLog(items){
|
|
|
|
|
+ const ul = $("#log"); ul.innerHTML = "";
|
|
|
|
|
+ if (!items || !items.length){
|
|
|
|
|
+ ul.innerHTML = '<li style="color:var(--faint)">Nothing yet — waiting for signal.</li>'; return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const it of items){
|
|
|
|
|
+ const li = document.createElement("li");
|
|
|
|
|
+ const t = document.createElement("time");
|
|
|
|
|
+ t.textContent = new Date(it.time).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
|
|
|
|
|
+ const b = document.createElement("span"); b.className = "badge "+it.outcome; b.textContent = it.outcome;
|
|
|
|
|
+ const who = document.createElement("span"); who.className="who"; who.textContent = it.source;
|
|
|
|
|
+ const what = document.createElement("span"); what.className="what"; what.textContent = it.detail || "";
|
|
|
|
|
+ li.append(t,b,who,what); ul.appendChild(li);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- config ---------- */
|
|
|
|
|
+async function loadConfig(){
|
|
|
|
|
+ cfg = await api("/api/config");
|
|
|
|
|
+ cfg.sources = cfg.sources || [];
|
|
|
|
|
+ bindGlobals(); renderStrips(); setDirty(false);
|
|
|
|
|
+}
|
|
|
|
|
+function bindGlobals(){
|
|
|
|
|
+ $("#hbOn").checked = !!cfg.heartbeat.enabled;
|
|
|
|
|
+ $("#hbChan").value = cfg.heartbeat.channel_id || "";
|
|
|
|
|
+ $("#hbInt").value = cfg.heartbeat.interval_minutes || 60;
|
|
|
|
|
+ $("#stOn").checked = !!cfg.status.enabled;
|
|
|
|
|
+ $("#stChan").value = cfg.status.channel_id || "";
|
|
|
|
|
+}
|
|
|
|
|
+function collectGlobals(){
|
|
|
|
|
+ cfg.heartbeat.enabled = $("#hbOn").checked;
|
|
|
|
|
+ cfg.heartbeat.channel_id = $("#hbChan").value.trim();
|
|
|
|
|
+ cfg.heartbeat.interval_minutes = parseInt($("#hbInt").value, 10) || 60;
|
|
|
|
|
+ cfg.status.enabled = $("#stOn").checked;
|
|
|
|
|
+ cfg.status.channel_id = $("#stChan").value.trim();
|
|
|
|
|
+}
|
|
|
|
|
+for (const id of ["hbOn","hbChan","hbInt","stOn","stChan"])
|
|
|
|
|
+ $("#"+id).addEventListener("input", ()=>{ collectGlobals(); setDirty(true); });
|
|
|
|
|
+
|
|
|
|
|
+function setDirty(v){ dirty = v; $("#savebar").classList.toggle("show", v); }
|
|
|
|
|
+window.addEventListener("beforeunload", e=>{ if(dirty){ e.preventDefault(); e.returnValue=""; }});
|
|
|
|
|
+
|
|
|
|
|
+$("#saveBtn").addEventListener("click", async ()=>{
|
|
|
|
|
+ collectGlobals();
|
|
|
|
|
+ try { await api("/api/config", {method:"PUT", body: JSON.stringify(cfg)});
|
|
|
|
|
+ setDirty(false); toast("Configuration saved."); }
|
|
|
|
|
+ catch(e){ toast("Save failed: "+e.message, true); }
|
|
|
|
|
+});
|
|
|
|
|
+$("#discardBtn").addEventListener("click", ()=> loadConfig().then(()=>toast("Changes discarded.")));
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- sources ---------- */
|
|
|
|
|
+function randHex(n){
|
|
|
|
|
+ const a = new Uint8Array(n); crypto.getRandomValues(a);
|
|
|
|
|
+ return Array.from(a, x=>x.toString(16).padStart(2,"0")).join("");
|
|
|
|
|
+}
|
|
|
|
|
+function slugify(s){ return s.toLowerCase().replace(/[^a-z0-9-]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64) || "source"; }
|
|
|
|
|
+function uniqueId(base){
|
|
|
|
|
+ let id = base, n = 2;
|
|
|
|
|
+ while (cfg.sources.some(s=>s.id===id)) id = base+"-"+(n++);
|
|
|
|
|
+ return id;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+$("#addSrc").addEventListener("click", ()=>{
|
|
|
|
|
+ const id = uniqueId("source-"+(cfg.sources.length+1));
|
|
|
|
|
+ cfg.sources.push({ id, name:"New source", secret: randHex(16), channel_id:"",
|
|
|
|
|
+ enabled:true, template:"", filters:[] });
|
|
|
|
|
+ renderStrips(id); setDirty(true);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function hookURL(id){ return location.origin + "/hook/" + id; }
|
|
|
|
|
+
|
|
|
|
|
+function copyText(text, btn){
|
|
|
|
|
+ const done = ()=>{ const old=btn.textContent; btn.textContent="copied"; setTimeout(()=>btn.textContent=old,1200); };
|
|
|
|
|
+ if (navigator.clipboard && navigator.clipboard.writeText)
|
|
|
|
|
+ navigator.clipboard.writeText(text).then(done).catch(()=>fallbackCopy(text,done));
|
|
|
|
|
+ else fallbackCopy(text,done);
|
|
|
|
|
+}
|
|
|
|
|
+function fallbackCopy(text, done){
|
|
|
|
|
+ const ta=document.createElement("textarea"); ta.value=text; document.body.appendChild(ta);
|
|
|
|
|
+ ta.select(); try{ document.execCommand("copy"); }catch(e){} ta.remove(); done();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function field(labelText, inputEl, hint){
|
|
|
|
|
+ const w = document.createElement("div"); w.className = "field";
|
|
|
|
|
+ const l = document.createElement("label"); l.textContent = labelText;
|
|
|
|
|
+ w.append(l, inputEl);
|
|
|
|
|
+ if (hint){ const h=document.createElement("div"); h.className="hint"; h.textContent=hint; w.appendChild(h); }
|
|
|
|
|
+ return w;
|
|
|
|
|
+}
|
|
|
|
|
+function textInput(value, oninput, placeholder){
|
|
|
|
|
+ const i = document.createElement("input"); i.value = value ?? ""; i.placeholder = placeholder||"";
|
|
|
|
|
+ i.addEventListener("input", ()=>{ oninput(i.value); setDirty(true); });
|
|
|
|
|
+ return i;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderStrips(openId){
|
|
|
|
|
+ const wrap = $("#strips"); wrap.innerHTML = "";
|
|
|
|
|
+ if (!cfg.sources.length){
|
|
|
|
|
+ const e = document.createElement("div"); e.className="empty";
|
|
|
|
|
+ e.innerHTML = "<p>No sources patched in yet.<br>Add one to get a webhook URL you can point anything at.</p>";
|
|
|
|
|
+ const b = document.createElement("button"); b.className="btn primary"; b.textContent="+ add your first source";
|
|
|
|
|
+ b.addEventListener("click", ()=> $("#addSrc").click());
|
|
|
|
|
+ e.appendChild(b); wrap.appendChild(e); return;
|
|
|
|
|
+ }
|
|
|
|
|
+ cfg.sources.forEach((src, idx)=> wrap.appendChild(buildStrip(src, idx, src.id===openId)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildStrip(src, idx, open){
|
|
|
|
|
+ const strip = document.createElement("div");
|
|
|
|
|
+ strip.className = "strip" + (open ? " open":"") + (src.enabled===false ? " disabled":"");
|
|
|
|
|
+
|
|
|
|
|
+ /* head with the relay line */
|
|
|
|
|
+ const head = document.createElement("div"); head.className = "strip-head";
|
|
|
|
|
+ head.setAttribute("role","button"); head.tabIndex = 0;
|
|
|
|
|
+ const jackS = document.createElement("span"); jackS.className = "jack src"; jackS.title = "webhook in";
|
|
|
|
|
+ const name = document.createElement("span"); name.className = "strip-name"; name.textContent = src.name || src.id;
|
|
|
|
|
+ const relay = document.createElement("span"); relay.className = "relay";
|
|
|
|
|
+ const wire = document.createElement("span"); wire.className = "wire";
|
|
|
|
|
+ const pulse = document.createElement("span"); pulse.className = "pulse";
|
|
|
|
|
+ wire.appendChild(pulse);
|
|
|
|
|
+ const jackD = document.createElement("span"); jackD.className = "jack dst"; jackD.title = "Discord out";
|
|
|
|
|
+ relay.append(wire);
|
|
|
|
|
+ const chan = document.createElement("span"); chan.className = "chan-tag";
|
|
|
|
|
+ chan.textContent = src.channel_id ? "#"+src.channel_id : "no channel";
|
|
|
|
|
+ const caret = document.createElement("span"); caret.className = "caret"; caret.textContent = "▸";
|
|
|
|
|
+ head.append(jackS, name, relay, jackD, chan, caret);
|
|
|
|
|
+ const toggleOpen = ()=> strip.classList.toggle("open");
|
|
|
|
|
+ head.addEventListener("click", toggleOpen);
|
|
|
|
|
+ head.addEventListener("keydown", e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); toggleOpen(); }});
|
|
|
|
|
+
|
|
|
|
|
+ /* body */
|
|
|
|
|
+ const body = document.createElement("div"); body.className = "strip-body";
|
|
|
|
|
+
|
|
|
|
|
+ const g1 = document.createElement("div"); g1.className = "grid2";
|
|
|
|
|
+ g1.appendChild(field("Display name",
|
|
|
|
|
+ textInput(src.name, v=>{ src.name=v; name.textContent=v||src.id; }, "e.g. Gitea CI")));
|
|
|
|
|
+ const idInput = textInput(src.id, v=>{ src.id = slugify(v); urlCode.textContent = hookURL(src.id); },
|
|
|
|
|
+ "gitea-ci");
|
|
|
|
|
+ g1.appendChild(field("Endpoint ID", idInput, "Lowercase letters, digits, dashes. Sets the URL below."));
|
|
|
|
|
+ body.appendChild(g1);
|
|
|
|
|
+
|
|
|
|
|
+ /* webhook url */
|
|
|
|
|
+ const urlBox = document.createElement("div"); urlBox.className = "hookurl";
|
|
|
|
|
+ const urlCode = document.createElement("code"); urlCode.textContent = hookURL(src.id);
|
|
|
|
|
+ const urlCopy = document.createElement("button"); urlCopy.className="btn tiny"; urlCopy.textContent="copy";
|
|
|
|
|
+ urlCopy.addEventListener("click", ()=> copyText(urlCode.textContent, urlCopy));
|
|
|
|
|
+ urlBox.append(urlCode, urlCopy);
|
|
|
|
|
+ body.appendChild(field("Webhook URL — point your service here", urlBox,
|
|
|
|
|
+ "Send POSTs with header X-Hook-Secret (or ?secret=) set to the secret below."));
|
|
|
|
|
+
|
|
|
|
|
+ const g2 = document.createElement("div"); g2.className = "grid2";
|
|
|
|
|
+ /* secret with regen + copy */
|
|
|
|
|
+ const secretWrap = document.createElement("div"); secretWrap.className = "inline";
|
|
|
|
|
+ const secretIn = textInput(src.secret, v=>{ src.secret=v; });
|
|
|
|
|
+ const regen = document.createElement("button"); regen.className="btn tiny"; regen.textContent="new";
|
|
|
|
|
+ regen.title = "Generate a new secret";
|
|
|
|
|
+ regen.addEventListener("click", ()=>{ src.secret = randHex(16); secretIn.value = src.secret; setDirty(true); });
|
|
|
|
|
+ const scopy = document.createElement("button"); scopy.className="btn tiny"; scopy.textContent="copy";
|
|
|
|
|
+ scopy.addEventListener("click", ()=> copyText(secretIn.value, scopy));
|
|
|
|
|
+ secretWrap.append(secretIn, regen, scopy);
|
|
|
|
|
+ g2.appendChild(field("Shared secret", secretWrap));
|
|
|
|
|
+ g2.appendChild(field("Discord channel ID",
|
|
|
|
|
+ textInput(src.channel_id, v=>{ src.channel_id=v.trim(); chan.textContent = v.trim()? "#"+v.trim():"no channel"; },
|
|
|
|
|
+ "1123456789012345678"),
|
|
|
|
|
+ "Right-click a channel in Discord → Copy Channel ID (developer mode)."));
|
|
|
|
|
+ body.appendChild(g2);
|
|
|
|
|
+
|
|
|
|
|
+ /* template */
|
|
|
|
|
+ const ta = document.createElement("textarea");
|
|
|
|
|
+ ta.value = src.template || "";
|
|
|
|
|
+ ta.placeholder = "**{repository.full_name}** — {pusher.name} pushed\n{commits[0].message}\n\nLeave empty to relay the full payload as a JSON block.";
|
|
|
|
|
+ ta.addEventListener("input", ()=>{ src.template = ta.value; setDirty(true); });
|
|
|
|
|
+ body.appendChild(field("Markdown template", ta,
|
|
|
|
|
+ "Discord Markdown plus {dotted.paths[0]} placeholders resolved from the incoming JSON payload."));
|
|
|
|
|
+
|
|
|
|
|
+ /* filters */
|
|
|
|
|
+ const fwrap = document.createElement("div"); fwrap.className = "filters";
|
|
|
|
|
+ const flabel = document.createElement("div"); flabel.className = "field";
|
|
|
|
|
+ const fl = document.createElement("label"); fl.textContent = "Event filters — relay only when every rule matches";
|
|
|
|
|
+ flabel.appendChild(fl); flabel.appendChild(fwrap);
|
|
|
|
|
+ const renderFilters = ()=>{
|
|
|
|
|
+ fwrap.innerHTML = "";
|
|
|
|
|
+ (src.filters||[]).forEach((flt, fi)=>{
|
|
|
|
|
+ const row = document.createElement("div"); row.className = "filter-row";
|
|
|
|
|
+ const p = textInput(flt.path, v=>flt.path=v, "payload path, e.g. action");
|
|
|
|
|
+ const sel = document.createElement("select");
|
|
|
|
|
+ for (const m of ["equals","contains","exists"]){
|
|
|
|
|
+ const o=document.createElement("option"); o.value=m; o.textContent=m;
|
|
|
|
|
+ if ((flt.mode||"equals")===m) o.selected=true; sel.appendChild(o);
|
|
|
|
|
+ }
|
|
|
|
|
+ sel.addEventListener("change", ()=>{ flt.mode=sel.value; v.disabled = sel.value==="exists"; setDirty(true); });
|
|
|
|
|
+ const v = textInput(flt.value, x=>flt.value=x, "expected value");
|
|
|
|
|
+ v.disabled = (flt.mode||"equals")==="exists";
|
|
|
|
|
+ const del = document.createElement("button"); del.className="btn tiny danger"; del.textContent="✕";
|
|
|
|
|
+ del.title = "Remove rule";
|
|
|
|
|
+ del.addEventListener("click", ()=>{ src.filters.splice(fi,1); renderFilters(); setDirty(true); });
|
|
|
|
|
+ row.append(p, sel, v, del); fwrap.appendChild(row);
|
|
|
|
|
+ });
|
|
|
|
|
+ const add = document.createElement("button"); add.className="btn tiny ghost"; add.textContent="+ add rule";
|
|
|
|
|
+ add.addEventListener("click", ()=>{ (src.filters ||= []).push({path:"",mode:"equals",value:""}); renderFilters(); setDirty(true); });
|
|
|
|
|
+ fwrap.appendChild(add);
|
|
|
|
|
+ };
|
|
|
|
|
+ renderFilters();
|
|
|
|
|
+ body.appendChild(flabel);
|
|
|
|
|
+
|
|
|
|
|
+ /* actions */
|
|
|
|
|
+ const actions = document.createElement("div"); actions.className = "strip-actions";
|
|
|
|
|
+ const en = document.createElement("label"); en.className = "toggle";
|
|
|
|
|
+ const enIn = document.createElement("input"); enIn.type="checkbox"; enIn.checked = src.enabled !== false;
|
|
|
|
|
+ const track = document.createElement("span"); track.className="track";
|
|
|
|
|
+ en.append(enIn, track, document.createTextNode("Enabled"));
|
|
|
|
|
+ enIn.addEventListener("change", ()=>{ src.enabled = enIn.checked; strip.classList.toggle("disabled", !enIn.checked); setDirty(true); });
|
|
|
|
|
+
|
|
|
|
|
+ const test = document.createElement("button"); test.className="btn"; test.textContent="Send test message";
|
|
|
|
|
+ test.addEventListener("click", async ()=>{
|
|
|
|
|
+ if (dirty){ toast("Save changes first, then test.", true); return; }
|
|
|
|
|
+ test.disabled = true;
|
|
|
|
|
+ strip.classList.remove("firing"); void strip.offsetWidth; strip.classList.add("firing");
|
|
|
|
|
+ try { await api("/api/test/"+encodeURIComponent(src.id), {method:"POST"}); toast("Test message relayed."); }
|
|
|
|
|
+ catch(e){ toast("Test failed: "+e.message, true); }
|
|
|
|
|
+ test.disabled = false;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const del = document.createElement("button"); del.className="btn danger"; del.textContent="Delete source";
|
|
|
|
|
+ del.addEventListener("click", ()=>{
|
|
|
|
|
+ if (!confirm(`Delete "${src.name||src.id}"? Its webhook URL stops working when you save.`)) return;
|
|
|
|
|
+ cfg.sources.splice(idx,1); renderStrips(); setDirty(true);
|
|
|
|
|
+ });
|
|
|
|
|
+ actions.append(en, test, del);
|
|
|
|
|
+ body.appendChild(actions);
|
|
|
|
|
+
|
|
|
|
|
+ strip.append(head, body);
|
|
|
|
|
+ return strip;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- boot ---------- */
|
|
|
|
|
+(async ()=>{
|
|
|
|
|
+ const saved = store.get("d2h_key");
|
|
|
|
|
+ if (saved){ try { await tryLogin(saved); return; } catch(e){ store.del("d2h_key"); } }
|
|
|
|
|
+ $("#keyInput").focus();
|
|
|
|
|
+})();
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|