/**
* admin-terminal.js ? Admin log terminal widget.
*
* Renders a fixed toggle button (bottom-right) visible only to admins.
* Clicking it opens a floating xterm.js panel that:
* - Streams admin BinkStream events as log lines
* - Accepts typed commands ("help" ? "no help available")
*/
(function () {
'use strict';
if (!window.currentUserIsAdmin) {
return;
}
var term = null;
var fitAddon = null;
var inputBuffer = '';
var panelOpen = false;
var initialized = false;
// ?? Stream stats ?????????????????????????????????????????????????????????????
var streamWatch = false;
var streamEventCount = 0;
var streamConnectedAt = null;
var streamLastEventType = null;
var streamLastEventAt = null;
// ?? Persistent state ?????????????????????????????????????????????????????????
// UserStorage automatically namespaces by user ID.
var LS_KEY = 'adminTerminal';
var MAX_LOG = 500;
var MAX_HISTORY = 100;
var outputLog = [];
function saveState() {
try {
UserStorage.setItem(LS_KEY, JSON.stringify({
v: 1,
streamWatch: streamWatch,
panelOpen: panelOpen,
outputLog: outputLog,
history: history
}));
} catch (_) {}
}
function loadState() {
try {
var raw = UserStorage.getItem(LS_KEY);
if (!raw) { return; }
var s = JSON.parse(raw);
if (!s || s.v !== 1) { return; }
streamWatch = !!s.streamWatch;
outputLog = Array.isArray(s.outputLog) ? s.outputLog : [];
history = Array.isArray(s.history) ? s.history : [];
// panelOpen is NOT restored here ? it is managed solely by openPanel()/closePanel().
// The auto-restore IIFE reads it directly from storage before calling openPanel().
} catch (_) {}
}
/** Write a line to the terminal AND persist it to the output log. */
function logLine(str) {
term.writeln(str);
outputLog.push(str);
if (outputLog.length > MAX_LOG) { outputLog = outputLog.slice(-MAX_LOG); }
saveState();
}
var PROMPT_STR = '\x1b[32m>\x1b[0m ';
// ?? History & search state ????????????????????????????????????????????????????
var history = [];
var historyIndex = -1; // -1 = current (unsaved) line
var historyTemp = ''; // saves typed input while navigating history
var cursorPos = 0; // cursor offset within inputBuffer (0 = before first char)
var searchMode = false;
var searchBuffer = '';
var searchMatches = [];
var searchMatchIndex = 0;
// ?? DOM ?????????????????????????????????????????????????????????????????????
var toggle = document.createElement('button');
toggle.id = 'admin-term-toggle';
toggle.title = 'Admin Terminal';
toggle.innerHTML = '<i class="fa fa-terminal"></i>';
toggle.setAttribute('aria-label', 'Admin Terminal');
document.body.appendChild(toggle);
var panel = document.createElement('div');
panel.id = 'admin-term-panel';
panel.setAttribute('aria-hidden', 'true');
panel.innerHTML =
'<div id="admin-term-header">' +
'<span><i class="fa fa-terminal" style="margin-right:6px"></i>Admin Terminal</span>' +
'<span id="admin-term-status" title="Stream: connecting...">● --</span>' +
'<button id="admin-term-close" aria-label="Close">×</button>' +
'</div>' +
'<div id="admin-term-body"></div>';
document.body.appendChild(panel);
// ?? Styles ???????????????????????????????????????????????????????????????????
var style = document.createElement('style');
style.textContent = [
'#admin-term-toggle {',
' position: fixed; bottom: 18px; right: 18px; z-index: 9000;',
' width: 40px; height: 40px; border-radius: 50%;',
' background: #1a1a2e; color: #00d4ff; border: 1px solid #00d4ff;',
' cursor: pointer; font-size: 15px; line-height: 1;',
' box-shadow: 0 2px 8px rgba(0,0,0,0.4);',
' transition: background 0.2s, color 0.2s;',
'}',
'#admin-term-toggle:hover { background: #00d4ff; color: #1a1a2e; }',
'#admin-term-panel {',
' position: fixed; bottom: 70px; right: 18px; z-index: 8999;',
' width: 680px; height: 320px;',
' background: #1a1a2e; border: 1px solid #00d4ff;',
' border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,0.6);',
' display: none; flex-direction: column; overflow: hidden;',
'}',
'#admin-term-panel.open { display: flex; }',
'#admin-term-header {',
' display: flex; justify-content: space-between; align-items: center;',
' padding: 4px 10px; background: #0f0f1e;',
' color: #00d4ff; font-size: 12px; font-family: monospace;',
' border-bottom: 1px solid #00d4ff22; flex-shrink: 0;',
'}',
'#admin-term-status {',
' font-family: monospace; font-size: 11px; color: #555;',
' cursor: default; user-select: none;',
' margin-left: auto; margin-right: 8px;',
' transition: color 0.3s;',
'}',
'#admin-term-status.st-ok { color: #22cc66; }',
'#admin-term-status.st-warn { color: #ffd700; }',
'#admin-term-status.st-err { color: #ff5555; }',
'#admin-term-close {',
' background: none; border: none; color: #00d4ff; cursor: pointer;',
' font-size: 18px; line-height: 1; padding: 0 2px;',
'}',
'#admin-term-close:hover { color: #fff; }',
'#admin-term-body { flex: 1; overflow: hidden; padding: 4px; }',
'@media (max-width: 767px) {',
' #admin-term-toggle, #admin-term-panel { display: none !important; }',
'}'
].join('\n');
document.head.appendChild(style);
// ?? Terminal init ????????????????????????????????????????????????????????????
function initTerminal() {
if (initialized) { return; }
initialized = true;
term = new Terminal({
theme: {
background: '#1a1a2e',
foreground: '#c8c8d0',
cursor: '#00d4ff',
cursorAccent:'#1a1a2e',
black: '#1a1a2e',
green: '#00d4ff',
yellow: '#ffd700',
red: '#ff5555',
cyan: '#00cfcf',
white: '#c8c8d0',
brightGreen: '#7ee8fa',
},
fontFamily: '"Cascadia Code", "Fira Code", "Courier New", monospace',
fontSize: 12,
rows: 16,
scrollback: 500,
cursorBlink: true,
convertEol: true,
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('admin-term-body'));
fitAddon.fit();
loadState();
if (outputLog.length > 0) {
outputLog.forEach(function (line) { term.writeln(line); });
term.writeln('\x1b[90m--- reconnected ' + new Date().toLocaleTimeString() + ' ---\x1b[0m');
} else {
term.writeln('\x1b[32mBinktermPHP Admin Terminal\x1b[0m');
term.writeln('\x1b[90mType "help" for available commands.\x1b[0m');
}
if (streamWatch) {
term.writeln('\x1b[90mStream watch is \x1b[32mon\x1b[90m.\x1b[0m');
}
writePrompt();
term.onData(handleInput);
wireEvents();
}
// ?? Input handling ???????????????????????????????????????????????????????????
function writePrompt() {
term.write('\r\n' + PROMPT_STR);
}
/**
* Redraw the input line with `content`, leaving the terminal cursor at `pos`
* characters into the buffer (0 = immediately after the prompt).
*/
function writeLineAt(content, pos) {
var s = '\r\x1b[2K' + PROMPT_STR + content;
var moveLeft = content.length - pos;
if (moveLeft > 0) { s += '\x1b[' + moveLeft + 'D'; }
term.write(s);
inputBuffer = content;
cursorPos = pos;
}
/** Replace the current terminal line with the normal prompt + content, cursor at end. */
function replaceCurrentLine(content) {
writeLineAt(content, content.length);
}
/** Rebuild search match list from the current searchBuffer. */
function updateSearchMatches() {
searchMatches = [];
if (searchBuffer === '') { return; }
for (var i = history.length - 1; i >= 0; i--) {
if (history[i].indexOf(searchBuffer) !== -1) {
searchMatches.push(history[i]);
}
}
searchMatchIndex = 0;
}
/** Redraw the reverse-search prompt line. */
function renderSearchPrompt() {
var match = searchMatches.length > 0 ? searchMatches[searchMatchIndex] : '';
var color = (searchBuffer !== '' && searchMatches.length === 0) ? '\x1b[31m' : '\x1b[36m';
term.write('\r\x1b[2K' + color + '(reverse-i-search)\x1b[0m`' + searchBuffer + '\': ' + match);
}
/** Accept the current search match: place it on the line and execute. */
function acceptSearch() {
var match = searchMatches.length > 0 ? searchMatches[searchMatchIndex] : '';
searchMode = false;
searchBuffer = '';
inputBuffer = '';
cursorPos = 0;
historyIndex = -1;
if (match !== '') {
term.write('\r\x1b[2K' + PROMPT_STR + match);
term.writeln('');
// Push echo to log without writing to terminal ? already on screen from term.write above.
outputLog.push(PROMPT_STR + match);
if (outputLog.length > MAX_LOG) { outputLog = outputLog.slice(-MAX_LOG); }
pushHistory(match);
runCommand(match);
} else {
term.write('\r\x1b[2K' + PROMPT_STR);
}
writePrompt();
}
/** Cancel search, restore whatever was being typed before Ctrl-R. */
function cancelSearch() {
searchMode = false;
searchBuffer = '';
// inputBuffer was preserved before search started; restore cursor at end
cursorPos = inputBuffer.length;
term.write('\r\x1b[2K' + PROMPT_STR + inputBuffer);
}
function pushHistory(cmd) {
if (history.length === 0 || history[history.length - 1] !== cmd) {
history.push(cmd);
if (history.length > MAX_HISTORY) { history = history.slice(-MAX_HISTORY); }
saveState();
}
}
function writeRawTerminalOutput(text) {
if (!text) { return; }
var normalized = String(text).replace(/\r?\n/g, '\r\n');
term.write(normalized.endsWith('\r\n') ? normalized : normalized + '\r\n');
}
function handleInput(data) {
if (searchMode) {
handleSearchInput(data);
return;
}
if (data === '\r') {
var cmd = inputBuffer.trim();
inputBuffer = '';
cursorPos = 0;
historyIndex = -1;
if (cmd !== '') {
term.writeln('');
// Push echo to log without writing to terminal ? it's already on screen from typing.
outputLog.push(PROMPT_STR + cmd);
if (outputLog.length > MAX_LOG) { outputLog = outputLog.slice(-MAX_LOG); }
pushHistory(cmd);
runCommand(cmd);
} else {
term.writeln('');
}
writePrompt();
} else if (data === '\x7f' || data === '\x08') {
// Backspace ? delete character before cursor
if (cursorPos > 0) {
var newBuf = inputBuffer.slice(0, cursorPos - 1) + inputBuffer.slice(cursorPos);
if (cursorPos === inputBuffer.length) {
// Cursor at end ? simple in-place erase
inputBuffer = newBuf;
cursorPos--;
term.write('\b \b');
} else {
writeLineAt(newBuf, cursorPos - 1);
}
}
} else if (data === '\x03') {
// Ctrl-C
inputBuffer = '';
cursorPos = 0;
historyIndex = -1;
term.writeln('^C');
writePrompt();
} else if (data === '\x01') {
// Ctrl-A ? go to beginning of line
if (cursorPos > 0) {
term.write('\x1b[' + cursorPos + 'D');
cursorPos = 0;
}
} else if (data === '\x05') {
// Ctrl-E ? go to end of line
var remaining = inputBuffer.length - cursorPos;
if (remaining > 0) {
term.write('\x1b[' + remaining + 'C');
cursorPos = inputBuffer.length;
}
} else if (data === '\x12') {
// Ctrl-R ? enter reverse search
searchMode = true;
searchBuffer = '';
searchMatches = [];
searchMatchIndex = 0;
renderSearchPrompt();
} else if (data === '\x1b[A') {
// Up arrow
if (history.length === 0) { return; }
if (historyIndex === -1) {
historyTemp = inputBuffer;
historyIndex = history.length - 1;
} else if (historyIndex > 0) {
historyIndex--;
}
replaceCurrentLine(history[historyIndex]);
} else if (data === '\x1b[B') {
// Down arrow
if (historyIndex === -1) { return; }
if (historyIndex < history.length - 1) {
historyIndex++;
replaceCurrentLine(history[historyIndex]);
} else {
historyIndex = -1;
replaceCurrentLine(historyTemp);
}
} else if (data === '\x1b[D') {
// Left arrow ? move cursor left
if (cursorPos > 0) {
cursorPos--;
term.write('\x1b[D');
}
} else if (data === '\x1b[C') {
// Right arrow ? move cursor right
if (cursorPos < inputBuffer.length) {
cursorPos++;
term.write('\x1b[C');
}
} else if (data >= ' ') {
// Insert character at cursor position
var newBuf = inputBuffer.slice(0, cursorPos) + data + inputBuffer.slice(cursorPos);
if (cursorPos === inputBuffer.length) {
// Appending at end ? just write the character
inputBuffer = newBuf;
cursorPos++;
term.write(data);
} else {
writeLineAt(newBuf, cursorPos + 1);
}
}
}
function handleSearchInput(data) {
if (data === '\r') {
acceptSearch();
} else if (data === '\x1b' || data === '\x03') {
// Escape or Ctrl-C ? cancel
cancelSearch();
} else if (data === '\x12') {
// Ctrl-R ? cycle to next match
if (searchMatches.length > 1) {
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
renderSearchPrompt();
}
} else if (data === '\x7f' || data === '\x08') {
// Backspace
if (searchBuffer.length > 0) {
searchBuffer = searchBuffer.slice(0, -1);
updateSearchMatches();
renderSearchPrompt();
}
} else if (data >= ' ') {
searchBuffer += data;
updateSearchMatches();
renderSearchPrompt();
} else if (data === '\x1b[A' || data === '\x1b[B') {
// Arrow keys cancel search and return to normal mode
cancelSearch();
}
}
var COMMANDS = [
{
cmd: 'clear',
desc: 'Clear terminal output and saved log',
run: function () {
outputLog = [];
saveState();
term.clear();
}
},
{
cmd: 'help',
desc: 'Show this help',
run: function () {
logLine('\x1b[1mCommands\x1b[0m');
COMMANDS.forEach(function (c) {
if (c.hidden) { return; }
logLine(' \x1b[96m' + c.cmd + '\x1b[0m ? ' + c.desc);
});
}
},
{
cmd: 'stream',
desc: 'Show stream status (mode, events received, last event)',
run: function () {
var bs = window.BinkStream;
var mode = bs ? bs.getMode() : 'unavailable';
var modeColors = { ws: '\x1b[32m', sse: '\x1b[33m', poll: '\x1b[31m' };
var modeColor = modeColors[mode] || '\x1b[90m';
var cursor = '', initCursor = '';
try { cursor = UserStorage.getItem('binkstream_cursor') || ''; } catch (_) {}
try { initCursor = sessionStorage.getItem('binkstream_cursor_init') || ''; } catch (_) {}
term.writeln('\x1b[1mStream Status\x1b[0m');
term.writeln(' Mode ' + modeColor + mode + '\x1b[0m');
term.writeln(' Cursor ' + (cursor ? '\x1b[37m' + cursor + '\x1b[0m' : '\x1b[90m-\x1b[0m'));
term.writeln(' Init cursor ' + (initCursor ? '\x1b[37m' + initCursor + '\x1b[0m' : '\x1b[90m-\x1b[0m'));
term.writeln(' Watch ' + (streamWatch ? '\x1b[32mon\x1b[0m' : '\x1b[90moff\x1b[0m'));
term.writeln(' Events recv \x1b[37m' + streamEventCount + '\x1b[0m');
term.writeln(' Connected at \x1b[37m' + (streamConnectedAt ? streamConnectedAt.toLocaleTimeString() : '-') + '\x1b[0m');
term.writeln(' Last event \x1b[37m' + (streamLastEventType || '-') + '\x1b[0m'
+ (streamLastEventAt ? ' \x1b[90m' + streamLastEventAt.toLocaleTimeString() + '\x1b[0m' : ''));
}
},
{
cmd: 'who',
desc: 'Show who is currently online',
run: function () {
logLine('\x1b[90mFetching online users?\x1b[0m');
fetch('/api/whosonline', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
var users = data.users || [];
// Erase the "fetching" line and replace with results
term.write('\r\x1b[A\x1b[2K');
if (users.length === 0) {
term.write('\r\x1b[2K');
logLine('\x1b[90mNo users online.\x1b[0m');
} else {
logLine('\x1b[1mOnline (' + users.length + ')\x1b[0m');
users.forEach(function (u) {
var svc = u.service ? '\x1b[90m[' + u.service + ']\x1b[0m ' : '';
var loc = u.location ? ' \x1b[90m(' + u.location + ')\x1b[0m' : '';
var act = u.activity ? ' \x1b[90m? ' + u.activity + '\x1b[0m' : '';
logLine(' ' + svc + '\x1b[96m' + u.username + '\x1b[0m' + loc + act);
});
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
term.write('\r\x1b[A\x1b[2K');
logLine('\x1b[31mFailed to fetch online users.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'finger',
desc: 'finger <username> ? Show user info and online status',
run: function (parts) {
var username = parts.slice(1).join(' ');
if (!username) {
logLine('\x1b[31mUsage: finger <username>\x1b[0m');
return;
}
logLine('\x1b[90mLooking up ' + escapeForTerminal(username) + '?\x1b[0m');
fetch('/admin/api/finger?username=' + encodeURIComponent(username), { credentials: 'same-origin' })
.then(function (r) {
if (r.status === 404) { throw new Error('not_found'); }
if (!r.ok) { throw new Error('error'); }
return r.json();
})
.then(function (u) {
term.write('\r\x1b[A\x1b[2K');
var badges = '';
if (u.is_admin) { badges += ' \x1b[33m[admin]\x1b[0m'; }
if (!u.is_active){ badges += ' \x1b[31m[inactive]\x1b[0m'; }
logLine('\x1b[1m\x1b[96m' + u.username + '\x1b[0m' + badges);
if (u.real_name) { logLine(' Real name ' + u.real_name); }
if (u.location) { logLine(' Location ' + u.location); }
if (u.fidonet_address) { logLine(' FTN addr ' + u.fidonet_address); }
if (u.last_login) {
logLine(' Last login ' + new Date(u.last_login).toLocaleString());
}
if (u.online && u.online.length > 0) {
logLine(' \x1b[32mCurrently online\x1b[0m (' + u.online.length + ' session' + (u.online.length > 1 ? 's' : '') + ')');
u.online.forEach(function (s) {
var line = ' \x1b[90m[' + (s.service || 'web') + ']\x1b[0m';
if (s.activity) { line += ' ' + s.activity; }
if (s.last_activity) { line += ' \x1b[90m' + new Date(s.last_activity).toLocaleTimeString() + '\x1b[0m'; }
logLine(line);
});
} else {
logLine(' \x1b[90mNot currently online\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function (e) {
term.write('\r\x1b[A\x1b[2K');
logLine(e.message === 'not_found'
? '\x1b[31mUser not found: ' + escapeForTerminal(username) + '\x1b[0m'
: '\x1b[31mFailed to look up user.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'stream watch',
desc: 'Toggle live stream event output on/off',
hidden: true,
run: function () {
streamWatch = !streamWatch;
logLine('Stream watch ' + (streamWatch ? '\x1b[32mon\x1b[0m' : '\x1b[31moff\x1b[0m') + '.');
saveState();
}
},
{
cmd: 'last',
desc: 'Show callers from the past week',
run: function () {
logLine('\x1b[90mFetching recent callers?\x1b[0m');
fetch('/admin/api/last', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
var callers = data.callers || [];
term.write('\r\x1b[A\x1b[2K');
if (callers.length === 0) {
logLine('\x1b[90mNo callers in the past week.\x1b[0m');
} else {
logLine('\x1b[1mCallers ? past 7 days (' + callers.length + ')\x1b[0m');
callers.forEach(function (c) {
var when = c.last_activity ? new Date(c.last_activity) : null;
var timeStr = when ? when.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
var online = c.is_online === true || c.is_online === 't'
? ' \x1b[32m?\x1b[0m' : '';
logLine(' \x1b[96m' + escapeForTerminal(c.username) + '\x1b[0m' + online + ' \x1b[90m' + timeStr + '\x1b[0m');
});
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
term.write('\r\x1b[A\x1b[2K');
logLine('\x1b[31mFailed to fetch callers.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'wall',
desc: 'wall <message> ? Broadcast a message to all online users',
run: function (parts) {
var message = parts.slice(1).join(' ');
if (!message) {
logLine('\x1b[31mUsage: wall <message>\x1b[0m');
return;
}
fetch('/admin/api/wall', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
logLine('\x1b[32mWall message sent.\x1b[0m');
} else {
logLine('\x1b[31mFailed: ' + escapeForTerminal(data.error || 'unknown error') + '\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
logLine('\x1b[31mFailed to send wall message.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'msg',
desc: 'msg <username> <message> ? Send a private message to a specific user (quote username if it contains spaces)',
run: function (parts) {
var username, message;
var cmdRest = parts.slice(1).join(' ');
if (parts[1] && parts[1].charAt(0) === '"') {
// Quoted username: msg "Dark Knight" hello world
var closeQuote = cmdRest.indexOf('"', 1);
if (closeQuote === -1) {
logLine('\x1b[31mUsage: msg "<username>" <message>\x1b[0m');
return;
}
username = cmdRest.slice(1, closeQuote);
message = cmdRest.slice(closeQuote + 1).trim();
} else {
// Unquoted username (no spaces): msg DarkKnight hello world
username = parts[1];
message = parts.slice(2).join(' ');
}
if (!username || !message) {
logLine('\x1b[31mUsage: msg <username> <message> or msg "<username with spaces>" <message>\x1b[0m');
return;
}
fetch('/admin/api/msg', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, message: message })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
logLine('\x1b[32mMessage sent to ' + escapeForTerminal(username) + '.\x1b[0m');
} else {
logLine('\x1b[31mFailed: ' + escapeForTerminal(data.error || 'unknown error') + '\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
logLine('\x1b[31mFailed to send message.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'uplinks',
desc: 'List configured binkp uplink addresses',
run: function () {
fetch('/admin/api/uplinks', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success && data.uplinks.length) {
data.uplinks.forEach(function (u) {
logLine(' \x1b[96m' + escapeForTerminal(u.address) + '\x1b[0m'
+ (u.domain ? ' \x1b[90m' + escapeForTerminal(u.domain) + '\x1b[0m' : ''));
});
} else {
logLine('\x1b[90mNo uplinks configured.\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
logLine('\x1b[31mFailed to fetch uplinks.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'poll',
desc: 'poll [<address>|all] ? Trigger a binkp poll (default: all uplinks)',
run: function (parts) {
var upstream = parts[1] || 'all';
logLine('\x1b[90mPolling ' + escapeForTerminal(upstream) + '...\x1b[0m');
fetch('/admin/api/poll', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upstream: upstream })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success && data.result) {
var out = (data.result.stdout || '').trim();
var err = (data.result.stderr || '').trim();
if (out) {
out.split('\n').forEach(function (line) {
logLine('\x1b[37m' + escapeForTerminal(line) + '\x1b[0m');
});
}
if (err) {
err.split('\n').forEach(function (line) {
logLine('\x1b[31m' + escapeForTerminal(line) + '\x1b[0m');
});
}
var code = data.result.exit_code;
logLine(code === 0
? '\x1b[32mPoll completed.\x1b[0m'
: '\x1b[31mPoll exited with code ' + code + '.\x1b[0m');
} else {
logLine('\x1b[31mFailed: ' + escapeForTerminal((data && data.error) || 'unknown error') + '\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function () {
logLine('\x1b[31mFailed to trigger poll.\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
},
{
cmd: 'binktop',
desc: 'Show current binktop RAM usage output',
run: function () {
logLine('\x1b[90mLoading binktop...\x1b[0m');
fetch('/admin/api/ram-usage', { credentials: 'same-origin' })
.then(function (response) {
return response.json().then(function (body) {
if (!response.ok || body.success === false) {
throw body;
}
return body;
});
})
.then(function (data) {
term.write('\r\x1b[A\x1b[2K');
if (data.output) {
writeRawTerminalOutput(data.output);
} else {
logLine('\x1b[90mNo binktop output available.\x1b[0m');
}
term.write(PROMPT_STR + inputBuffer);
})
.catch(function (error) {
term.write('\r\x1b[A\x1b[2K');
logLine('\x1b[31m' + escapeForTerminal((error && error.error) || 'Failed to load binktop.') + '\x1b[0m');
term.write(PROMPT_STR + inputBuffer);
});
}
}
];
function runCommand(cmd) {
var parts = cmd.toLowerCase().split(/\s+/);
var originalParts = cmd.split(/\s+/);
// Try longest match first (e.g. "stream watch" before "stream")
var twoWord = parts.length >= 2 ? parts[0] + ' ' + parts[1] : null;
var entry = (twoWord && COMMANDS.find(function (c) { return c.cmd === twoWord; }))
|| COMMANDS.find(function (c) { return c.cmd === parts[0]; });
if (entry) {
// Pass originalParts so commands that use trailing arguments (like wall/msg)
// receive the message text with its original casing intact.
entry.run(originalParts);
} else {
logLine('\x1b[31mUnknown command: ' + escapeForTerminal(parts[0]) + '\x1b[0m');
}
}
function escapeForTerminal(str) {
return String(str).replace(/[\x00-\x1f\x7f]/g, '');
}
// ?? BinkStream event wiring ??????????????????????????????????????????????????
function writeEventLine(label, color, data) {
// Clear the current input line, write + persist the event, then restore the prompt.
term.write('\r\x1b[K');
var ts = new Date().toLocaleTimeString();
var payload = (data !== null && data !== undefined)
? ' ' + JSON.stringify(data)
: '';
var line = '\x1b[90m' + ts + '\x1b[0m ' + color + label + '\x1b[0m' + payload;
logLine(line);
if (searchMode) {
renderSearchPrompt();
} else {
term.write(PROMPT_STR + inputBuffer);
}
}
// Known SSE event types ? subscribing tells the worker to fetch them from the server.
var SSE_TYPES = [
'binkp_session',
'file_approvals_changed',
'files_changed',
'message_read',
'sse_test',
'dashboard_stats',
'chat_message',
'wall_message',
];
// Delay before showing a reconnecting state ? suppresses flicker on fast reconnects
// (e.g. dev server SSE window=0 which reconnects every ~2 s).
var RECONNECT_WARN_DELAY_MS = 3500;
var reconnectWarnTimer = null;
/** Update the header connection-status indicator. */
function updateStreamStatus(mode) {
var el = document.getElementById('admin-term-status');
if (!el) { return; }
var modeLabels = { ws: 'WS', sse: 'SSE', poll: 'POLL' };
if (mode === 'sse-reconnecting') {
// Don't flash yellow immediately ? SSE reconnects quickly in normal operation.
// Only show the warning state if reconnection takes longer than the threshold.
if (!reconnectWarnTimer) {
reconnectWarnTimer = setTimeout(function () {
reconnectWarnTimer = null;
el.className = 'st-warn';
el.title = 'Stream: SSE reconnecting';
el.innerHTML = '● SSE';
}, RECONNECT_WARN_DELAY_MS);
}
return;
}
// Any non-reconnecting mode cancels the pending warn timer.
if (reconnectWarnTimer) {
clearTimeout(reconnectWarnTimer);
reconnectWarnTimer = null;
}
if (mode === 'reconnecting') {
el.className = 'st-warn';
el.title = 'Stream: WebSocket reconnecting';
el.innerHTML = '● WS';
} else if (modeLabels[mode]) {
el.className = 'st-ok';
el.title = 'Stream: connected via ' + mode.toUpperCase();
el.innerHTML = '● ' + modeLabels[mode];
} else {
el.className = 'st-err';
el.title = 'Stream: disconnected';
el.innerHTML = '● --';
}
}
function wireEvents() {
if (typeof window.BinkStream === 'undefined') { return; }
streamConnectedAt = new Date();
// Subscribe to all known types so the worker fetches them from the SSE stream.
SSE_TYPES.forEach(function (type) {
window.BinkStream.on(type, function () {});
});
// Transport status indicator ? updates header icon, never printed to terminal.
// Seed with the current known mode so the indicator is correct on open.
updateStreamStatus(window.BinkStream.getMode() || '');
window.BinkStream.on('transport', function (data) {
updateStreamStatus(data && data.mode ? data.mode : '');
});
// Wildcard listener ? tracks stats and prints to terminal when watch is on.
// Excludes transport/connected events which are handled by the header indicator.
window.BinkStream.on('*', function (type, data) {
if (type === 'transport' || type === 'connected') { return; }
streamEventCount++;
streamLastEventType = type;
streamLastEventAt = new Date();
if (!streamWatch) { return; }
writeEventLine('[' + type + ']', '\x1b[36m', data);
});
}
// ?? Toggle panel ?????????????????????????????????????????????????????????????
function openPanel() {
panelOpen = true;
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
toggle.classList.add('active');
initTerminal();
saveState();
// Slight delay so the flex layout has settled before fit
setTimeout(function () { if (fitAddon) { fitAddon.fit(); } term.focus(); }, 50);
}
function closePanel() {
panelOpen = false;
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
toggle.classList.remove('active');
saveState();
}
// Ensure state is saved before navigating away.
window.addEventListener('beforeunload', saveState);
// Restore open state from previous session. Deferred so the browser has
// completed layout before xterm tries to measure the container.
setTimeout(function () {
try {
var raw = UserStorage.getItem(LS_KEY);
if (raw) {
var s = JSON.parse(raw);
if (s && s.v === 1 && s.panelOpen) { openPanel(); }
}
} catch (_) {}
}, 0);
toggle.addEventListener('click', function () {
if (panelOpen) { closePanel(); } else { openPanel(); }
});
document.getElementById('admin-term-close').addEventListener('click', closePanel);
// Close on Escape (but not when reverse-search is active ? that cancels search instead)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panelOpen && !searchMode) { closePanel(); }
});
// Resize
window.addEventListener('resize', function () {
if (panelOpen && fitAddon) { fitAddon.fit(); }
});
}());
|