PeerCortex/public/shell.html
Rene Fichtmueller 58bf76fa82 feat: add terminal feedback widget + admin shell
- index-editorial.html: floating \$_ terminal button (bottom-right)
  - macOS-style title bar (traffic light dots), backdrop blur 18px
  - Guided wizard: category → message → name → submit
  - POST /api/feedback with ASN context auto-filled
  - Safe DOM output builder (no innerHTML on user data)

- server.js: feedback API endpoints
  - POST /api/feedback — stores entries to feedback.json
  - GET /api/feedback?token=... — admin read (token-protected)
  - OPTIONS preflight for CORS
  - FEEDBACK_TOKEN + FEEDBACK_FILE constants from .env
  - Host routing: shell.peercortex.org → shell.html

- public/shell.html: full-screen admin terminal
  - login command → token auth via API
  - list / list [category] — tabular overview
  - show <n> — full entry detail
  - stats — bar chart by category + top ASNs
  - export — JSON file download
  - refresh, logout, clear, help
2026-03-29 15:38:24 +02:00

421 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PeerCortex Shell — Feedback Admin</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;background:#0a0a0a;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;line-height:1.7;overflow:hidden}
#shell{display:flex;flex-direction:column;height:100vh;max-width:900px;margin:0 auto;padding:0 1rem}
/* Title bar */
#titlebar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-bottom:1px solid rgba(0,255,65,.12);flex-shrink:0;margin-bottom:.25rem}
.tb-dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex-shrink:0}
#tb-title{flex:1;text-align:center;color:rgba(0,255,65,.45);font-size:.68rem;letter-spacing:.1em}
/* Output */
#output{flex:1;overflow-y:auto;padding:.5rem 0;word-break:break-word}
#output div{padding:.05rem 0}
/* Input bar */
#inputbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-top:1px solid rgba(0,255,65,.12);flex-shrink:0}
#prompt{color:rgba(0,255,65,.55);white-space:nowrap;flex-shrink:0}
#cmd{flex:1;background:transparent;border:none;outline:none;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;caret-color:#00ff41}
/* Scrollbar */
#output::-webkit-scrollbar{width:4px}
#output::-webkit-scrollbar-track{background:transparent}
#output::-webkit-scrollbar-thumb{background:rgba(0,255,65,.2);border-radius:2px}
</style>
</head>
<body>
<div id="shell">
<div id="titlebar">
<span class="tb-dot" style="background:#ff5f56"></span>
<span class="tb-dot" style="background:#ffbd2e"></span>
<span class="tb-dot" style="background:#27c93f"></span>
<span id="tb-title">shell.peercortex.org — admin terminal</span>
</div>
<div id="output"></div>
<div id="inputbar">
<span id="prompt">shell:~$</span>
<input id="cmd" type="text" autocomplete="off" spellcheck="false">
</div>
</div>
<script>
var token = null;
var allEntries = [];
var G = 'color:#27c93f';
var DIM = 'color:rgba(0,255,65,.35)';
var MUT = 'color:rgba(0,255,65,.45)';
var Y = 'color:#ffbd2e';
var R = 'color:rgba(255,100,100,.8)';
var W = 'color:#fff';
// Safe DOM output — no innerHTML on user data
function print(template, fallbackColor) {
var out = document.getElementById('output');
var line = document.createElement('div');
if (fallbackColor) line.style.color = fallbackColor;
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
parts.forEach(function(part) {
var m = part.match(/^<span([^>]*)>(.*?)<\/span>$/);
if (m) {
var sp = document.createElement('span');
var sm = m[1].match(/style="([^"]*)"/);
if (sm) sp.style.cssText = sm[1];
sp.textContent = m[2];
line.appendChild(sp);
} else if (part) {
line.appendChild(document.createTextNode(part));
}
});
out.appendChild(line);
out.scrollTop = out.scrollHeight;
}
function printText(text, color) {
var out = document.getElementById('output');
var line = document.createElement('div');
line.textContent = text;
if (color) line.style.color = color;
out.appendChild(line);
out.scrollTop = out.scrollHeight;
}
function blank() { print(''); }
function clear() { document.getElementById('output').textContent = ''; }
function setPrompt(p) { document.getElementById('prompt').textContent = p; }
// Boot sequence
function boot() {
clear();
token = null;
allEntries = [];
setPrompt('shell:~$');
var lines = [
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
'',
' <span style="' + G + ';font-weight:600">PeerCortex — Feedback Administration Shell</span>',
' <span style="' + MUT + '">Unauthorized access prohibited. All sessions logged.</span>',
'',
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
'',
'Type <span style="' + G + '">login</span> to authenticate.',
''
];
var d = 0;
lines.forEach(function(l){ setTimeout(function(){ print(l); }, d); d += 40; });
}
// Command dispatch
function dispatch(raw) {
var val = raw.trim();
var low = val.toLowerCase();
var args = val.split(/\s+/);
var cmd = args[0].toLowerCase();
// Echo input safely
(function(){
var out = document.getElementById('output');
var d = document.createElement('div');
var sp = document.createElement('span');
sp.style.color = 'rgba(0,255,65,.35)';
sp.textContent = document.getElementById('prompt').textContent;
d.appendChild(sp);
d.appendChild(document.createTextNode(' ' + val));
out.appendChild(d);
out.scrollTop = out.scrollHeight;
})();
if (!token) {
// Unauthenticated mode
if (cmd === 'login') {
doLogin();
} else if (cmd === 'help') {
showHelp(false);
} else if (cmd === 'clear') {
clear();
} else if (val === '') {
// noop
} else {
printText('Permission denied. Type login first.', 'rgba(255,100,100,.75)');
}
return;
}
// Authenticated mode
if (cmd === 'list') {
var cat = args[1] || null;
doList(cat);
} else if (cmd === 'show') {
var idx = parseInt(args[1], 10);
doShow(idx);
} else if (cmd === 'stats') {
doStats();
} else if (cmd === 'export') {
doExport();
} else if (cmd === 'refresh') {
doFetch(function(){ print('<span style="' + G + '">✓ Data refreshed. ' + allEntries.length + ' entries loaded.</span>'); });
} else if (cmd === 'clear') {
clear();
} else if (cmd === 'logout') {
token = null; allEntries = [];
setPrompt('shell:~$');
blank();
printText('Session terminated.', MUT.slice(6));
blank();
printText('Type login to re-authenticate.');
} else if (cmd === 'help') {
showHelp(true);
} else if (val === '') {
// noop
} else {
printText('shell: ' + val + ': command not found', 'rgba(255,100,100,.75)');
}
}
// Login flow
var loginState = 0;
function doLogin() {
loginState = 1;
setPrompt('token:');
blank();
printText('Enter FEEDBACK_TOKEN:');
document.getElementById('cmd').type = 'password';
}
function handleLogin(val) {
document.getElementById('cmd').type = 'text';
loginState = 0;
setPrompt('shell:~$');
blank();
// Verify token
fetch('/api/feedback?token=' + encodeURIComponent(val))
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) {
token = val;
allEntries = d.entries || [];
setPrompt('root@peercortex:~#');
print('<span style="' + G + '">✓ Authenticated. ' + allEntries.length + ' feedback entries loaded.</span>');
blank();
showHelp(true);
} else {
printText('✗ Authentication failed. Wrong token.', 'rgba(255,100,100,.8)');
blank();
}
}).catch(function(){
printText('Network error during authentication.', 'rgba(255,100,100,.8)');
});
}
// Fetch fresh data from server
function doFetch(cb) {
fetch('/api/feedback?token=' + encodeURIComponent(token))
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { allEntries = d.entries || []; if (cb) cb(); }
else { printText('Fetch error: ' + (d.error || 'unknown'), 'rgba(255,100,100,.8)'); }
}).catch(function(){ printText('Network error.', 'rgba(255,100,100,.8)'); });
}
// list [category]
function doList(filterCat) {
var data = allEntries;
if (filterCat) {
var lf = filterCat.toLowerCase();
data = data.filter(function(e){ return e.category && e.category.toLowerCase().indexOf(lf) >= 0; });
}
blank();
if (data.length === 0) {
printText('No entries' + (filterCat ? ' matching "' + filterCat + '"' : '') + '.', MUT.slice(6));
blank();
return;
}
print('<span style="' + DIM + '">── # DATE CATEGORY NAME ASN ──</span>');
data.forEach(function(e, i) {
var realIdx = allEntries.indexOf(e);
var date = e.timestamp ? e.timestamp.slice(0,10) : '?';
var cat = (e.category || 'General').slice(0,18).padEnd(18);
var name = (e.name || 'Anonymous').slice(0,16).padEnd(16);
var asn = e.asn ? ('AS' + e.asn) : '—';
var num = String(realIdx + 1).padStart(3);
(function(entry, n){
var out = document.getElementById('output');
var line = document.createElement('div');
var snr = document.createElement('span');
snr.style.color = 'rgba(0,255,65,.4)';
snr.textContent = num + ' ';
var sdat = document.createElement('span');
sdat.style.color = '#ffbd2e';
sdat.textContent = date + ' ';
var scat = document.createElement('span');
scat.style.color = '#00ff41';
scat.textContent = cat + ' ';
var snm = document.createElement('span');
snm.style.color = 'rgba(0,255,65,.7)';
snm.textContent = name + ' ';
var sasn = document.createElement('span');
sasn.style.color = 'rgba(0,255,65,.45)';
sasn.textContent = asn;
line.appendChild(snr); line.appendChild(sdat); line.appendChild(scat);
line.appendChild(snm); line.appendChild(sasn);
out.appendChild(line);
out.scrollTop = out.scrollHeight;
})(e, realIdx);
});
print('<span style="' + DIM + '">──────────────────────────────────────────────────────────────</span>');
var out2 = document.getElementById('output');
var tot = document.createElement('div');
tot.style.color = 'rgba(0,255,65,.45)';
tot.textContent = data.length + ' entr' + (data.length === 1 ? 'y' : 'ies') + (filterCat ? ' (filtered)' : '') + ' — type show <n> for full message';
out2.appendChild(tot);
out2.scrollTop = out2.scrollHeight;
blank();
}
// show <n>
function doShow(n) {
if (isNaN(n) || n < 1 || n > allEntries.length) {
printText('Usage: show <number> (1' + allEntries.length + ')', 'rgba(255,189,46,.8)');
return;
}
var e = allEntries[n - 1];
blank();
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
(function(){
var fields = [
['ID', e.id || '?'],
['Timestamp', e.timestamp || '?'],
['Category', e.category || 'General'],
['Name', e.name || 'Anonymous'],
['ASN', e.asn ? 'AS' + e.asn : '—'],
['IP', e.ip || '—'],
];
fields.forEach(function(f){
var out = document.getElementById('output');
var d = document.createElement('div');
var lbl = document.createElement('span');
lbl.style.color = 'rgba(0,255,65,.45)';
lbl.textContent = f[0].padEnd(12) + ' ';
var val = document.createElement('span');
val.style.color = '#00ff41';
val.textContent = f[1];
d.appendChild(lbl); d.appendChild(val);
out.appendChild(d);
out.scrollTop = out.scrollHeight;
});
})();
blank();
printText('Message:', 'rgba(0,255,65,.45)');
printText(e.message || '(empty)', '#fff');
blank();
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
blank();
}
// stats
function doStats() {
if (allEntries.length === 0) { printText('No feedback entries yet.', MUT.slice(6)); return; }
var cats = {};
var asns = {};
allEntries.forEach(function(e){
var c = e.category || 'General';
cats[c] = (cats[c] || 0) + 1;
if (e.asn) { asns[e.asn] = (asns[e.asn] || 0) + 1; }
});
blank();
print('<span style="' + G + ';font-weight:600">─── Feedback Statistics ───────────────────</span>');
var out = document.getElementById('output');
var tot = document.createElement('div');
tot.style.color = '#ffbd2e';
tot.textContent = 'Total entries: ' + allEntries.length;
out.appendChild(tot);
blank();
printText('By category:', 'rgba(0,255,65,.45)');
Object.keys(cats).sort(function(a,b){ return cats[b]-cats[a]; }).forEach(function(c){
var d2 = document.createElement('div');
var bar = '█'.repeat(Math.round(cats[c] / allEntries.length * 20));
var sp1 = document.createElement('span');
sp1.style.color = 'rgba(0,255,65,.4)';
sp1.textContent = ' ' + c.padEnd(20);
var sp2 = document.createElement('span');
sp2.style.color = '#27c93f';
sp2.textContent = bar + ' ' + cats[c];
d2.appendChild(sp1); d2.appendChild(sp2);
out.appendChild(d2);
});
var topAsn = Object.keys(asns);
if (topAsn.length > 0) {
blank();
printText('Top ASNs reported:', 'rgba(0,255,65,.45)');
topAsn.sort(function(a,b){ return asns[b]-asns[a]; }).slice(0,5).forEach(function(a){
var d3 = document.createElement('div');
d3.style.color = 'rgba(0,255,65,.7)';
d3.textContent = ' AS' + a + ' — ' + asns[a] + ' report' + (asns[a]===1?'':'s');
out.appendChild(d3);
});
}
out.scrollTop = out.scrollHeight;
blank();
}
// export — trigger JSON file download
function doExport() {
var blob = new Blob([JSON.stringify(allEntries, null, 2)], {type:'application/json'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'peercortex-feedback-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(url);
print('<span style="' + G + '">✓ Export downloaded: ' + allEntries.length + ' entries.</span>');
blank();
}
// help
function showHelp(authed) {
blank();
print('<span style="' + G + ';font-weight:600">Available commands:</span>');
if (!authed) {
print(' <span style="' + G + '">login</span> — authenticate with FEEDBACK_TOKEN');
print(' <span style="' + G + '">clear</span> — clear screen');
} else {
print(' <span style="' + G + '">list</span> — show all feedback entries');
print(' <span style="' + G + '">list [category]</span> — filter by category (bug, feature, design, general)');
print(' <span style="' + G + '">show <n></span> — show full message for entry #n');
print(' <span style="' + G + '">stats</span> — category + ASN statistics');
print(' <span style="' + G + '">export</span> — download all entries as JSON');
print(' <span style="' + G + '">refresh</span> — reload entries from server');
print(' <span style="' + G + '">clear</span> — clear screen');
print(' <span style="' + G + '">logout</span> — end session');
}
blank();
}
// Input handler
document.getElementById('cmd').addEventListener('keydown', function(e){
if (e.key !== 'Enter') return;
var val = this.value;
this.value = '';
if (loginState === 1) {
handleLogin(val.trim());
} else {
dispatch(val);
}
});
// Start
boot();
</script>
</body>
</html>