| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- <!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>
|