Skip to content

Instantly share code, notes, and snippets.

@SmugZombie
Created August 28, 2025 00:18
Show Gist options
  • Save SmugZombie/02ff4220feceb3679aef805efdd6551e to your computer and use it in GitHub Desktop.
Save SmugZombie/02ff4220feceb3679aef805efdd6551e to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name n8n Webhook Footer Console
// @author Ron Egli <[email protected]> (github.com/smugzombie)
// @namespace http://tampermonkey.net/
// @version 2025.08.27
// @description Footer panel on n8n designer to send test webhook requests; persists values; DOM-safe; hide/show toggle.
// @match http*://*/*workflow*
// @match http*://*/*workflows*
// @match http*://*/*/workflow/*
// @match http*://*/*/workflows/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// ==/UserScript==
(function () {
'use strict';
// Avoid duplicates on SPA
if (document.getElementById('nw-footer-safe') || document.getElementById('nw-launcher')) return;
// ----- storage helpers -----
const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const P = 'n8nWebhookConsoleSafe.';
const store = {
get(k, d){ try { return hasGM ? GM_getValue(k, d) : (localStorage.getItem(P+k) ?? d); } catch { return d; } },
set(k, v){ try { hasGM ? GM_setValue(k, v) : localStorage.setItem(P+k, v); } catch {} },
del(k){ try { hasGM ? GM_deleteValue(k) : localStorage.removeItem(P+k); } catch {} },
};
// ----- styles (inherits page colors) -----
const css = `
#nw-footer-safe {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483647;
background: var(--color-background, rgba(0,0,0,0.04));
backdrop-filter: blur(6px);
border-top: 1px solid rgba(127,127,127,.25);
font: 13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: inherit;
display: flex; flex-direction: column;
height: 240px;
transition: transform .18s ease, opacity .18s ease;
}
#nw-footer-safe.min { height: 34px; }
#nw-footer-safe.hidden { transform: translateY(110%); opacity: 0; pointer-events: none; }
#nw-resize { height: 6px; cursor: ns-resize; border-bottom: 1px solid rgba(127,127,127,.18); }
#nw-head { display: flex; gap: 8px; align-items: center; padding: 6px 10px; }
#nw-title { font-weight: 600; opacity: .9; margin-right: 6px; }
.nw-input, .nw-select, .nw-btn {
border: 1px solid rgba(127,127,127,.28);
background: inherit; color: inherit; border-radius: 8px; padding: 6px 8px;
}
#nw-url { flex: 1 1 auto; min-width: 240px; }
#nw-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 8px 10px; height: 100%; }
.nw-col { display: flex; flex-direction: column; min-height: 0; }
.nw-label { font-weight: 600; margin-bottom: 4px; opacity: .85; }
.nw-ta { flex: 1 1 auto; min-height: 0; resize: none; border: 1px solid rgba(127,127,127,.28); border-radius: 10px; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; background: inherit; color: inherit; }
#nw-row { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
#nw-foot { padding: 6px 10px; border-top: 1px solid rgba(127,127,127,.18); display: flex; align-items: center; gap: 10px; }
#nw-status { opacity: .85; white-space: nowrap; }
#nw-toggle { margin-left: auto; }
.nw-btn.primary { box-shadow: inset 0 0 0 999px rgba(99,102,241,.15); border-color: rgba(99,102,241,.6); }
.nw-badge { border: 1px solid rgba(127,127,127,.28); border-radius: 999px; padding: 2px 6px; font-size: 11px; opacity: .8; }
/* launcher button (shows when console is hidden) */
#nw-launcher {
position: fixed; right: 14px; bottom: 14px; z-index: 2147483646;
border: 1px solid rgba(127,127,127,.28);
background: var(--color-background, rgba(0,0,0,0.04));
color: inherit; border-radius: 999px; padding: 8px 12px; cursor: pointer;
font: 13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
box-shadow: 0 4px 14px rgba(0,0,0,.18);
opacity: .9;
}
#nw-launcher.hidden { display: none; }
`;
if (typeof GM_addStyle === 'function') GM_addStyle(css);
else { const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); }
// ----- small DOM helpers -----
const h = (tag, attrs={}, children=[]) => {
const el = document.createElement(tag);
Object.entries(attrs).forEach(([k,v])=>{
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k in el) el[k] = v;
else el.setAttribute(k, v);
});
children.forEach(c => el.appendChild(c));
return el;
};
// ===== Build UI =====
const root = h('div', { id: 'nw-footer-safe' });
const savedH = parseInt(store.get('height',''),10);
if (!isNaN(savedH) && savedH >= 120) root.style.height = savedH + 'px';
if (store.get('open','1') !== '1') root.classList.add('min');
// Visibility (hidden vs shown)
const initiallyVisible = store.get('visible','1') === '1';
if (!initiallyVisible) root.classList.add('hidden');
const resize = h('div', { id: 'nw-resize' });
const title = h('span', { id: 'nw-title', text: 'Webhook Tester' });
const method = h('select', { class: 'nw-select', id: 'nw-method' }, [
...['POST','GET','PUT','PATCH','DELETE','HEAD'].map(m=>h('option',{text:m}))
]);
const url = h('input', { class: 'nw-input', id: 'nw-url', placeholder: 'Demo Webhook URL (https://.../webhook-test/xxxx)' });
const beautify = h('button', { class: 'nw-btn', id: 'nw-beautify', text: 'Beautify' });
const clear = h('button', { class: 'nw-btn', id: 'nw-clear', text: 'Clear' });
const send = h('button', { class: 'nw-btn primary', id: 'nw-send', text: 'Send', title: 'Ctrl+Enter' });
const head = h('div', { id: 'nw-head' }, [title, method, url, beautify, clear, send]);
const left = h('div', { class: 'nw-col' });
left.appendChild(h('div', { class: 'nw-label', text: 'Request' }));
const rowTop = h('div', { id: 'nw-row' });
const ct = h('select', { class: 'nw-select', id: 'nw-ct' }, [
...['application/json','text/plain','application/x-www-form-urlencoded'].map(t=>h('option',{text:t}))
]);
const credWrap = h('label', { class: 'nw-badge' }, [
h('input', { type: 'checkbox', id: 'nw-cred', style: 'vertical-align:middle;margin-right:6px' }),
document.createTextNode(' credentials')
]);
const timeout = h('input', { class: 'nw-input', id: 'nw-timeout', type: 'number', value: '30000', min: '1', step: '1', style: 'width:120px' });
rowTop.appendChild(ct);
rowTop.appendChild(credWrap);
rowTop.appendChild(timeout);
const headers = h('textarea', { class: 'nw-ta', id: 'nw-headers', placeholder: 'Authorization: Bearer xyz\nX-Demo: 1' });
const bodyLabel = h('div', { class: 'nw-label', text: 'Body' });
const body = h('textarea', { class: 'nw-ta', id: 'nw-body', placeholder: '{"hello":"world"}' });
const rowBtns = h('div', { id: 'nw-row' });
const sample = h('button', { class: 'nw-btn', id: 'nw-sample', text: 'Sample JSON' });
const copyBody = h('button', { class: 'nw-btn', id: 'nw-copy', text: 'Copy Body' });
const saveBtn = h('button', { class: 'nw-btn', id: 'nw-save', text: 'Save Defaults' });
rowBtns.appendChild(sample); rowBtns.appendChild(copyBody); rowBtns.appendChild(saveBtn);
left.appendChild(rowTop);
left.appendChild(headers);
left.appendChild(bodyLabel);
left.appendChild(body);
left.appendChild(rowBtns);
const right = h('div', { class: 'nw-col' });
right.appendChild(h('div', { class: 'nw-label', text: 'Response' }));
const resp = h('textarea', { class: 'nw-ta', id: 'nw-resp', readOnly: true, placeholder: 'Response will appear here...' });
right.appendChild(resp);
const bodyWrap = h('div', { id: 'nw-body' }, [left, right]);
const status = h('span', { id: 'nw-status', text: 'Ready.' });
const toggleMinBtn = h('button', { class: 'nw-btn', id: 'nw-toggle', text: root.classList.contains('min') ? '▲' : '▼', title: 'Minimize/Expand (Alt+`)' });
const hideBtn = h('button', { class: 'nw-btn', id: 'nw-hide', text: 'Hide', title: 'Hide (Alt+\\)' });
const foot = h('div', { id: 'nw-foot' }, [status, toggleMinBtn, hideBtn]);
root.appendChild(resize);
root.appendChild(head);
root.appendChild(bodyWrap);
root.appendChild(foot);
document.body.appendChild(root);
// Launcher (shows when hidden)
const launcher = h('button', { id: 'nw-launcher', class: initiallyVisible ? 'hidden' : '', text: 'Webhook' });
document.body.appendChild(launcher);
// ----- load saved -----
url.value = store.get('url','');
body.value = store.get('body','');
method.value = store.get('method','POST') || 'POST';
ct.value = store.get('contentType','application/json') || 'application/json';
headers.value = store.get('headers','');
(store.get('cred','0') === '1') && (document.getElementById('nw-cred').checked = true);
const savedTO = parseInt(store.get('timeout','30000'),10); if (!isNaN(savedTO)) timeout.value = savedTO;
// ----- helpers -----
const setStatus = (s) => status.textContent = s;
const isJSONish = (s) => {
const t = (s||'').trim();
return (t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'));
};
const parseHeaders = (raw) => {
const out = {};
(raw||'').split('\n').map(l=>l.trim()).filter(Boolean).forEach(line=>{
const i = line.indexOf(':'); if (i>0) { out[line.slice(0,i).trim()] = line.slice(i+1).trim(); }
});
return out;
};
const saveDefaults = () => {
store.set('url', url.value||'');
store.set('body', body.value||'');
store.set('method', method.value||'POST');
store.set('contentType', ct.value||'application/json');
store.set('headers', headers.value||'');
store.set('cred', document.getElementById('nw-cred').checked ? '1' : '0');
const to = parseInt(timeout.value,10); store.set('timeout', isNaN(to) ? '30000' : String(to));
setStatus('Saved.');
};
const beautifyJSON = () => {
if (!isJSONish(body.value)) { setStatus('Body does not look like JSON.'); return; }
try { body.value = JSON.stringify(JSON.parse(body.value), null, 2); }
catch(e){ setStatus('Beautify failed: ' + e.message); }
};
const toggleMin = () => {
root.classList.toggle('min');
store.set('open', root.classList.contains('min') ? '0' : '1');
toggleMinBtn.textContent = root.classList.contains('min') ? '▲' : '▼';
};
const setVisible = (visible) => {
if (visible) {
root.classList.remove('hidden');
launcher.classList.add('hidden');
store.set('visible','1');
} else {
root.classList.add('hidden');
launcher.classList.remove('hidden');
store.set('visible','0');
}
};
// ----- events -----
beautify.addEventListener('click', beautifyJSON);
clear.addEventListener('click', ()=>{ resp.value=''; setStatus('Cleared.'); });
sample.addEventListener('click', ()=>{
body.value = JSON.stringify({ event:'demo', workflow:'test', time:new Date().toISOString(), data:{ foo:'bar', count:1 } }, null, 2);
});
copyBody.addEventListener('click', async ()=>{
try { await navigator.clipboard.writeText(body.value||''); setStatus('Body copied.'); }
catch { setStatus('Clipboard failed.'); }
});
saveBtn.addEventListener('click', saveDefaults);
toggleMinBtn.addEventListener('click', toggleMin);
hideBtn.addEventListener('click', ()=>setVisible(false));
launcher.addEventListener('click', ()=>setVisible(true));
// resize
let dragging=false, startY=0, startH=0;
document.getElementById('nw-resize').addEventListener('mousedown', (e)=>{ dragging=true; startY=e.clientY; startH=root.getBoundingClientRect().height; document.body.style.userSelect='none'; });
window.addEventListener('mouseup', ()=>{ if (dragging){ dragging=false; document.body.style.userSelect=''; store.set('height', String(root.getBoundingClientRect().height)); }});
window.addEventListener('mousemove', (e)=>{ if (dragging && !root.classList.contains('min')) { const nh = Math.max(120, startH + (startY - e.clientY)); root.style.height = nh+'px'; } });
// keyboard
window.addEventListener('keydown', (e)=>{
if (e.altKey && e.key === '`'){ e.preventDefault(); toggleMin(); }
if (e.altKey && e.key === '\\'){ e.preventDefault(); setVisible(root.classList.contains('hidden')); } // Alt+\ toggles hide/show
if ((e.ctrlKey||e.metaKey) && e.key === 'Enter'){ e.preventDefault(); send.click(); }
if (e.altKey && e.key.toLowerCase() === 'j'){ e.preventDefault(); beautifyJSON(); }
});
// send
send.addEventListener('click', async ()=>{
const endpoint = (url.value||'').trim();
if (!endpoint){ setStatus('Please enter a webhook URL.'); url.focus(); return; }
const m = method.value || 'POST';
const ctype = ct.value || 'application/json';
const extra = parseHeaders(headers.value);
const hdrs = Object.assign({ 'Accept':'application/json, text/plain, */*', 'Content-Type': ctype }, extra);
let bodyData = body.value || '';
if (m !== 'GET' && m !== 'HEAD') {
if (ctype.includes('application/json')) {
if (isJSONish(bodyData)) {
try { JSON.parse(bodyData); } catch(e){ setStatus('Invalid JSON: ' + e.message); return; }
} else {
bodyData = JSON.stringify({ value: bodyData });
}
} else if (ctype === 'application/x-www-form-urlencoded') {
const params = new URLSearchParams();
if (isJSONish(bodyData)) {
try { const o = JSON.parse(bodyData); Object.keys(o).forEach(k=>params.append(k, String(o[k]))); }
catch {}
} else {
(bodyData||'').split('\n').forEach(line=>{
const i=line.indexOf('='); if (i>0){ params.append(line.slice(0,i).trim(), line.slice(i+1).trim()); }
});
}
bodyData = params.toString();
}
}
const useCreds = document.getElementById('nw-cred').checked;
const tms = Math.max(1, parseInt(timeout.value,10) || 30000);
const ctl = new AbortController();
const timer = setTimeout(()=>ctl.abort(), tms);
setStatus('Sending...');
send.disabled = true; const t0 = performance.now(); resp.value = '';
try {
const r = await fetch(endpoint, {
method: m,
headers: hdrs,
body: (m==='GET'||m==='HEAD') ? undefined : bodyData,
credentials: useCreds ? 'include' : 'same-origin',
signal: ctl.signal,
mode: 'cors',
cache: 'no-store',
});
const ms = Math.round(performance.now() - t0);
const ctH = r.headers.get('content-type') || '';
let text = '';
try { text = ctH.includes('application/json') ? JSON.stringify(await r.json(), null, 2) : await r.text(); }
catch (e) { text = '[Failed to read response body: ' + e.message + ']'; }
const hdrDump = Array.from(r.headers.entries()).map(([k,v])=>`${k}: ${v}`).join('\n');
resp.value = `// ${r.ok ? 'OK' : 'ERROR'} ${r.status} ${r.statusText} (${ms} ms)\n${hdrDump ? '\n'+hdrDump+'\n' : ''}${text}`;
setStatus(`${r.ok ? 'Success' : 'HTTP '+r.status} in ${ms} ms`);
} catch (e) {
const ms = Math.round(performance.now() - t0);
resp.value = `// Request failed (${ms} ms)\n${e.name}: ${e.message}`;
setStatus('Request failed: ' + e.message);
} finally {
clearTimeout(timer);
send.disabled = false;
// autosave last used
store.set('url', url.value||'');
store.set('body', body.value||'');
store.set('method', method.value||'POST');
store.set('contentType', ct.value||'application/json');
store.set('headers', headers.value||'');
store.set('cred', useCreds ? '1' : '0');
store.set('timeout', String(tms));
}
});
// keep mounted across SPA route changes
const mo = new MutationObserver(()=>{
if (!document.body.contains(root)) document.body.appendChild(root);
if (!document.body.contains(launcher)) document.body.appendChild(launcher);
});
mo.observe(document.documentElement, { childList:true, subtree:true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment