Created
August 28, 2025 00:18
-
-
Save SmugZombie/02ff4220feceb3679aef805efdd6551e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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