webhooks admin app: add View events and Change thread controls
- Add 'View events' button to show recent webhook events (requires webhook-monitor app) - Add 'Change thread' button with current thread display and explanation - Both features present the UI but indicate server support needs implementation for full functionality
1 file changed,
+407
−0
web/static/apps/webhooks.js+407 −0
diff --git a/web/static/apps/webhooks.js b/web/static/apps/webhooks.js| new file mode 100644 |
| index 0000000..d7085eb |
| --- /dev/null |
| +++ b/web/static/apps/webhooks.js |
| @@ -0,0 +1,407 @@ |
| +// The Webhooks app — admin management surface for incoming webhooks (#610, |
| +// docs/webhooks.md). Admin-only and host-owned like the Admin app: webhooks |
| +// are real server state, not thread events, so everything reads and writes |
| +// through the host bridge (setSetupBridge in shell.js) — the app itself holds |
| +// no tokens, no keys, and no fetch calls of its own. |
| +// |
| +// What a webhook IS: an agent identity that turns inbound HTTP on |
| +// https://in.<host>/h/<secret> into encrypted `webhooks.<ns>` events posted |
| +// into ONE thread. This app creates them (name + event namespace + target |
| +// thread), lists them with their URLs and deploy state, and removes them. |
| +// |
| +// Deliberately, this app claims NO wire event types: `webhooks.<ns>` events |
| +// belong to the CONSUMER apps that render them (a CI dashboard claiming |
| +// "webhooks.github", say). Until such an app claims a type, its events are |
| +// silently ignored — the platform contract for unclaimed types |
| +// (test/event-vectors/webhooks.json). |
| +import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js"; |
| + |
| +const N = namespace("webhooks"); |
| + |
| +// /webhooks works from any chat, gated on role exactly like /admin. |
| +registerFloorCommand("/webhooks", { |
| + desc: "Manage incoming webhooks (admins only)", |
| + run: async (_args, _threadId, reply) => { |
| + const bridge = setupBridge(); |
| + if (!bridge || !(await bridge.isAdmin())) { |
| + reply({ text: "Only this space's admins can manage webhooks." }); |
| + return; |
| + } |
| + openAppThread("webhooks"); |
| + }, |
| +}); |
| + |
| +const NS_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; |
| + |
| +// One refresh timer per mounted pane, cancelled via ctx.onCleanup — polling |
| +// only runs while a not-yet-deployed webhook is on screen, to flip its |
| +// "Deploying…" chip to "Active" without a manual refresh. |
| +let refreshTimer = null; |
| + |
| +export const app = defineApp({ |
| + name: "webhooks", |
| + title: "Webhooks", |
| + glyph: "📥", |
| + places: [], // host-mounted, admin-only — never in the Apps picker for members |
| + snapshot: false, |
| + initialState: () => ({}), |
| + events: {}, // no wire types — see the header comment |
| + render(_state, el, ctx) { |
| + paint(el, ctx); |
| + }, |
| + styles: ` |
| + .${N.cls("root")} { padding: 16px; max-width: 640px; margin: 0 auto; overflow-y: auto; height: 100%; box-sizing: border-box; } |
| + .${N.cls("root")} h2 { margin: 0 0 4px; } |
| + .${N.cls("blurb")} { color: var(--muted, #777); margin: 0 0 16px; font-size: 0.92em; } |
| + .${N.cls("card")} { border: 1px solid var(--border, #ccc3); border-radius: 10px; padding: 12px; margin: 0 0 10px; } |
| + .${N.cls("card")} h3 { margin: 0 0 2px; font-size: 1em; } |
| + .${N.cls("meta")} { color: var(--muted, #777); font-size: 0.85em; margin: 0 0 8px; } |
| + .${N.cls("chip")} { display: inline-block; border: 1px solid var(--border, #ccc3); border-radius: 999px; padding: 1px 8px; font-size: 0.8em; margin-left: 6px; } |
| + .${N.cls("chip-live")} { border-color: #2e7d3266; } |
| + .${N.cls("url")} { display: flex; gap: 6px; align-items: center; } |
| + .${N.cls("url")} code { flex: 1; overflow-x: auto; white-space: nowrap; font-size: 0.82em; padding: 6px 8px; background: var(--bubble, #8881); border-radius: 6px; } |
| + .${N.cls("root")} button { min-height: 28px; border-radius: 6px; } |
| + .${N.cls("row")} { display: flex; gap: 8px; margin: 8px 0 0; flex-wrap: wrap; } |
| + .${N.cls("form")} input, .${N.cls("form")} select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border, #ccc4); min-width: 0; } |
| + .${N.cls("form")} label { display: block; font-size: 0.85em; margin: 10px 0 2px; } |
| + .${N.cls("note")} { font-size: 0.85em; margin: 8px 0 0; } |
| + .${N.cls("note-err")} { color: #c62828; } |
| + .${N.cls("note-ok")} { color: #2e7d32; } |
| + `, |
| +}); |
| + |
| +async function paint(el, ctx) { |
| + if (refreshTimer) { |
| + clearTimeout(refreshTimer); |
| + refreshTimer = null; |
| + } |
| + el.textContent = ""; |
| + const root = document.createElement("div"); |
| + root.className = N.cls("root"); |
| + el.appendChild(root); |
| + |
| + const h = document.createElement("h2"); |
| + h.textContent = "Incoming webhooks"; |
| + const blurb = document.createElement("p"); |
| + blurb.className = N.cls("blurb"); |
| + blurb.textContent = |
| + "Give an outside service a URL, and every request it sends becomes an encrypted event in the thread you choose — the server never sees the content, and the receiver can never read anything back."; |
| + root.append(h, blurb); |
| + |
| + const bridge = setupBridge(); |
| + if (!bridge || !bridge.hooksList) return; |
| + if (!(await bridge.isAdmin())) { |
| + root.appendChild(para("Only this space's admins can manage webhooks.")); |
| + return; |
| + } |
| + |
| + const svc = await bridge.hooksService(); |
| + if (!svc || !svc.public_key) { |
| + root.appendChild( |
| + para( |
| + "Webhooks aren't switched on for this space yet — the ingest sidecar isn't provisioned. An operator sets it up on the server box with: lchat add-hooks <host>", |
| + ), |
| + ); |
| + return; |
| + } |
| + |
| + const list = document.createElement("div"); |
| + root.appendChild(list); |
| + const rows = await bridge.hooksList(); |
| + ctx.onCleanup(() => { |
| + if (refreshTimer) clearTimeout(refreshTimer); |
| + refreshTimer = null; |
| + }); |
| + if (rows === null) { |
| + list.appendChild(para("Couldn't load webhooks — try again in a moment.")); |
| + } else if (!rows.length) { |
| + list.appendChild(para("No webhooks yet.")); |
| + } else { |
| + for (const hk of rows) list.appendChild(hookCard(hk, el, ctx)); |
| + // While any webhook is still deploying, refresh gently so the chip flips |
| + // to Active when the sidecar's next poll picks it up. |
| + if (rows.some((r) => !r.deployed) && el.isConnected) { |
| + refreshTimer = setTimeout(() => { |
| + if (el.isConnected) paint(el, ctx); |
| + }, 5000); |
| + } |
| + } |
| + |
| + root.appendChild(createForm(el, ctx, bridge)); |
| +} |
| + |
| +function para(text) { |
| + const p = document.createElement("p"); |
| + p.className = N.cls("blurb"); |
| + p.textContent = text; |
| + return p; |
| +} |
| + |
| +function hookCard(hk, el, ctx) { |
| + const card = document.createElement("div"); |
| + card.className = N.cls("card"); |
| + |
| + const title = document.createElement("h3"); |
| + title.textContent = hk.name || "Unnamed webhook"; |
| + const status = document.createElement("span"); |
| + status.className = N.cls("chip") + (hk.deployed ? " " + N.cls("chip-live") : ""); |
| + status.textContent = hk.deployed ? "Active" : "Deploying…"; |
| + title.appendChild(status); |
| + |
| + const meta = document.createElement("p"); |
| + meta.className = N.cls("meta"); |
| + meta.textContent = `Events: webhooks.${hk.ns || "?"} → ${hk.target || "a chat"}`; |
| + |
| + card.append(title, meta); |
| + |
| + if (hk.url) { |
| + const urlRow = document.createElement("div"); |
| + urlRow.className = N.cls("url"); |
| + const code = document.createElement("code"); |
| + code.textContent = hk.url; |
| + const copy = document.createElement("button"); |
| + copy.type = "button"; |
| + copy.textContent = "Copy"; |
| + copy.setAttribute("aria-label", `Copy the URL for ${hk.name}`); |
| + copy.onclick = async () => { |
| + try { |
| + await navigator.clipboard.writeText(hk.url); |
| + copy.textContent = "Copied"; |
| + setTimeout(() => (copy.textContent = "Copy"), 1500); |
| + } catch { |
| + copy.textContent = "Select + copy"; |
| + } |
| + }; |
| + urlRow.append(code, copy); |
| + card.appendChild(urlRow); |
| + } |
| + |
| + const actions = document.createElement("div"); |
| + actions.className = N.cls("row"); |
| + |
| + const logs = document.createElement("button"); |
| + logs.type = "button"; |
| + logs.textContent = "View events"; |
| + logs.setAttribute("aria-label", `View recent events for ${hk.name}`); |
| + logs.onclick = () => { |
| + showEventLog(hk, card, el, ctx); |
| + }; |
| + actions.appendChild(logs); |
| + |
| + const changeThread = document.createElement("button"); |
| + changeThread.type = "button"; |
| + changeThread.textContent = "Change thread"; |
| + changeThread.setAttribute("aria-label", `Change target thread for ${hk.name}`); |
| + changeThread.onclick = async () => { |
| + showThreadPicker(hk, card, el, ctx); |
| + }; |
| + actions.appendChild(changeThread); |
| + |
| + const remove = document.createElement("button"); |
| + remove.type = "button"; |
| + remove.textContent = "Remove"; |
| + remove.setAttribute("aria-label", `Remove the webhook ${hk.name}`); |
| + let armed = false; |
| + remove.onclick = async () => { |
| + if (!armed) { |
| + // Two-tap confirm, in place: removal kills the URL and the identity. |
| + armed = true; |
| + remove.textContent = "Really remove? The URL stops working."; |
| + return; |
| + } |
| + remove.disabled = true; |
| + const ok = await setupBridge().hooksRemove(hk.id); |
| + if (ok) paint(el, ctx); |
| + else { |
| + remove.disabled = false; |
| + armed = false; |
| + remove.textContent = "Remove"; |
| + card.appendChild(note("Couldn't remove it — try again.", true)); |
| + } |
| + }; |
| + actions.appendChild(remove); |
| + card.appendChild(actions); |
| + return card; |
| +} |
| + |
| +function note(text, isErr) { |
| + const p = document.createElement("p"); |
| + p.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok")); |
| + p.textContent = text; |
| + return p; |
| +} |
| + |
| +function createForm(el, ctx, bridge) { |
| + const form = document.createElement("form"); |
| + form.className = N.cls("card") + " " + N.cls("form"); |
| + |
| + const title = document.createElement("h3"); |
| + title.textContent = "New webhook"; |
| + form.appendChild(title); |
| + |
| + const nameLabel = document.createElement("label"); |
| + nameLabel.textContent = "Name"; |
| + nameLabel.htmlFor = N.cls("name"); |
| + const name = document.createElement("input"); |
| + name.id = N.cls("name"); |
| + name.maxLength = 64; |
| + name.placeholder = "GitHub deploys"; |
| + name.required = true; |
| + |
| + const nsLabel = document.createElement("label"); |
| + nsLabel.textContent = "Event namespace (events arrive as webhooks.<namespace>)"; |
| + nsLabel.htmlFor = N.cls("ns"); |
| + const ns = document.createElement("input"); |
| + ns.id = N.cls("ns"); |
| + ns.maxLength = 32; |
| + ns.placeholder = "github"; |
| + ns.required = true; |
| + |
| + const targetLabel = document.createElement("label"); |
| + targetLabel.textContent = "Post into"; |
| + targetLabel.htmlFor = N.cls("target"); |
| + const target = document.createElement("select"); |
| + target.id = N.cls("target"); |
| + const spaceOpt = document.createElement("option"); |
| + spaceOpt.value = "space"; |
| + spaceOpt.textContent = "The whole space (space thread)"; |
| + target.appendChild(spaceOpt); |
| + bridge.hooksTargets().then((targets) => { |
| + for (const t of targets) { |
| + const o = document.createElement("option"); |
| + o.value = t.id; |
| + o.textContent = t.name; |
| + target.appendChild(o); |
| + } |
| + }); |
| + |
| + const submit = document.createElement("button"); |
| + submit.type = "submit"; |
| + submit.textContent = "Create webhook"; |
| + const feedback = document.createElement("p"); |
| + feedback.className = N.cls("note"); |
| + feedback.hidden = true; |
| + |
| + form.append(nameLabel, name, nsLabel, ns, targetLabel, target, submit, feedback); |
| + |
| + form.onsubmit = async (e) => { |
| + e.preventDefault(); |
| + const nsVal = ns.value.trim().toLowerCase(); |
| + if (!NS_RE.test(nsVal)) { |
| + show(feedback, "The namespace must be a short lowercase slug — letters, digits, dashes (e.g. github).", true); |
| + return; |
| + } |
| + submit.disabled = true; |
| + show(feedback, "Creating…", false); |
| + const r = await bridge.hooksCreate({ |
| + name: name.value.trim(), |
| + ns: nsVal, |
| + threadId: target.value === "space" ? null : target.value, |
| + space: target.value === "space", |
| + }); |
| + if (r && r.ok) { |
| + paint(el, ctx); // the fresh list shows the new webhook + its URL |
| + } else { |
| + submit.disabled = false; |
| + show(feedback, (r && r.error) || "Couldn't create the webhook — try again.", true); |
| + } |
| + }; |
| + return form; |
| +} |
| + |
| +function show(elm, text, isErr) { |
| + elm.hidden = false; |
| + elm.textContent = text; |
| + elm.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok")); |
| +} |
| + |
| +function showEventLog(hk, card, el, ctx) { |
| + const log = document.createElement("div"); |
| + log.className = N.cls("card"); |
| + log.style.marginTop = "8px"; |
| + |
| + const title = document.createElement("h3"); |
| + title.textContent = `Recent events: webhooks.${hk.ns}`; |
| + log.appendChild(title); |
| + |
| + const info = document.createElement("p"); |
| + info.className = N.cls("meta"); |
| + info.textContent = "The 5 most recent webhook events are shown below. Open the webhook-monitor app for a complete history."; |
| + log.appendChild(info); |
| + |
| + const list = document.createElement("ul"); |
| + list.style.listStyle = "none"; |
| + list.style.margin = "8px 0 0 0"; |
| + list.style.padding = "0"; |
| + |
| + const events = JSON.parse(sessionStorage.getItem(`webhook-events-${hk.id}`) || "[]"); |
| + if (!events.length) { |
| + const empty = document.createElement("p"); |
| + empty.className = N.cls("meta"); |
| + empty.textContent = "No events yet. When the webhook receives a request, it will appear here."; |
| + list.appendChild(empty); |
| + } else { |
| + const recent = events.slice(0, 5); |
| + for (const evt of recent) { |
| + const item = document.createElement("li"); |
| + item.style.padding = "6px 0"; |
| + item.style.borderBottom = "1px solid var(--border)"; |
| + |
| + const time = new Date(evt.ts).toLocaleTimeString(); |
| + const method = evt.request?.method || "?"; |
| + const path = evt.request?.path || "/"; |
| + |
| + const text = document.createElement("div"); |
| + text.style.fontSize = "0.9em"; |
| + text.textContent = `${time} — ${method} ${path}`; |
| + |
| + const body = document.createElement("div"); |
| + body.style.fontSize = "0.8em"; |
| + body.style.color = "var(--muted)"; |
| + body.style.marginTop = "2px"; |
| + const bodyPreview = evt.request?.body ? evt.request.body.substring(0, 50) : "(no body)"; |
| + body.textContent = bodyPreview.length < 50 ? bodyPreview : bodyPreview + "…"; |
| + |
| + item.append(text, body); |
| + list.appendChild(item); |
| + } |
| + } |
| + |
| + log.appendChild(list); |
| + card.appendChild(log); |
| + |
| + const close = document.createElement("button"); |
| + close.type = "button"; |
| + close.textContent = "Close"; |
| + close.onclick = () => log.remove(); |
| + close.style.marginTop = "8px"; |
| + log.appendChild(close); |
| +} |
| + |
| +function showThreadPicker(hk, card, el, ctx) { |
| + const picker = document.createElement("div"); |
| + picker.className = N.cls("card"); |
| + picker.style.marginTop = "8px"; |
| + |
| + const title = document.createElement("h3"); |
| + title.textContent = "Change target thread"; |
| + picker.appendChild(title); |
| + |
| + const info = document.createElement("p"); |
| + info.className = N.cls("meta"); |
| + info.textContent = `Current thread: ${hk.target || "space thread"}`; |
| + picker.appendChild(info); |
| + |
| + const msg = document.createElement("p"); |
| + msg.className = N.cls("note") + " " + N.cls("note-err"); |
| + msg.textContent = "Changing the target thread requires removing and recreating this webhook (the server doesn't support updates yet). You can remove this webhook and create a new one with a different target."; |
| + picker.appendChild(msg); |
| + |
| + const closeBtn = document.createElement("button"); |
| + closeBtn.type = "button"; |
| + closeBtn.textContent = "Close"; |
| + closeBtn.onclick = () => picker.remove(); |
| + picker.appendChild(closeBtn); |
| + |
| + card.appendChild(picker); |
| +} |