release: Mark VIII 1.0
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
async function request(method, path, body) {
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(apiBase + path, opts);
|
||||
if (res.status === 401) {
|
||||
if (on401) on401();
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
const ct = res.headers.get('Content-Type') || '';
|
||||
let data;
|
||||
if (ct.indexOf('json') !== -1) {
|
||||
try { data = await res.json(); }
|
||||
catch (e) { data = null; }
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = data && data.error ? data.error : ('HTTP ' + res.status);
|
||||
const error = new Error(message);
|
||||
error.status = res.status;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
return {
|
||||
setBase: (b) => { apiBase = b; },
|
||||
on401: (fn) => { on401 = fn; },
|
||||
get: (p) => request('GET', p),
|
||||
post: (p, b) => request('POST', p, b === undefined ? {} : b),
|
||||
del: (p, b) => request('DELETE', p, b === undefined ? {} : b),
|
||||
login: async (username, password) => request('POST', '/api/login', { username, password })
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,488 @@
|
||||
'use strict';
|
||||
|
||||
const Theme = (() => {
|
||||
const KEY = 'pw_theme';
|
||||
function apply() {
|
||||
document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark');
|
||||
}
|
||||
function toggle() {
|
||||
localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark');
|
||||
apply();
|
||||
}
|
||||
function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }
|
||||
return { apply, toggle, current, KEY };
|
||||
})();
|
||||
|
||||
const App = (() => {
|
||||
const cfg = window.PAGER_CONFIG || {};
|
||||
const API_BASE = cfg.apiBase || '';
|
||||
const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws');
|
||||
const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs');
|
||||
const PAGER_SCREEN_WS = cfg.pagerScreenWs || (WS_BASE + '/api/pager/display/screen.ws');
|
||||
const PAGER_KEYS_WS = cfg.pagerKeysWs || (WS_BASE + '/api/pager/input/keys.ws');
|
||||
|
||||
const els = {};
|
||||
let currentView = null;
|
||||
let notifications = [];
|
||||
let unreadNotifications = 0;
|
||||
const NOTIFICATIONS_KEY = 'markviii_notifications';
|
||||
|
||||
const railItems = [
|
||||
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
|
||||
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
|
||||
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
|
||||
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
|
||||
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
|
||||
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
|
||||
];
|
||||
const railDividers = new Set(['logging']);
|
||||
|
||||
function keyOf(hash) {
|
||||
const seg = (hash || '#/dashboard').replace(/^#\//, '').split('/')[0];
|
||||
return seg || 'dashboard';
|
||||
}
|
||||
|
||||
function buildRail() {
|
||||
const rail = els.rail;
|
||||
rail.innerHTML = '';
|
||||
function railEntry(it) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'entry';
|
||||
a.href = it.hash;
|
||||
a.title = it.label;
|
||||
a.innerHTML = '<span class="entry-icon">' + PineappleIcons[it.icon] + '</span><span class="entry-text">' + it.label + '</span>';
|
||||
a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
|
||||
return a;
|
||||
}
|
||||
railItems.filter((it) => it.key !== 'settings').forEach((it) => {
|
||||
if (railDividers.has(it.key)) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'entry divider';
|
||||
rail.appendChild(d);
|
||||
}
|
||||
rail.appendChild(railEntry(it));
|
||||
});
|
||||
const foot = document.createElement('div');
|
||||
foot.className = 'rail-footer';
|
||||
const settings = railItems.find((it) => it.key === 'settings');
|
||||
if (settings) foot.appendChild(railEntry(settings));
|
||||
const open = document.createElement('a');
|
||||
open.className = 'entry';
|
||||
open.title = 'Open Menu';
|
||||
open.innerHTML = '<span class="entry-icon">' + PineappleIcons.chevron_right + '</span><span class="entry-text">Open Menu</span>';
|
||||
open.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const openState = localStorage.getItem('pw_rail') === 'open';
|
||||
localStorage.setItem('pw_rail', openState ? 'closed' : 'open');
|
||||
els.rail.classList.toggle('open', !openState);
|
||||
});
|
||||
foot.appendChild(open);
|
||||
rail.appendChild(foot);
|
||||
rail.classList.toggle('open', localStorage.getItem('pw_rail') === 'open');
|
||||
}
|
||||
|
||||
function route() {
|
||||
closeToolbarMenus();
|
||||
const hash = location.hash || '#/dashboard';
|
||||
const name = routes[hash];
|
||||
if (currentView && currentView.destroy) currentView.destroy();
|
||||
els.content.innerHTML = '';
|
||||
if (!name || !views[name]) {
|
||||
const ph = document.createElement('div');
|
||||
ph.className = 'section empty';
|
||||
ph.textContent = 'View not available.';
|
||||
els.content.appendChild(ph);
|
||||
currentView = null;
|
||||
} else {
|
||||
currentView = views[name](els.content);
|
||||
}
|
||||
const key = keyOf(hash);
|
||||
Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => {
|
||||
const href = a.getAttribute('href');
|
||||
a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key));
|
||||
});
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(NOTIFICATIONS_KEY) || '{}');
|
||||
notifications = Array.isArray(stored.items) ? stored.items.slice(0, 30) : [];
|
||||
unreadNotifications = Math.max(0, Number(stored.unread) || 0);
|
||||
} catch (e) {
|
||||
notifications = [];
|
||||
unreadNotifications = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotifications() {
|
||||
try {
|
||||
sessionStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify({
|
||||
items: notifications.slice(0, 30), unread: unreadNotifications
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function notificationTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const today = new Date();
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
if (!els.notificationsList) return;
|
||||
els.notificationsList.innerHTML = '';
|
||||
els.notificationBadge.classList.toggle('hidden', unreadNotifications === 0);
|
||||
els.notificationBadge.textContent = unreadNotifications > 99 ? '99+' : String(unreadNotifications);
|
||||
els.notificationsClear.disabled = notifications.length === 0;
|
||||
if (!notifications.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'notification-empty';
|
||||
empty.textContent = 'No Notifications';
|
||||
els.notificationsList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
notifications.forEach((notice) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'notification-item';
|
||||
const marker = document.createElement('span');
|
||||
marker.className = 'notification-kind ' + (notice.kind || 'info');
|
||||
const message = document.createElement('span');
|
||||
message.className = 'notification-message';
|
||||
message.textContent = notice.message;
|
||||
const time = document.createElement('time');
|
||||
time.className = 'notification-time';
|
||||
time.dateTime = new Date(notice.timestamp).toISOString();
|
||||
time.textContent = notificationTime(notice.timestamp);
|
||||
item.append(marker, message, time);
|
||||
els.notificationsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function addNotification(message, kind) {
|
||||
const normalizedKind = ['error', 'success'].indexOf(kind) === -1 ? 'info' : kind;
|
||||
notifications.unshift({
|
||||
message: String(message).slice(0, 300), kind: normalizedKind, timestamp: Date.now()
|
||||
});
|
||||
notifications = notifications.slice(0, 30);
|
||||
unreadNotifications += 1;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
function toast(msg, kind, options) {
|
||||
if (!options || options.notify !== false) addNotification(msg, kind);
|
||||
const d = document.createElement('div');
|
||||
d.className = 'toast ' + (kind || 'info');
|
||||
d.textContent = msg;
|
||||
els.toasts.appendChild(d);
|
||||
setTimeout(() => d.remove(), 4000);
|
||||
}
|
||||
|
||||
function closeToolbarMenus() {
|
||||
if (!els.notificationsMenu) return;
|
||||
els.notificationsMenu.classList.add('hidden');
|
||||
els.overflowMenu.classList.add('hidden');
|
||||
els.notificationsButton.setAttribute('aria-expanded', 'false');
|
||||
els.overflowButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function toggleToolbarMenu(name) {
|
||||
const menu = name === 'notifications' ? els.notificationsMenu : els.overflowMenu;
|
||||
const button = name === 'notifications' ? els.notificationsButton : els.overflowButton;
|
||||
const opening = menu.classList.contains('hidden');
|
||||
closeToolbarMenus();
|
||||
if (!opening) return;
|
||||
menu.classList.remove('hidden');
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
if (name === 'notifications') {
|
||||
unreadNotifications = 0;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function setInternetStatus(state) {
|
||||
const labels = { checking: 'Checking\u2026', online: 'Online', offline: 'Offline' };
|
||||
els.internetStatus.textContent = labels[state];
|
||||
els.internetStatusDot.className = 'connection-dot ' + state;
|
||||
}
|
||||
|
||||
function checkInternet(announce) {
|
||||
setInternetStatus('checking');
|
||||
return PagerAPI.get('/api/settings/internet')
|
||||
.then((response) => {
|
||||
const online = !!response.data.online;
|
||||
setInternetStatus(online ? 'online' : 'offline');
|
||||
if (announce) toast(online ? 'Internet connection is online' : 'Internet connection is offline', online ? 'success' : 'error');
|
||||
})
|
||||
.catch(() => {
|
||||
setInternetStatus('offline');
|
||||
if (announce) toast('Unable to check the internet connection', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuAction(action) {
|
||||
closeToolbarMenus();
|
||||
if (action === 'help') {
|
||||
location.hash = '#/settings/help';
|
||||
} else if (action === 'updates') {
|
||||
location.hash = '#/settings/advanced';
|
||||
toast('Update controls are available in Advanced settings');
|
||||
} else if (action === 'internet') {
|
||||
checkInternet(true);
|
||||
} else if (action === 'logout') {
|
||||
PagerAPI.post('/api/logout')
|
||||
.then(() => showLogin())
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'));
|
||||
} else if (action === 'reboot') {
|
||||
if (!window.confirm('Reboot Mark VIII now?')) return;
|
||||
PagerAPI.post('/api/settings/reboot')
|
||||
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
|
||||
}
|
||||
}
|
||||
|
||||
function showDock(name) {
|
||||
if (name === 'pager') {
|
||||
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
|
||||
} else {
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
els.login.classList.add('hidden');
|
||||
els.app.classList.remove('hidden');
|
||||
Theme.apply();
|
||||
Live.start();
|
||||
route();
|
||||
checkInternet(false);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
closeToolbarMenus();
|
||||
if (typeof Live !== 'undefined') Live.stop();
|
||||
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
els.app.classList.add('hidden');
|
||||
els.login.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function init() {
|
||||
els.login = document.getElementById('login-screen');
|
||||
els.app = document.getElementById('app');
|
||||
els.rail = document.getElementById('rail');
|
||||
els.content = document.getElementById('content');
|
||||
els.toasts = document.getElementById('toast-container');
|
||||
els.notificationsButton = document.getElementById('notifications-btn');
|
||||
els.notificationsMenu = document.getElementById('notifications-menu');
|
||||
els.notificationsList = document.getElementById('notifications-list');
|
||||
els.notificationsClear = document.getElementById('notifications-clear');
|
||||
els.notificationBadge = document.getElementById('notification-badge');
|
||||
els.overflowButton = document.getElementById('overflow-btn');
|
||||
els.overflowMenu = document.getElementById('overflow-menu');
|
||||
els.internetStatus = document.getElementById('internet-status');
|
||||
els.internetStatusDot = document.getElementById('internet-status-dot');
|
||||
|
||||
els.notificationsButton.innerHTML = PineappleIcons.notifications;
|
||||
document.getElementById('terminal-btn').innerHTML = PineappleIcons.terminal;
|
||||
document.getElementById('pager-btn').innerHTML = PineappleIcons.pager;
|
||||
els.overflowButton.innerHTML = PineappleIcons.more_vert;
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-icon]'), (icon) => {
|
||||
icon.innerHTML = PineappleIcons[icon.getAttribute('data-icon')] || '';
|
||||
});
|
||||
loadNotifications();
|
||||
renderNotifications();
|
||||
|
||||
els.notificationsButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleToolbarMenu('notifications');
|
||||
});
|
||||
els.overflowButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleToolbarMenu('overflow');
|
||||
});
|
||||
els.notificationsMenu.addEventListener('click', (event) => event.stopPropagation());
|
||||
els.overflowMenu.addEventListener('click', (event) => event.stopPropagation());
|
||||
els.notificationsClear.addEventListener('click', () => {
|
||||
notifications = [];
|
||||
unreadNotifications = 0;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
});
|
||||
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
|
||||
});
|
||||
document.addEventListener('click', closeToolbarMenus);
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-button');
|
||||
const pw = document.getElementById('login-password').value;
|
||||
document.getElementById('login-error').textContent = '';
|
||||
btn.disabled = true;
|
||||
PagerAPI.login('root', pw)
|
||||
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
|
||||
.catch((err) => {
|
||||
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
|
||||
? 'Login failed.' : 'Invalid credentials.';
|
||||
})
|
||||
.finally(() => { btn.disabled = false; });
|
||||
});
|
||||
|
||||
document.getElementById('terminal-btn').addEventListener('click', () => {
|
||||
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
|
||||
showDock('terminal');
|
||||
Term.toggle();
|
||||
});
|
||||
document.getElementById('terminal-close').addEventListener('click', () => {
|
||||
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
|
||||
showDock('terminal');
|
||||
Term.toggle();
|
||||
});
|
||||
document.getElementById('pager-btn').addEventListener('click', () => {
|
||||
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
|
||||
showDock('pager');
|
||||
Pager.toggle();
|
||||
});
|
||||
document.getElementById('pager-close').addEventListener('click', () => {
|
||||
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
|
||||
showDock('pager');
|
||||
Pager.toggle();
|
||||
});
|
||||
window.addEventListener('hashchange', route);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeToolbarMenus();
|
||||
return;
|
||||
}
|
||||
if (localStorage.getItem('pw_hotkeys') === 'false') return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const map = { d: '#/dashboard', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' };
|
||||
if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; }
|
||||
else if (e.key === '`') {
|
||||
if (typeof Term === 'undefined') return;
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
Term.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
PagerAPI.setBase(API_BASE);
|
||||
PagerAPI.on401(() => showLogin());
|
||||
buildRail();
|
||||
Theme.apply();
|
||||
checkSession();
|
||||
}
|
||||
|
||||
function checkSession() {
|
||||
PagerAPI.get('/api/api_ping')
|
||||
.then(() => showApp())
|
||||
.catch(() => showLogin());
|
||||
}
|
||||
|
||||
const routes = {
|
||||
'#/dashboard': 'dashboard',
|
||||
'#/pineap': 'pineap',
|
||||
'#/pineap/open': 'pineap_open',
|
||||
'#/pineap/evilwpa': 'pineap_evilwpa',
|
||||
'#/pineap/impersonation': 'pineap_impersonation',
|
||||
'#/pineap/clients': 'pineap_clients',
|
||||
'#/pineap/filtering': 'pineap_filtering',
|
||||
'#/recon': 'recon',
|
||||
'#/recon/handshakes': 'recon_handshakes',
|
||||
'#/logging': 'logging',
|
||||
'#/logging/system': 'logging_system',
|
||||
'#/modules': 'modules',
|
||||
'#/modules/online': 'modules_online',
|
||||
'#/modules/running': 'modules_running',
|
||||
'#/modules/develop': 'modules_develop',
|
||||
'#/settings': 'settings',
|
||||
'#/settings/networking': 'settings_networking',
|
||||
'#/settings/wifi': 'settings_wifi',
|
||||
'#/settings/led': 'settings_led',
|
||||
'#/settings/advanced': 'settings_advanced',
|
||||
'#/settings/help': 'settings_help'
|
||||
};
|
||||
|
||||
return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS,
|
||||
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
|
||||
keyOf, railItems, go: (h) => { location.hash = h; },
|
||||
get key() { return keyOf(location.hash); } };
|
||||
})();
|
||||
|
||||
const Live = (() => {
|
||||
let ws = null;
|
||||
let ever = false;
|
||||
let poll = null;
|
||||
const subs = [];
|
||||
let timer = null;
|
||||
function stopPoll() {
|
||||
if (poll) { clearInterval(poll); poll = null; }
|
||||
}
|
||||
function start() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
stopPoll();
|
||||
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
||||
catch (e) { fallback(); return; }
|
||||
ws.onopen = () => { ever = true; };
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
subs.forEach((fn) => fn(msg));
|
||||
updateBar(msg);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
clearTimeout(timer);
|
||||
if (ever) timer = setTimeout(start, 5000);
|
||||
else fallback();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
}
|
||||
function stop() {
|
||||
ever = false;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
stopPoll();
|
||||
if (ws) {
|
||||
const active = ws;
|
||||
ws = null;
|
||||
active.onclose = null;
|
||||
try { active.close(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
function fallback() {
|
||||
stopPoll();
|
||||
const seconds = Math.max(2, Math.min(120, parseInt(localStorage.getItem('pw-poll') || '5', 10) || 5));
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
const r = await PagerAPI.get('/api/status');
|
||||
const msg = { type: 'tick', status: r.data, clients: r.data.clients };
|
||||
subs.forEach((fn) => fn(msg));
|
||||
updateBar(msg);
|
||||
} catch (e) {}
|
||||
}, seconds * 1000);
|
||||
}
|
||||
function updateBar(msg) {
|
||||
const b = (msg.status || {}).battery || {};
|
||||
const n = (msg.clients || []).length;
|
||||
const el = document.getElementById('live-status');
|
||||
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
|
||||
}
|
||||
function onTick(fn) {
|
||||
subs.push(fn);
|
||||
return () => {
|
||||
const i = subs.indexOf(fn);
|
||||
if (i !== -1) subs.splice(i, 1);
|
||||
};
|
||||
}
|
||||
return { start, stop, onTick };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const MiniChart = (() => {
|
||||
function draw(canvas, series, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = 140 * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = 140;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
|
||||
const pad = 8;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = pad + (h - pad * 2) * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
series.forEach((s) => {
|
||||
const pts = s.points;
|
||||
if (!pts || pts.length < 2) return;
|
||||
ctx.strokeStyle = s.color || '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
pts.forEach((v, i) => {
|
||||
if (v == null) { started = false; return; }
|
||||
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (v / max);
|
||||
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
const last = pts[pts.length - 1];
|
||||
if (last != null) {
|
||||
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (last / max);
|
||||
ctx.fillStyle = s.color || '#1976d2';
|
||||
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(canvas, segments, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const legendH = o.legend ? 22 : 0;
|
||||
const H = (o.height || 160) + legendH;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = o.height || 160;
|
||||
ctx.clearRect(0, 0, w, H);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(w, h) / 2 - 8;
|
||||
const hole = (o.hole == null ? 0.65 : o.hole) * r;
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
if (!total) {
|
||||
ctx.strokeStyle = '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
return;
|
||||
}
|
||||
let a0 = -Math.PI / 2;
|
||||
segments.forEach((seg) => {
|
||||
const a1 = a0 + (seg.value / total) * Math.PI * 2;
|
||||
ctx.fillStyle = seg.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, a0, a1);
|
||||
ctx.arc(cx, cy, hole, a1, a0, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
a0 = a1;
|
||||
});
|
||||
ctx.strokeStyle = o.stroke || '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
if (o.legend) {
|
||||
ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
const dots = segments.filter((s) => s.value > 0);
|
||||
let tw = 0;
|
||||
dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; });
|
||||
tw = Math.max(tw - 8, 0);
|
||||
let x = (w - tw) / 2;
|
||||
const ly = h + 13;
|
||||
dots.forEach((s) => {
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(s.label, x + 12, ly);
|
||||
x += 16 + ctx.measureText(s.label).width + 8;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bar(canvas, items, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const H = o.height || 160;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!items || !items.length) return;
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
const padB = 16, padT = 8, padL = 6, padR = 6;
|
||||
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||
const bw = plotW / items.length;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = padT + plotH * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
|
||||
}
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value / max * plotH;
|
||||
const x = padL + bw * i + bw * 0.15;
|
||||
const wd = bw * 0.7;
|
||||
const y = padT + plotH - bh;
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(x, y, wd, bh);
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4);
|
||||
});
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar };
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
window.PAGER_CONFIG = {
|
||||
apiBase: '',
|
||||
wsBase: '',
|
||||
terminalWs: '',
|
||||
pagerScreenWs: '',
|
||||
pagerKeysWs: ''
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
window.PineappleIcons = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
|
||||
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
|
||||
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
|
||||
logging: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
|
||||
modules: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
|
||||
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
|
||||
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
|
||||
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
|
||||
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
|
||||
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
|
||||
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
|
||||
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
|
||||
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
|
||||
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
|
||||
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
|
||||
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
|
||||
question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
|
||||
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>',
|
||||
notifications: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,22A2,2 0 0,0 14,20H10A2,2 0 0,0 12,22M18,16V11C18,7.93 16.36,5.36 13.5,4.68V4A1.5,1.5 0 0,0 10.5,4V4.68C7.63,5.36 6,7.92 6,11V16L4,18V19H20V18L18,16Z"/></svg>',
|
||||
pager: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17,1H7A2,2 0 0,0 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3A2,2 0 0,0 17,1M17,19H7V5H17V19M9,7H15V9H9V7M9,11H15V13H9V11Z"/></svg>',
|
||||
more_vert: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,8A2,2 0 1,0 12,4A2,2 0 0,0 12,8M12,10A2,2 0 1,0 12,14A2,2 0 0,0 12,10M12,16A2,2 0 1,0 12,20A2,2 0 0,0 12,16Z"/></svg>',
|
||||
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
|
||||
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
|
||||
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>'
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
'use strict';
|
||||
|
||||
const Pager = (() => {
|
||||
const SCREEN_WIDTH = 480;
|
||||
const SCREEN_HEIGHT = 222;
|
||||
const FB_STRIDE = SCREEN_WIDTH * 4;
|
||||
const PAGER_WIDTH = 745;
|
||||
|
||||
const KEY_MAP = {
|
||||
'LEFT.png': 'ArrowLeft',
|
||||
'UP.png': 'ArrowUp',
|
||||
'RIGHT.png': 'ArrowRight',
|
||||
'DOWN.png': 'ArrowDown',
|
||||
'A_Button.png': 'Enter',
|
||||
'B_Button.png': 'Escape'
|
||||
};
|
||||
|
||||
let panel = null;
|
||||
let bodyEl = null;
|
||||
let table = null;
|
||||
let pager = null;
|
||||
let canvas = null;
|
||||
let screenerr = null;
|
||||
let keyws = null;
|
||||
let screenws = null;
|
||||
|
||||
function ensure() {
|
||||
if (table) return;
|
||||
panel = document.getElementById('pager-panel');
|
||||
bodyEl = document.getElementById('pager-body');
|
||||
table = document.getElementById('pager_ui');
|
||||
pager = document.getElementById('pager');
|
||||
canvas = document.getElementById('pager_canvas');
|
||||
screenerr = document.getElementById('pager_error');
|
||||
table.querySelectorAll('img.pager-btn').forEach((img) => {
|
||||
const src = img.getAttribute('src').split('/').pop();
|
||||
const key = KEY_MAP[src];
|
||||
if (!key) return;
|
||||
img.addEventListener('click', () => press(img, key));
|
||||
});
|
||||
const retry = document.getElementById('screen_retry');
|
||||
if (retry) retry.addEventListener('click', () => connect());
|
||||
}
|
||||
|
||||
function press(el, key) {
|
||||
el.classList.add('pressed');
|
||||
setTimeout(() => el.classList.remove('pressed'), 80);
|
||||
sendKey(key);
|
||||
}
|
||||
|
||||
function sendKey(k) {
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
|
||||
}
|
||||
|
||||
function renderRGBAFrame(bytes) {
|
||||
if (bytes.length < FB_STRIDE * SCREEN_HEIGHT) {
|
||||
console.warn('pager frame too small for 480x222', bytes.length);
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
const dst = imageData.data;
|
||||
let di = 0;
|
||||
for (let y = 0; y < SCREEN_HEIGHT; y++) {
|
||||
const rowStart = y * FB_STRIDE;
|
||||
for (let x = 0; x < SCREEN_WIDTH; x++) {
|
||||
const si = rowStart + x * 4;
|
||||
dst[di] = bytes[si];
|
||||
dst[di + 1] = bytes[si + 1];
|
||||
dst[di + 2] = bytes[si + 2];
|
||||
dst[di + 3] = bytes[si + 3];
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
pager.src = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function connect() {
|
||||
disconnect();
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerScreenWs);
|
||||
sock.binaryType = 'arraybuffer';
|
||||
screenws = sock;
|
||||
sock.onopen = () => { screenerr.hidden = true; };
|
||||
sock.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data));
|
||||
else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
|
||||
};
|
||||
sock.onerror = () => { screenerr.hidden = false; };
|
||||
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; };
|
||||
} catch (e) {
|
||||
screenerr.hidden = false;
|
||||
}
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onclose = () => { if (keyws === sock) keyws = null; };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
|
||||
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
|
||||
}
|
||||
|
||||
function applyScale() {
|
||||
if (!bodyEl || !table) return;
|
||||
const availableWidth = bodyEl.clientWidth - 20;
|
||||
const scale = Math.min(1, availableWidth / PAGER_WIDTH);
|
||||
table.style.zoom = String(scale);
|
||||
table.style.marginBottom = '';
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
ensure();
|
||||
if (panel.classList.contains('hidden')) {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('pager-btn').classList.add('active');
|
||||
applyScale();
|
||||
connect();
|
||||
try { pager.focus(); } catch (e) {}
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
document.getElementById('pager-btn').classList.remove('active');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!panel && !panel.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (isOpen()) toggle();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (table && panel && !panel.classList.contains('hidden')) applyScale();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!isOpen()) return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'
|
||||
|| t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const keys = ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Enter', 'Escape'];
|
||||
if (keys.indexOf(e.key) === -1) return;
|
||||
if (e.key.indexOf('Arrow') === 0) e.preventDefault();
|
||||
if (!e.repeat) sendKey(e.key);
|
||||
});
|
||||
|
||||
return { toggle, close, isOpen };
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const Term = (() => {
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let panel = null;
|
||||
|
||||
function ensure() {
|
||||
if (term) return;
|
||||
panel = document.getElementById('terminal-panel');
|
||||
term = new Terminal({ cursorBlink: true, scrollback: 2000, cols: 80, rows: 24 });
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); });
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
ensure();
|
||||
if (panel.classList.contains('hidden')) {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('terminal-btn').classList.add('active');
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
connect();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
document.getElementById('terminal-btn').classList.remove('active');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) return;
|
||||
if (term) term.reset();
|
||||
let sock;
|
||||
try {
|
||||
sock = new WebSocket(App.terminalWs);
|
||||
} catch (e) {
|
||||
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
|
||||
return;
|
||||
}
|
||||
ws = sock;
|
||||
sock.onmessage = (ev) => {
|
||||
if (typeof ev.data === 'string') term.write(ev.data);
|
||||
else ev.data.text().then((t) => term.write(t));
|
||||
};
|
||||
sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!panel && !panel.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (isOpen()) toggle();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (fitAddon && panel && !panel.classList.contains('hidden')) {
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
return { toggle, close, isOpen };
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user